Repository: vitalik/django-ninja
Pull request #1423 (2025-03-14 16:06:42)

Fix for request.FILES Population on Non-POST Methods
This PR addresses a longstanding [Django issue](https://code.djangoproject.com/ticket/12635) where request.FILES is only populated when the request method is POST, ignoring file uploads for other HTTP methods.

While this behavior may have aligned with how browsers handled file uploads in the past, modern browsers and HTTP clients allow file uploads with any method.

This PR introduces a check for non-POST methods that accept file uploads. If such a request is detected, Django-Ninja will raise an error with a suggestion to enable a specific middleware that corrects this behavior by ensuring request.FILES is properly populated.

can be disabled with `settings.NINJA_FIX_REQUEST_FILES_METHODS = []`

CC @baseplate-admin  (https://github.com/baseplate-admin/ninja_put_patch_file_upload_middleware)



Patch:
diff --git a/ninja/compatibility/files.py b/ninja/compatibility/files.py
new file mode 100644
index 0000000..344c62b
--- /dev/null
+++ b/ninja/compatibility/files.py
@@ -0,0 +1,64 @@
+from typing import Any, List
+
+from asgiref.sync import iscoroutinefunction, sync_to_async
+from django.conf import settings
+from django.http import HttpRequest
+from django.utils.decorators import sync_and_async_middleware
+
+from ninja.conf import settings as ninja_settings
+from ninja.params.models import FileModel
+
+FIX_MIDDLEWARE_PATH: str = "ninja.compatibility.files.fix_request_files_middleware"
+FIX_METHODS = ninja_settings.FIX_REQUEST_FILES_METHODS
+
+
+def need_to_fix_request_files(methods: List[str], params_models: List[Any]) -> bool:
+    has_files_params = any(
+        issubclass(model_class, FileModel) for model_class in params_models
+    )
+    method_needs_fix = bool(set(methods) & FIX_METHODS)
+    middleware_installed = FIX_MIDDLEWARE_PATH in settings.MIDDLEWARE
+    return has_files_params and method_needs_fix and not middleware_installed
+
+
+@sync_and_async_middleware
+def fix_request_files_middleware(get_response: Any) -> Any:
+    """
+    This middleware fixes long historical Django behavior where request.FILES is only
+    populated for POST requests.
+    https://code.djangoproject.com/ticket/12635
+    """
+    if iscoroutinefunction(get_response):
+
+        async def async_middleware(request: HttpRequest) -> Any:
+            if (
+                request.method in FIX_METHODS
+                and request.content_type != "application/json"
+            ):
+                initial_method = request.method
+                request.method = "POST"
+                request.META["REQUEST_METHOD"] = "POST"
+                await sync_to_async(request._load_post_and_files)()
+                request.META["REQUEST_METHOD"] = initial_method
+                request.method = initial_method
+
+            return get_response(request)
+
+        return async_middleware
+    else:
+
+        def sync_middleware(request: HttpRequest) -> Any:
+            if (
+                request.method in FIX_METHODS
+                and request.content_type != "application/json"
+            ):
+                initial_method = request.method
+                request.method = "POST"
+                request.META["REQUEST_METHOD"] = "POST"
+                request._load_post_and_files()
+                request.META["REQUEST_METHOD"] = initial_method
+                request.method = initial_method
+
+            return get_response(request)
+
+        return sync_middleware
diff --git a/ninja/conf.py b/ninja/conf.py
index 1ae570e..71da0b0 100644
--- a/ninja/conf.py
+++ b/ninja/conf.py
@@ -1,5 +1,5 @@
 from math import inf
-from typing import Dict, Optional
+from typing import Dict, Optional, Set
 
 from django.conf import settings as django_settings
 from pydantic import BaseModel, Field
@@ -24,6 +24,10 @@ class Settings(BaseModel):
         alias="NINJA_DEFAULT_THROTTLE_RATES",
     )
 
+    FIX_REQUEST_FILES_METHODS: Set[str] = Field(
+        {"PUT", "PATCH", "DELETE"}, alias="NINJA_FIX_REQUEST_FILES_METHODS"
+    )
+
     class Config:
         from_attributes = True
 
diff --git a/ninja/operation.py b/ninja/operation.py
index 0e4976d..093bc30 100644
--- a/ninja/operation.py
+++ b/ninja/operation.py
@@ -18,6 +18,7 @@ from asgiref.sync import async_to_sync
 from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed
 from django.http.response import HttpResponseBase
 
+from ninja.compatibility.files import FIX_MIDDLEWARE_PATH, need_to_fix_request_files
 from ninja.constants import NOT_SET, NOT_SET_TYPE
 from ninja.errors import (
     AuthenticationError,
@@ -95,6 +96,12 @@ class Operation:
         else:
             self.response_models = {200: self._create_response_model(response)}
 
+        if need_to_fix_request_files(methods, self.models):
+            raise ConfigError(
+                f"Router '{path}' has method(s) {methods}  that require fixing request.FILES. "
+                f"Please add '{FIX_MIDDLEWARE_PATH}' to settings.MIDDLEWARE"
+            )
+
         self.operation_id = operation_id
         self.summary = summary or self.view_func.__name__.title().replace("_", " ")
         self.description = description or self.signature.docstring


Test patch:
diff --git a/tests/test_files.py b/tests/test_files.py
index d90e2ab..5b94805 100644
--- a/tests/test_files.py
+++ b/tests/test_files.py
@@ -5,6 +5,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
 from django.utils.datastructures import MultiValueDict
 
 from ninja import File, NinjaAPI, UploadedFile
+from ninja.errors import ConfigError
 from ninja.testing import TestClient
 
 api = NinjaAPI()
@@ -133,3 +134,13 @@ def test_schema():
 def test_invalid_file():
     with pytest.raises(ValueError):
         UploadedFile._validate("not_a_file", None)
+
+
+def test_files_fix_middleware():
+    api = NinjaAPI()
+
+    with pytest.raises(ConfigError):
+
+        @api.patch("/file1")
+        def patch_with_file(request, file: UploadedFile):
+            return {"name": file.name}