Repository: vitalik/django-ninja
Pull request #863 (2023-09-25 13:07:28)

Forbid extra


Patch:
diff --git a/docs/docs/guides/input/path-params.md b/docs/docs/guides/input/path-params.md
index b240843..5086aea 100644
--- a/docs/docs/guides/input/path-params.md
+++ b/docs/docs/guides/input/path-params.md
@@ -87,7 +87,7 @@ Django's `path` converter allows you to handle path-like parameters:
 def someview(request, value: str):
        return value
 ```
-you can query this operation with `/dir/some/path/with-slashes` and your `value` will be equal to `some/path/with-slashes`
+you can quiery this operation with `/dir/some/path/with-slashes` and your `value` will be euqual to `some/path/with-slashes`
 
 ### Multiple parameters
 
diff --git a/docs/docs/guides/input/query-params.md b/docs/docs/guides/input/query-params.md
index f88c962..d1397e4 100644
--- a/docs/docs/guides/input/query-params.md
+++ b/docs/docs/guides/input/query-params.md
@@ -21,8 +21,7 @@ The same benefits that apply to path parameters also apply to query parameters:
 - Automatic documentation
 
 
-!!! Note
-    if you do not annotate your arguments, they will be treated as `str` types
+Note: if you do not annotate your arguments, they will be treated as `str` types:
 
 ```Python hl_lines="2"
 @api.get("/weapons")
diff --git a/ninja/operation.py b/ninja/operation.py
index 6ca3087..55321b6 100644
--- a/ninja/operation.py
+++ b/ninja/operation.py
@@ -204,8 +204,8 @@ class Operation:
             return temporal_response
 
         resp_object = ResponseObject(result)
-        # ^ we need object because getter_dict seems work only with from_orm
-        result = response_model.from_orm(resp_object).model_dump(
+        # ^ we need object because getter_dict seems work only with model_validate
+        result = response_model.model_validate(resp_object).model_dump(
             by_alias=self.by_alias,
             exclude_unset=self.exclude_unset,
             exclude_defaults=self.exclude_defaults,
@@ -419,7 +419,7 @@ class PathView:
 
 
 class ResponseObject:
-    "Basically this is just a helper to be able to pass response to pydantic's from_orm"
+    "Basically this is just a helper to be able to pass response to pydantic's model_validate"
 
     def __init__(self, response: HttpResponse) -> None:
         self.response = response
diff --git a/ninja/schema.py b/ninja/schema.py
index 7573520..1a6c013 100644
--- a/ninja/schema.py
+++ b/ninja/schema.py
@@ -29,6 +29,7 @@ from django.db.models.fields.files import FieldFile
 from django.template import Variable, VariableDoesNotExist
 from pydantic import BaseModel, Field, ValidationInfo, model_validator, validator
 from pydantic._internal._model_construction import ModelMetaclass
+from pydantic.functional_validators import ModelWrapValidatorHandler
 from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
 
 from ninja.signature.utils import get_args_names, has_kwargs
@@ -45,7 +46,7 @@ S = TypeVar("S", bound="Schema")
 class DjangoGetter:
     __slots__ = ("_obj", "_schema_cls", "_context")
 
-    def __init__(self, obj: Any, schema_cls: "Schema", context: Any = None):
+    def __init__(self, obj: Any, schema_cls: Type[S], context: Any = None):
         self._obj = obj
         self._schema_cls = schema_cls
         self._context = context
@@ -54,7 +55,7 @@ class DjangoGetter:
         # if key.startswith("__pydantic"):
         #     return getattr(self._obj, key)
 
-        resolver = self._schema_cls._ninja_resolvers.get(key)  # type: ignore
+        resolver = self._schema_cls._ninja_resolvers.get(key)
         if resolver:
             value = resolver(getter=self)
         else:
@@ -198,10 +199,18 @@ class Schema(BaseModel, metaclass=ResolverMetaclass):
     class Config:
         from_attributes = True  # aka orm_mode
 
-    @model_validator(mode="before")
-    def _run_root_validator(cls, values: Any, info: ValidationInfo) -> Any:
+    @model_validator(mode="wrap")
+    @classmethod
+    def _run_root_validator(
+        cls, values: Any, handler: ModelWrapValidatorHandler[S], info: ValidationInfo
+    ) -> S:
+        # when extra is "forbid" we need to perform default pydantic valudation
+        # as DjangoGetter does not act as dict and pydantic will not be able to validate it
+        if cls.model_config.get("extra") == "forbid":
+            handler(values)
+
         values = DjangoGetter(values, cls, info.context)
-        return values
+        return handler(values)
 
     @classmethod
     def from_orm(cls: Type[S], obj: Any) -> S:
diff --git a/ninja/signature/details.py b/ninja/signature/details.py
index f767bc5..7576d56 100644
--- a/ninja/signature/details.py
+++ b/ninja/signature/details.py
@@ -277,13 +277,6 @@ def is_pydantic_model(cls: Any) -> bool:
 
 def is_collection_type(annotation: Any) -> bool:
     origin = get_origin(annotation)
-
-    if origin in UNION_TYPES:
-        for arg in get_args(annotation):
-            if is_collection_type(arg):
-                return True
-        return False
-
     collection_types = (List, list, set, tuple)
     if origin is None:
         return (
diff --git a/pyproject.toml b/pyproject.toml
index c6c5440..737d03d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -77,6 +77,15 @@ dev = [
     "pre-commit",
 ]
 
+
+# BLACK ==========================================================
+
+[tool.black]
+line-length = 88
+skip-string-normalization = false
+target-version = ['py311']
+
+
 [tool.ruff]
 select = [
     "E",  # pycodestyle errors


Test patch:
diff --git a/tests/main.py b/tests/main.py
index d313d08..80984da 100644
--- a/tests/main.py
+++ b/tests/main.py
@@ -1,4 +1,4 @@
-from typing import List, Optional
+from typing import List
 from uuid import UUID
 
 from django.urls import register_converter
@@ -214,13 +214,6 @@ def get_query_list(request, query: List[str] = Query(...)):
     return ",".join(query)
 
 
-@router.get("/query/list-optional")
-def get_query_optional_list(request, query: Optional[List[str]] = Query(None)):
-    if query:
-        return ",".join(query)
-    return query
-
-
 @router.get("/query/param")
 def get_query_param(request, query=Query(None)):
     if query is None:
diff --git a/tests/test_query.py b/tests/test_query.py
index 6ad1894..8d0c3f5 100644
--- a/tests/test_query.py
+++ b/tests/test_query.py
@@ -58,9 +58,6 @@ client = TestClient(router)
         ("/query/int/default?query=50", 200, "foo bar 50"),
         ("/query/int/default?query=foo", 422, response_not_valid_int),
         ("/query/list?query=a&query=b&query=c", 200, "a,b,c"),
-        ("/query/list-optional?query=a&query=b&query=c", 200, "a,b,c"),
-        ("/query/list-optional?query=a", 200, "a"),
-        ("/query/list-optional", 200, None),
         ("/query/param", 200, "foo bar"),
         ("/query/param?query=50", 200, "foo bar 50"),
         ("/query/param-required", 422, response_missing),
diff --git a/tests/test_request.py b/tests/test_request.py
index d7e4dfb..40d5dd5 100644
--- a/tests/test_request.py
+++ b/tests/test_request.py
@@ -1,8 +1,23 @@
+from typing import Optional
+
 import pytest
+from pydantic import ConfigDict
 
-from ninja import Cookie, Header, Router
+from ninja import Body, Cookie, Header, Router, Schema
 from ninja.testing import TestClient
 
+
+class OptionalEmptySchema(Schema):
+    model_config = ConfigDict(extra="forbid")
+    name: Optional[str] = None
+
+
+class ExtraForbidSchema(Schema):
+    model_config = ConfigDict(extra="forbid")
+    name: str
+    metadata: Optional[OptionalEmptySchema] = None
+
+
 router = Router()
 
 
@@ -41,6 +56,11 @@ def cookies2(request, wpn: str = Cookie(..., alias="weapon")):
     return wpn
 
 
+@router.post("/test-schema")
+def test_schema(request, payload: ExtraForbidSchema = Body(...)):
+    return "ok"
+
+
 client = TestClient(router)
 
 
@@ -77,3 +97,57 @@ def test_headers(path, expected_status, expected_response):
     assert response.status_code == expected_status, response.content
     print(response.json())
     assert response.json() == expected_response
+
+
+@pytest.mark.parametrize(
+    "path,json,expected_status,expected_response",
+    [
+        (
+            "/test-schema",
+            {"name": "test", "extra_name": "test2"},
+            422,
+            {
+                "detail": [
+                    {
+                        "type": "extra_forbidden",
+                        "loc": ["body", "payload", "extra_name"],
+                        "msg": "Extra inputs are not permitted",
+                    }
+                ]
+            },
+        ),
+        (
+            "/test-schema",
+            {"name": "test", "metadata": {"extra_name": "xxx"}},
+            422,
+            {
+                "detail": [
+                    {
+                        "loc": ["body", "payload", "metadata", "extra_name"],
+                        "msg": "Extra inputs are not permitted",
+                        "type": "extra_forbidden",
+                    }
+                ]
+            },
+        ),
+        (
+            "/test-schema",
+            {"name": "test", "metadata": "test2"},
+            422,
+            {
+                "detail": [
+                    {
+                        "type": "model_attributes_type",
+                        "loc": ["body", "payload", "metadata"],
+                        "msg": "Input should be a valid dictionary or object to extract fields from",
+                    }
+                ]
+            },
+        ),
+    ],
+)
+def test_pydantic_config(path, json, expected_status, expected_response):
+    # test extra forbid
+    response = client.post(path, json=json)
+    assert response.json() == expected_response
+    assert response.status_code == expected_status
diff --git a/tests/test_signature_details.py b/tests/test_signature_details.py
index 3816851..c48f84f 100644
--- a/tests/test_signature_details.py
+++ b/tests/test_signature_details.py
@@ -15,9 +15,6 @@ from ninja.signature.details import is_collection_type
         pytest.param(set, True, id="true_for_native_set"),
         pytest.param(typing.Tuple, True, id="true_for_typing_Tuple"),
         pytest.param(tuple, True, id="true_for_native_tuple"),
-        pytest.param(
-            typing.Optional[typing.List[str]], True, id="true_for_optional_list"
-        ),
         pytest.param(
             type("Custom", (), {}),
             False,