Repository: vitalik/django-ninja
Pull request #1470 (2025-05-26 13:42:14)

Update latest docs


Patch:
diff --git a/docs/docs/guides/input/file-params.md b/docs/docs/guides/input/file-params.md
index 54e3429..6415e33 100644
--- a/docs/docs/guides/input/file-params.md
+++ b/docs/docs/guides/input/file-params.md
@@ -101,3 +101,50 @@ def create_user(request, details: Form[UserDetails], avatar: File[UploadedFile]
     if avatar is not None:
         set_user_avatar(user)
 ```
+
+
+
+Sure! Here’s a Django Ninja documentation-style section for the described behavior and middleware solution:
+
+⸻
+
+## Handling request.FILES in PUT/PATCH Requests
+
+**Problem**
+
+```python
+@api.put("/upload") # !!!!
+def upload(request, file: File[UploadedFile]):
+   ...
+```
+
+For some [historical reasosns Django’s](https://groups.google.com/g/django-users/c/BeBKj_6qNsc) `request.FILES` is populated only for POST requests by default. When using HTTP PUT or PATCH methods with file uploads (e.g., multipart/form-data), request.FILES will not contain uploaded files. This is a known Django behavior, not specific to Django Ninja.
+
+As a result, views expecting files in PUT or PATCH requests may not behave correctly, since request.FILES will be empty.
+
+**Solution**
+
+Django Ninja provides a built-in middleware to automatically fix this behavior:
+`ninja.compatibility.files.fix_request_files_middleware`
+
+This middleware will manually parse multipart/form-data for PUT and PATCH requests and populate request.FILES, making file uploads work as expected across all HTTP methods.
+
+**Usage**
+
+To enable the middleware, add the following to your Django settings:
+
+```python
+MIDDLEWARE = [
+    # ... your existing middleware ...
+    "ninja.compatibility.files.fix_request_files_middleware",
+]
+```
+
+**Auto-detection**
+
+When Django Ninja detects a PUT or PATCH  etc methods with multipart/form-data and expected FILES  - it will throw an error message suggesting you install the compatibility middleware:
+
+
+Note: This middleware does not interfere with normal POST behavior or any other methods.
+
+
diff --git a/docs/docs/guides/response/config-pydantic.md b/docs/docs/guides/response/config-pydantic.md
index aff7a26..eabe8f9 100644
--- a/docs/docs/guides/response/config-pydantic.md
+++ b/docs/docs/guides/response/config-pydantic.md
@@ -19,8 +19,8 @@ from ninja import Schema
 
 
 def to_camel(string: str) -> str:
-    return ''.join(word.capitalize() for word in string.split('_'))
-
+    words = string.split('_')
+    return words[0].lower() + ''.join(word.capitalize() for word in words[1:])
 
 class CamelModelSchema(Schema):
     str_field_name: str
@@ -39,7 +39,7 @@ Keep in mind that when you want modify output for field names (like camel case)
 class UserSchema(ModelSchema):
     class Config:
         model = User
-        model_fields = ["id", "email"]
+        model_fields = ["id", "email", "is_staff"]
         alias_generator = to_camel
         populate_by_name = True  # !!!!!! <--------
 
@@ -55,12 +55,14 @@ results:
 ```JSON
 [
   {
-    "Id": 1,
-    "Email": "tim@apple.com"
+    "id": 1,
+    "email": "tim@apple.com",
+    "isStaff": true
   },
   {
-    "Id": 2,
-    "Email": "sarah@smith.com"
+    "id": 2,
+    "email": "sarah@smith.com",
+    "isStaff": false
   }
   ...
 ]
diff --git a/ninja/__init__.py b/ninja/__init__.py
index f572480..cd5a0f4 100644
--- a/ninja/__init__.py
+++ b/ninja/__init__.py
@@ -1,6 +1,6 @@
 """Django Ninja - Fast Django REST framework"""
 
-__version__ = "1.4.0"
+__version__ = "1.4.1"
 
 
 from pydantic import Field
diff --git a/ninja/compatibility/files.py b/ninja/compatibility/files.py
index 344c62b..9e28db8 100644
--- a/ninja/compatibility/files.py
+++ b/ninja/compatibility/files.py
@@ -42,7 +42,7 @@ def fix_request_files_middleware(get_response: Any) -> Any:
                 request.META["REQUEST_METHOD"] = initial_method
                 request.method = initial_method
 
-            return get_response(request)
+            return await get_response(request)
 
         return async_middleware
     else:
diff --git a/ninja/router.py b/ninja/router.py
index 6e29b0d..d6f96d0 100644
--- a/ninja/router.py
+++ b/ninja/router.py
@@ -1,3 +1,4 @@
+import re
 from typing import (
     TYPE_CHECKING,
     Any,
@@ -320,6 +321,12 @@ class Router:
         include_in_schema: bool = True,
         openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> None:
+        path = re.sub(r"\{uuid:(\w+)\}", r"{uuidstr:\1}", path, flags=re.IGNORECASE)
+        # django by default convert strings to UUIDs
+        # but we want to keep them as strings to let pydantic handle conversion/validation
+        # if user whants UUID object
+        # uuidstr is custom registered converter
+
         if path not in self.path_operations:
             path_view = PathView()
             self.path_operations[path] = path_view
diff --git a/ninja/signature/details.py b/ninja/signature/details.py
index cb79cf1..607b750 100644
--- a/ninja/signature/details.py
+++ b/ninja/signature/details.py
@@ -289,7 +289,7 @@ def is_pydantic_model(cls: Any) -> bool:
         if get_origin(cls) in UNION_TYPES:
             return any(issubclass(arg, pydantic.BaseModel) for arg in get_args(cls))
         return issubclass(cls, pydantic.BaseModel)
-    except TypeError:
+    except TypeError:  # pragma: no cover
         return False
 
 
diff --git a/ninja/signature/utils.py b/ninja/signature/utils.py
index bc04eac..c3993d8 100644
--- a/ninja/signature/utils.py
+++ b/ninja/signature/utils.py
@@ -72,16 +72,11 @@ def get_args_names(func: Callable[..., Any]) -> List[str]:
     return list(inspect.signature(func).parameters.keys())
 
 
-class NinjaUUIDConverter:
+class UUIDStrConverter(UUIDConverter):
     """Return a path converted UUID as a str instead of the standard UUID"""
 
-    regex = UUIDConverter.regex
+    def to_python(self, value: str) -> str:  # type: ignore
+        return value  # return string value instead of UUID
 
-    def to_python(self, value: str) -> str:
-        return value
 
-    def to_url(self, value: Any) -> str:
-        return str(value)
-
-
-register_converter(NinjaUUIDConverter, "uuid")
+register_converter(UUIDStrConverter, "uuidstr")


Test patch:
diff --git a/tests/main.py b/tests/main.py
index 6535036..38ff7a1 100644
--- a/tests/main.py
+++ b/tests/main.py
@@ -174,8 +174,15 @@ def get_path_param_django_uuid(request, item_id: UUID):
     return item_id
 
 
-@router.get("/path/param-django-uuid-str/{uuid:item_id}")
-def get_path_param_django_uuid_str(request, item_id):
+@router.get("/path/param-django-uuid-notype/{uuid:item_id}")
+def get_path_param_django_uuid_notype(request, item_id):
+    # no type annotation defaults to str..............^
+    assert isinstance(item_id, str)
+    return item_id
+
+
+@router.get("/path/param-django-uuid-typestr/{uuid:item_id}")
+def get_path_param_django_uuid_typestr(request, item_id: str):
     assert isinstance(item_id, str)
     return item_id
 
diff --git a/tests/test_misc.py b/tests/test_misc.py
index 3060a5e..355dc88 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -7,7 +7,7 @@ from pydantic import BaseModel
 from ninja import NinjaAPI
 from ninja.constants import NOT_SET
 from ninja.signature.details import is_pydantic_model
-from ninja.signature.utils import NinjaUUIDConverter
+from ninja.signature.utils import UUIDStrConverter
 from ninja.testing import TestClient
 
 
@@ -48,7 +48,7 @@ def test_kwargs():
 
 
 def test_uuid_converter():
-    conv = NinjaUUIDConverter()
+    conv = UUIDStrConverter()
     assert isinstance(conv.to_url(uuid.uuid4()), str)
 
 
diff --git a/tests/test_openapi_schema.py b/tests/test_openapi_schema.py
index f7bf5d6..fe5d084 100644
--- a/tests/test_openapi_schema.py
+++ b/tests/test_openapi_schema.py
@@ -28,7 +28,8 @@ class TypeB(Schema):
 
 
 def to_camel(string: str) -> str:
-    return "".join(word.capitalize() for word in string.split("_"))
+    words = string.split("_")
+    return words[0].lower() + "".join(word.capitalize() for word in words[1:])
 
 
 class Response(Schema):
diff --git a/tests/test_pagination.py b/tests/test_pagination.py
index 2cb8f0e..94b5997 100644
--- a/tests/test_pagination.py
+++ b/tests/test_pagination.py
@@ -462,7 +462,7 @@ def test_case10_max_page_size():
     response = client.get("/items_10?page=2&page_size=30").json()
     assert response == {"items": ITEMS[20:40], "count": 100}
 
-    schema = api.get_openapi_schema()["paths"]["/api/items_5"]["get"]
+    schema = api.get_openapi_schema()["paths"]["/api/items_10"]["get"]
 
     assert schema["parameters"] == [
         {
diff --git a/tests/test_path.py b/tests/test_path.py
index 687d648..e9fea9d 100644
--- a/tests/test_path.py
+++ b/tests/test_path.py
@@ -301,12 +301,17 @@ def test_get_path(path, expected_status, expected_response):
             "31ea378c-c052-4b4c-bf0b-679ce5cfcc2a",
         ),
         (
-            "/path/param-django-uuid/31ea378c-c052-4b4c-bf0b-679ce5cfcc2",
+            "/path/param-django-uuid/31ea378c-c052-4b4c-bf0b-679ce5cfcc2",  # invalid UUID (missing last digit)
             "Cannot resolve",
             Exception,
         ),
         (
-            "/path/param-django-uuid-str/31ea378c-c052-4b4c-bf0b-679ce5cfcc2a",
+            "/path/param-django-uuid-notype/31ea378c-c052-4b4c-bf0b-679ce5cfcc2a",
+            200,
+            "31ea378c-c052-4b4c-bf0b-679ce5cfcc2a",
+        ),
+        (
+            "/path/param-django-uuid-typestr/31ea378c-c052-4b4c-bf0b-679ce5cfcc2a",
             200,
             "31ea378c-c052-4b4c-bf0b-679ce5cfcc2a",
         ),
diff --git a/tests/test_response.py b/tests/test_response.py
index 72921dd..09f5045 100644
--- a/tests/test_response.py
+++ b/tests/test_response.py
@@ -38,7 +38,8 @@ class MyEnum(Enum):
 
 
 def to_camel(string: str) -> str:
-    return "".join(word.capitalize() for word in string.split("_"))
+    words = string.split("_")
+    return words[0].lower() + "".join(word.capitalize() for word in words[1:])
 
 
 class UserModel(BaseModel):
@@ -109,7 +110,7 @@ client = TestClient(router)
             [{"id": 1, "user_name": "John"}],
         ),  # the password is skipped
         ("/check_model", {"id": 1, "user_name": "John"}),  # the password is skipped
-        ("/check_model_alias", {"Id": 1, "UserName": "John"}),  # result is Camal Case
+        ("/check_model_alias", {"id": 1, "userName": "John"}),  # result is camelCase
         ("/check_union?q=0", 1),
         ("/check_union?q=1", {"id": 1, "user_name": "John"}),
     ],