Repository: vitalik/django-ninja
Pull request #1268 (2024-08-18 15:34:38)

Operation default options, addresses #1267
Fairly simple change to the Router to allow a set of operation options to be set for every operation on the router.
#1267 

- [x] implement basic Router Ops defaults
- [x] test to ensure the default options are used
- [x] test to ensure settings on Operations override the Router defaults

I picked 4 options: by_alias, exclude_unset, exclude_defaults, and exclude_none since they seemed ripe for this, but this could be expanded. 

I did not tackle any sort of inheritance in this PR. Adding a Router to a Router that has defaults won't cause the default options to percolate.


Patch:
diff --git a/.gitignore b/.gitignore
index 9d4b202..bfa3296 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ docs/site
 
 .DS_Store
 .idea
+.python-version
diff --git a/ninja/main.py b/ninja/main.py
index b6ec581..67216ac 100644
--- a/ninja/main.py
+++ b/ninja/main.py
@@ -131,10 +131,10 @@ class NinjaAPI:
         description: Optional[str] = None,
         tags: Optional[List[str]] = None,
         deprecated: Optional[bool] = None,
-        by_alias: bool = False,
-        exclude_unset: bool = False,
-        exclude_defaults: bool = False,
-        exclude_none: bool = False,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
         openapi_extra: Optional[Dict[str, Any]] = None,
@@ -174,10 +174,10 @@ class NinjaAPI:
         description: Optional[str] = None,
         tags: Optional[List[str]] = None,
         deprecated: Optional[bool] = None,
-        by_alias: bool = False,
-        exclude_unset: bool = False,
-        exclude_defaults: bool = False,
-        exclude_none: bool = False,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
         openapi_extra: Optional[Dict[str, Any]] = None,
@@ -217,10 +217,10 @@ class NinjaAPI:
         description: Optional[str] = None,
         tags: Optional[List[str]] = None,
         deprecated: Optional[bool] = None,
-        by_alias: bool = False,
-        exclude_unset: bool = False,
-        exclude_defaults: bool = False,
-        exclude_none: bool = False,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
         openapi_extra: Optional[Dict[str, Any]] = None,
@@ -260,10 +260,10 @@ class NinjaAPI:
         description: Optional[str] = None,
         tags: Optional[List[str]] = None,
         deprecated: Optional[bool] = None,
-        by_alias: bool = False,
-        exclude_unset: bool = False,
-        exclude_defaults: bool = False,
-        exclude_none: bool = False,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
         openapi_extra: Optional[Dict[str, Any]] = None,
@@ -303,10 +303,10 @@ class NinjaAPI:
         description: Optional[str] = None,
         tags: Optional[List[str]] = None,
         deprecated: Optional[bool] = None,
-        by_alias: bool = False,
-        exclude_unset: bool = False,
-        exclude_defaults: bool = False,
-        exclude_none: bool = False,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
         openapi_extra: Optional[Dict[str, Any]] = None,
@@ -347,10 +347,10 @@ class NinjaAPI:
         description: Optional[str] = None,
         tags: Optional[List[str]] = None,
         deprecated: Optional[bool] = None,
-        by_alias: bool = False,
-        exclude_unset: bool = False,
-        exclude_defaults: bool = False,
-        exclude_none: bool = False,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
         openapi_extra: Optional[Dict[str, Any]] = None,
diff --git a/ninja/operation.py b/ninja/operation.py
index 455a166..623c0b7 100644
--- a/ninja/operation.py
+++ b/ninja/operation.py
@@ -48,10 +48,10 @@ class Operation:
         description: Optional[str] = None,
         tags: Optional[List[str]] = None,
         deprecated: Optional[bool] = None,
-        by_alias: bool = False,
-        exclude_unset: bool = False,
-        exclude_defaults: bool = False,
-        exclude_none: bool = False,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
         include_in_schema: bool = True,
         url_name: Optional[str] = None,
         openapi_extra: Optional[Dict[str, Any]] = None,
@@ -99,10 +99,10 @@ class Operation:
         self.openapi_extra = openapi_extra
 
         # Exporting models params
-        self.by_alias = by_alias
-        self.exclude_unset = exclude_unset
-        self.exclude_defaults = exclude_defaults
-        self.exclude_none = exclude_none
+        self.by_alias = by_alias or False
+        self.exclude_unset = exclude_unset or False
+        self.exclude_defaults = exclude_defaults or False
+        self.exclude_none = exclude_none or False
 
         if hasattr(view_func, "_ninja_contribute_to_operation"):
             # Allow 3rd party code to contribute to the operation behavior
@@ -407,10 +407,10 @@ class PathView:
         description: Optional[str] = None,
         tags: Optional[List[str]] = None,
         deprecated: Optional[bool] = None,
-        by_alias: bool = False,
-        exclude_unset: bool = False,
-        exclude_defaults: bool = False,
-        exclude_none: bool = False,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
         openapi_extra: Optional[Dict[str, Any]] = None,
diff --git a/ninja/router.py b/ninja/router.py
index f471e80..39da902 100644
--- a/ninja/router.py
+++ b/ninja/router.py
@@ -35,11 +35,20 @@ class Router:
         auth: Any = NOT_SET,
         throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
         tags: Optional[List[str]] = None,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
     ) -> None:
         self.api: Optional[NinjaAPI] = None
         self.auth = auth
         self.throttle = throttle
         self.tags = tags
+        self.by_alias = by_alias
+        self.exclude_unset = exclude_unset
+        self.exclude_defaults = exclude_defaults
+        self.exclude_none = exclude_none
+
         self.path_operations: Dict[str, PathView] = {}
         self._routers: List[Tuple[str, Router]] = []
 
@@ -55,10 +64,10 @@ class Router:
         description: Optional[str] = None,
         tags: Optional[List[str]] = None,
         deprecated: Optional[bool] = None,
-        by_alias: bool = False,
-        exclude_unset: bool = False,
-        exclude_defaults: bool = False,
-        exclude_none: bool = False,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
         openapi_extra: Optional[Dict[str, Any]] = None,
@@ -95,10 +104,10 @@ class Router:
         description: Optional[str] = None,
         tags: Optional[List[str]] = None,
         deprecated: Optional[bool] = None,
-        by_alias: bool = False,
-        exclude_unset: bool = False,
-        exclude_defaults: bool = False,
-        exclude_none: bool = False,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
         openapi_extra: Optional[Dict[str, Any]] = None,
@@ -135,10 +144,10 @@ class Router:
         description: Optional[str] = None,
         tags: Optional[List[str]] = None,
         deprecated: Optional[bool] = None,
-        by_alias: bool = False,
-        exclude_unset: bool = False,
-        exclude_defaults: bool = False,
-        exclude_none: bool = False,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
         openapi_extra: Optional[Dict[str, Any]] = None,
@@ -175,10 +184,10 @@ class Router:
         description: Optional[str] = None,
         tags: Optional[List[str]] = None,
         deprecated: Optional[bool] = None,
-        by_alias: bool = False,
-        exclude_unset: bool = False,
-        exclude_defaults: bool = False,
-        exclude_none: bool = False,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
         openapi_extra: Optional[Dict[str, Any]] = None,
@@ -215,10 +224,10 @@ class Router:
         description: Optional[str] = None,
         tags: Optional[List[str]] = None,
         deprecated: Optional[bool] = None,
-        by_alias: bool = False,
-        exclude_unset: bool = False,
-        exclude_defaults: bool = False,
-        exclude_none: bool = False,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
         openapi_extra: Optional[Dict[str, Any]] = None,
@@ -256,10 +265,10 @@ class Router:
         description: Optional[str] = None,
         tags: Optional[List[str]] = None,
         deprecated: Optional[bool] = None,
-        by_alias: bool = False,
-        exclude_unset: bool = False,
-        exclude_defaults: bool = False,
-        exclude_none: bool = False,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
         openapi_extra: Optional[Dict[str, Any]] = None,
@@ -303,10 +312,10 @@ class Router:
         description: Optional[str] = None,
         tags: Optional[List[str]] = None,
         deprecated: Optional[bool] = None,
-        by_alias: bool = False,
-        exclude_unset: bool = False,
-        exclude_defaults: bool = False,
-        exclude_none: bool = False,
+        by_alias: Optional[bool] = None,
+        exclude_unset: Optional[bool] = None,
+        exclude_defaults: Optional[bool] = None,
+        exclude_none: Optional[bool] = None,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
         openapi_extra: Optional[Dict[str, Any]] = None,
@@ -316,6 +325,14 @@ class Router:
             self.path_operations[path] = path_view
         else:
             path_view = self.path_operations[path]
+
+        by_alias = by_alias is None and self.by_alias or by_alias
+        exclude_unset = exclude_unset is None and self.exclude_unset or exclude_unset
+        exclude_defaults = (
+            exclude_defaults is None and self.exclude_defaults or exclude_defaults
+        )
+        exclude_none = exclude_none is None and self.exclude_none or exclude_none
+
         path_view.add_operation(
             path=path,
             methods=methods,


Test patch:
diff --git a/tests/test_router_defaults.py b/tests/test_router_defaults.py
new file mode 100644
index 0000000..9506fe1
--- /dev/null
+++ b/tests/test_router_defaults.py
@@ -0,0 +1,62 @@
+from typing import Optional
+
+import pytest
+from pydantic import Field
+
+from ninja import NinjaAPI, Router, Schema
+from ninja.testing import TestClient
+
+
+class SomeResponse(Schema):
+    field1: Optional[int] = 1
+    field2: Optional[str] = "default value"
+    field3: Optional[int] = Field(None, alias="aliased")
+
+
+@pytest.mark.parametrize(
+    "oparg,retdict,assertone,asserttwo",
+    [
+        (
+            "exclude_defaults",
+            {"field1": 3},
+            {"field1": 3},
+            {"field1": 3, "field2": "default value", "field3": None},
+        ),
+        (
+            "exclude_unset",
+            {"field2": "test"},
+            {"field2": "test"},
+            {"field1": 1, "field2": "test", "field3": None},
+        ),
+        (
+            "exclude_none",
+            {"field1": None, "field2": None, "aliased": 10},
+            {"field3": 10},
+            {"field1": None, "field2": None, "field3": 10},
+        ),
+        (
+            "by_alias",
+            {"aliased": 10},
+            {"field1": 1, "field2": "default value", "aliased": 10},
+            {"field1": 1, "field2": "default value", "field3": 10},
+        ),
+    ],
+)
+def test_router_defaults(oparg, retdict, assertone, asserttwo):
+    """Test that the router level settings work and can be overriden at the op level"""
+    api = NinjaAPI()
+    router = Router(**{oparg: True})
+    api.add_router("/", router)
+
+    func1 = router.get("/test1", response=SomeResponse)(lambda request: retdict)
+    func2 = router.get("/test2", response=SomeResponse, **{oparg: False})(
+        lambda request: retdict
+    )
+
+    client = TestClient(api)
+
+    assert getattr(func1._ninja_operation, oparg) is True
+    assert getattr(func2._ninja_operation, oparg) is False
+
+    assert client.get("/test1").json() == assertone
+    assert client.get("/test2").json() == asserttwo