Repository: vitalik/django-ninja
Pull request #1262 (2024-08-10 21:56:50)

PatchDict util
PatchDict container allows to convert any schema into all-optional-fields schema and return a dict with only provided fields:

Usage example
```python
from ninja import PatchDict

class GroupSchema(Schema):
    # You do not have to make fields optional it will be converted by PatchDict
    name: str
    description: str
    due_date: date


@api.patch("/patch/{pk}")
def modify_data(request, pk: int, payload: PatchDict[GroupSchema]):
    obj = MyModel.objects.get(pk=pk)

    for attr, value in payload.items():
        setattr(obj, attr, value)
    
    obj.save()
```


Patch:
diff --git a/docs/docs/guides/response/django-pydantic.md b/docs/docs/guides/response/django-pydantic.md
index f3f23ea..0cd4d80 100644
--- a/docs/docs/guides/response/django-pydantic.md
+++ b/docs/docs/guides/response/django-pydantic.md
@@ -125,3 +125,31 @@ def patch(request, pk: int, payload: PatchGroupSchema):
 
 
 ```
+
+#### PatchDict
+
+Another way to work with patch request data is a `PatchDict` container which allows you to make 
+a schema with all optional fields and get a dict with **only** fields that was provide
+
+```Python hl_lines="1 11"
+from ninja import PatchDict
+
+class GroupSchema(Schema):
+    # You do not have to make fields optional it will be converted by PatchDict
+    name: str
+    description: str
+    due_date: date
+
+
+@api.patch("/patch/{pk}")
+def modify_data(request, pk: int, payload: PatchDict[GroupSchema]):
+    obj = MyModel.objects.get(pk=pk)
+
+    for attr, value in payload.items():
+        setattr(obj, attr, value)
+    
+    obj.save()
+
+```
+
+in this example the `payload` argument will be a type of `dict` only fields that were passed in request and validated using `GroupSchema`
diff --git a/ninja/__init__.py b/ninja/__init__.py
index 81f4a6d..90837c4 100644
--- a/ninja/__init__.py
+++ b/ninja/__init__.py
@@ -27,6 +27,7 @@ from ninja.params import (
     Query,
     QueryEx,
 )
+from ninja.patch_dict import PatchDict
 from ninja.router import Router
 from ninja.schema import Schema
 
@@ -55,4 +56,5 @@ __all__ = [
     "FilterSchema",
     "Swagger",
     "Redoc",
+    "PatchDict",
 ]
diff --git a/ninja/operation.py b/ninja/operation.py
index b47eb4f..455a166 100644
--- a/ninja/operation.py
+++ b/ninja/operation.py
@@ -21,7 +21,7 @@ from django.http.response import HttpResponseBase
 from ninja.constants import NOT_SET, NOT_SET_TYPE
 from ninja.errors import AuthenticationError, ConfigError, Throttled, ValidationError
 from ninja.params.models import TModels
-from ninja.schema import Schema
+from ninja.schema import Schema, pydantic_version
 from ninja.signature import ViewSignature, is_async
 from ninja.throttling import BaseThrottle
 from ninja.types import DictStrAny
@@ -60,7 +60,7 @@ class Operation:
         self.path: str = path
         self.methods: List[str] = methods
         self.view_func: Callable = view_func
-        self.api: "NinjaAPI" = cast("NinjaAPI", None)
+        self.api: NinjaAPI = cast("NinjaAPI", None)
         if url_name is not None:
             self.url_name = url_name
 
@@ -261,11 +261,19 @@ class Operation:
             resp_object, context={"request": request, "response_status": status}
         )
 
+        model_dump_kwargs: Dict[str, Any] = {}
+        if pydantic_version >= [2, 7]:
+            # pydantic added support for serialization context at 2.7
+            model_dump_kwargs.update(
+                context={"request": request, "response_status": status}
+            )
+
         result = validated_object.model_dump(
             by_alias=self.by_alias,
             exclude_unset=self.exclude_unset,
             exclude_defaults=self.exclude_defaults,
             exclude_none=self.exclude_none,
+            **model_dump_kwargs,
         )["response"]
         return self.api.create_response(
             request, result, temporal_response=temporal_response
diff --git a/ninja/patch_dict.py b/ninja/patch_dict.py
new file mode 100644
index 0000000..63c5f0a
--- /dev/null
+++ b/ninja/patch_dict.py
@@ -0,0 +1,52 @@
+from typing import TYPE_CHECKING, Any, Dict, Optional, Type
+
+from pydantic_core import core_schema
+from typing_extensions import Annotated
+
+from ninja import Body
+from ninja.utils import is_optional_type
+
+
+class ModelToDict(dict):
+    _wrapped_model: Any = None
+    _wrapped_model_dump_params: Dict[str, Any] = {}
+
+    @classmethod
+    def __get_pydantic_core_schema__(cls, _source: Any, _handler: Any) -> Any:
+        return core_schema.no_info_after_validator_function(
+            cls._validate,
+            cls._wrapped_model.__pydantic_core_schema__,
+        )
+
+    @classmethod
+    def _validate(cls, input_value: Any) -> Any:
+        return input_value.model_dump(**cls._wrapped_model_dump_params)
+
+
+def create_patch_schema(schema_cls: Type[Any]) -> Type[ModelToDict]:
+    values, annotations = {}, {}
+    for f in schema_cls.__fields__.keys():
+        t = schema_cls.__annotations__[f]
+        if not is_optional_type(t):
+            values[f] = getattr(schema_cls, f, None)
+            annotations[f] = Optional[t]
+    values["__annotations__"] = annotations
+    OptionalSchema = type(f"{schema_cls.__name__}Patch", (schema_cls,), values)
+
+    class OptionalDictSchema(ModelToDict):
+        _wrapped_model = OptionalSchema
+        _wrapped_model_dump_params = {"exclude_unset": True}
+
+    return OptionalDictSchema
+
+
+class PatchDictUtil:
+    def __getitem__(self, schema_cls: Any) -> Any:
+        new_cls = create_patch_schema(schema_cls)
+        return Body[new_cls]  # type: ignore
+
+
+if TYPE_CHECKING:  # pragma: nocover
+    PatchDict = Annotated[dict, "<PatchDict>"]
+else:
+    PatchDict = PatchDictUtil()
diff --git a/ninja/router.py b/ninja/router.py
index 13e26d4..f471e80 100644
--- a/ninja/router.py
+++ b/ninja/router.py
@@ -36,7 +36,7 @@ class Router:
         throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
         tags: Optional[List[str]] = None,
     ) -> None:
-        self.api: Optional["NinjaAPI"] = None
+        self.api: Optional[NinjaAPI] = None
         self.auth = auth
         self.throttle = throttle
         self.tags = tags
diff --git a/ninja/utils.py b/ninja/utils.py
index dd2e2d5..98ff863 100644
--- a/ninja/utils.py
+++ b/ninja/utils.py
@@ -50,6 +50,13 @@ def is_async_callable(f: Callable[..., Any]) -> bool:
     )
 
 
+def is_optional_type(t: Type[Any]) -> bool:
+    try:
+        return type(None) in t.__args__
+    except AttributeError:
+        return False
+
+
 def contribute_operation_callback(
     func: Callable[..., Any], callback: Callable[..., Any]
 ) -> None:
diff --git a/pyproject.toml b/pyproject.toml
index b87d11a..921f0f9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -59,7 +59,7 @@ test = [
     "pytest-asyncio",
     "psycopg2-binary",
     "mypy==1.7.1",
-    "ruff==0.4.2",
+    "ruff==0.5.7",
     "django-stubs",
 ]
 doc = ["mkdocs", "mkdocs-material", "markdown-include", "mkdocstrings"]


Test patch:
diff --git a/docs/docs/guides/testing.md b/docs/docs/guides/testing.md
index 8d9f203..9686cb0 100644
--- a/docs/docs/guides/testing.md
+++ b/docs/docs/guides/testing.md
@@ -33,6 +33,11 @@ class HelloTest(TestCase):
         self.assertEqual(response.json(), {"msg": "Hello World"})
 ```
 
+It is also possible to access the deserialized data using the `data` property:
+```python
+    self.assertEqual(response.data, {"msg": "Hello World"})
+```
+
 Arbitrary attributes can be added to the request object by passing keyword arguments to the client request methods:
 ```python
 class HelloTest(TestCase):
diff --git a/ninja/testing/client.py b/ninja/testing/client.py
index 8829112..8a3cbcd 100644
--- a/ninja/testing/client.py
+++ b/ninja/testing/client.py
@@ -189,10 +189,17 @@ class NinjaResponse:
             self.content = b"".join(http_response.streaming_content)  # type: ignore
         else:
             self.content = http_response.content  # type: ignore[union-attr]
+        self._data = None
 
     def json(self) -> Any:
         return json_loads(self.content)
 
+    @property
+    def data(self) -> Any:
+        if self._data is None:  # Recomputes if json() is None but cheap then
+            self._data = self.json()
+        return self._data
+
     def __getitem__(self, key: str) -> Any:
         return self._response[key]
 
diff --git a/tests/test_patch_dict.py b/tests/test_patch_dict.py
new file mode 100644
index 0000000..ffc42d7
--- /dev/null
+++ b/tests/test_patch_dict.py
@@ -0,0 +1,60 @@
+from typing import Optional
+
+import pytest
+
+from ninja import NinjaAPI, Schema
+from ninja.patch_dict import PatchDict
+from ninja.testing import TestClient
+
+api = NinjaAPI()
+
+client = TestClient(api)
+
+
+class SomeSchema(Schema):
+    name: str
+    age: int
+    category: Optional[str] = None
+
+
+@api.patch("/patch")
+def patch(request, payload: PatchDict[SomeSchema]):
+    return {"payload": payload, "type": str(type(payload))}
+
+
+@pytest.mark.parametrize(
+    "input,output",
+    [
+        ({"name": "foo"}, {"name": "foo"}),
+        ({"age": "1"}, {"age": 1}),
+        ({}, {}),
+        ({"wrong_param": 1}, {}),
+        ({"age": None}, {"age": None}),
+    ],
+)
+def test_patch_calls(input: dict, output: dict):
+    response = client.patch("/patch", json=input)
+    assert response.json() == {"payload": output, "type": "<class 'dict'>"}
+
+
+def test_schema():
+    "Checking that json schema properties are all optional"
+    schema = api.get_openapi_schema()
+    assert schema["components"]["schemas"]["SomeSchemaPatch"] == {
+        "title": "SomeSchemaPatch",
+        "type": "object",
+        "properties": {
+            "name": {
+                "anyOf": [{"type": "string"}, {"type": "null"}],
+                "title": "Name",
+            },
+            "age": {
+                "anyOf": [{"type": "integer"}, {"type": "null"}],
+                "title": "Age",
+            },
+            "category": {
+                "anyOf": [{"type": "string"}, {"type": "null"}],
+                "title": "Category",
+            },
+        },
+    }
diff --git a/tests/test_response.py b/tests/test_response.py
index 5504e51..6b234a6 100644
--- a/tests/test_response.py
+++ b/tests/test_response.py
@@ -117,6 +117,7 @@ def test_responses(path, expected_response):
     response = client.get(path)
     assert response.status_code == 200, response.content
     assert response.json() == expected_response
+    assert response.data == response.data == expected_response  # Ensures cache works
 
 
 def test_validates():
diff --git a/tests/test_serialization_context.py b/tests/test_serialization_context.py
new file mode 100644
index 0000000..3b24f4c
--- /dev/null
+++ b/tests/test_serialization_context.py
@@ -0,0 +1,75 @@
+from unittest import mock
+
+import pytest
+from pydantic import model_serializer
+
+from ninja import Router, Schema
+from ninja.schema import pydantic_version
+from ninja.testing import TestClient
+
+
+def api_endpoint_test(request):
+    return {
+        "test1": "foo",
+        "test2": "bar",
+    }
+
+
+@pytest.mark.skipif(
+    pydantic_version < [2, 7],
+    reason="Serialization context was introduced in Pydantic 2.7",
+)
+def test_request_is_passed_in_context_when_supported():
+    class SchemaWithCustomSerializer(Schema):
+        test1: str
+        test2: str
+
+        @model_serializer(mode="wrap")
+        def ser_model(self, handler, info):
+            assert "request" in info.context
+            assert info.context["request"].path == "/test"  # check it is HttRequest
+            assert "response_status" in info.context
+
+            return handler(self)
+
+    router = Router()
+    router.add_api_operation(
+        "/test", ["GET"], api_endpoint_test, response=SchemaWithCustomSerializer
+    )
+
+    TestClient(router).get("/test")
+
+
+@pytest.mark.parametrize(
+    ["pydantic_version"],
+    [
+        [[2, 0]],
+        [[2, 4]],
+        [[2, 6]],
+    ],
+)
+def test_no_serialisation_context_used_when_no_supported(pydantic_version):
+    class SchemaWithCustomSerializer(Schema):
+        test1: str
+        test2: str
+
+        @model_serializer(mode="wrap")
+        def ser_model(self, handler, info):
+            if hasattr(info, "context"):
+                # an actually newer Pydantic, but pydantic_version is still mocked, so no context is expected
+                assert info.context is None
+
+            return handler(self)
+
+    with mock.patch("ninja.operation.pydantic_version", pydantic_version):
+        router = Router()
+        router.add_api_operation(
+            "/test", ["GET"], api_endpoint_test, response=SchemaWithCustomSerializer
+        )
+
+        resp_json = TestClient(router).get("/test").json()
+
+        assert resp_json == {
+            "test1": "foo",
+            "test2": "bar",
+        }