Repository: vitalik/django-ninja
Pull request #301 (2021-12-14 19:57:18)

Add support for union type in payload
@vitalik This MR adds support for defining a request body as a union of two schemas. 

Prior to this change, a payload with the union type would be incorrectly classified as a query parameter, and fail to appear within the list of components defined in the OpenAPI schema

vitalik: Hi @AkeemMcLennon 

you need to to use get_origin from ninja/compatibility/util.py 
(to support python 3.6, 3.7 etc)
AkeemMcLennon: @vitalik Ah, thanks. Updated

Patch:
diff --git a/docs/docs/proposals/v1.md b/docs/docs/proposals/v1.md
index a242e4e..d047ecf 100644
--- a/docs/docs/proposals/v1.md
+++ b/docs/docs/proposals/v1.md
@@ -2,11 +2,9 @@
 
 Django Ninja is already used by tens of companies and by the visitors and downloads stats it's growing.
 
-At this point introducing changes that will force current users to change their code (or break it) is not 
-acceptable.
+At this point intorducing changes that will force current users to change their code (or break it) is not acceptable.
 
-On the other hand some decisions that where initially made does not work well. These  breaking changes will be 
-introduced in version 1.0.0
+On the other hand some decisions that where initally made does not work well. These  breaking changes will be introduced in version 1.0.0
 
 ## Changes that most likely be in v1
 
diff --git a/docs/docs/tutorial/crud.md b/docs/docs/tutorial/crud.md
index 909fde9..acf1fa2 100644
--- a/docs/docs/tutorial/crud.md
+++ b/docs/docs/tutorial/crud.md
@@ -134,7 +134,7 @@ Here we used the `payload.dict` method to set all object attributes:
 
 `for attr, value in payload.dict().items()`
 
-You can also do this more explicit:
+You can also do this more implicit:
 
 ```Python
 employee.first_name = payload.first_name
diff --git a/docs/docs/tutorial/index.md b/docs/docs/tutorial/index.md
index 5c0462d..f19af3a 100644
--- a/docs/docs/tutorial/index.md
+++ b/docs/docs/tutorial/index.md
@@ -99,21 +99,11 @@ def patch_operation(request):
     ...
 ```
 
-If you need to handle multiple methods with a single function for a given path,
-you can use the `api_operation` method:
+If you need to handle multiple methods with a single function, you can use the `api_operation` method:
 
 
 ```Python hl_lines="1"
-@api.api_operation(["POST", "PATCH"], "/path")
-def mixed(request):
-    ...
-```
-
-This feature can also be used to implement other HTTP methods that don't have 
-corresponding django-ninja methods, such as `HEAD` or `OPTIONS`.
-
-```Python hl_lines="1"
-@api.api_operation(["HEAD", "OPTIONS"], "/path")
+@api.api_operation(["POST", "PATCH"])
 def mixed(request):
     ...
 ```
diff --git a/ninja/compatibility/util.py b/ninja/compatibility/util.py
index 22b47d5..db86af7 100644
--- a/ninja/compatibility/util.py
+++ b/ninja/compatibility/util.py
@@ -1,12 +1,16 @@
 from typing import Any, Optional
 
 try:
-    from typing import get_origin  # type: ignore
+    from typing import get_args, get_origin  # type: ignore
 except ImportError:  # pragma: no coverage
 
     def get_origin(tp: Any) -> Optional[Any]:
         "typing.get_origin introduced in python3.8"
         return getattr(tp, "__origin__", None)
 
+    def get_args(tp: Any) -> Optional[Any]:
+        "typing.get_args introduced in python3.8"
+        return getattr(tp, "__args__", None)
 
-__all__ = ["get_origin"]
+
+__all__ = ["get_origin", "get_args"]
diff --git a/ninja/signature/details.py b/ninja/signature/details.py
index c9b2d1b..73cf96d 100644
--- a/ninja/signature/details.py
+++ b/ninja/signature/details.py
@@ -1,12 +1,12 @@
 import inspect
 import warnings
 from collections import defaultdict, namedtuple
-from typing import Any, Callable, Dict, Generator, List, Tuple
+from typing import Any, Callable, Dict, Generator, List, Tuple, Union
 
 import pydantic
 
 from ninja import UploadedFile, params
-from ninja.compatibility.util import get_origin as get_collection_origin
+from ninja.compatibility.util import get_args, get_origin as get_collection_origin
 from ninja.errors import ConfigError
 from ninja.params import Body, File, Form, _MultiPartBody
 from ninja.params_models import TModel, TModels
@@ -241,6 +241,8 @@ class ViewSignature:
 
 def is_pydantic_model(cls: Any) -> bool:
     try:
+        if get_collection_origin(cls) == Union:
+            return all(issubclass(arg, pydantic.BaseModel) for arg in get_args(cls))
         return issubclass(cls, pydantic.BaseModel)
     except TypeError:
         return False


Test patch:
diff --git a/.github/workflows/test_full.yml b/.github/workflows/test_full.yml
index cd9c1ba..e48501f 100644
--- a/.github/workflows/test_full.yml
+++ b/.github/workflows/test_full.yml
@@ -11,7 +11,7 @@ jobs:
     strategy:
       matrix:
         python-version: ['3.6', '3.7', '3.8', '3.9', '3.10']
-        django-version: ['<2.2', '<3.0', '<3.1', '<3.2', '<3.3', '<4.1']
+        django-version: ['<2.2', '<3.0', '<3.1', '<3.2', '<3.3']
 
     steps:
       - uses: actions/checkout@v2
diff --git a/tests/test_openapi_schema.py b/tests/test_openapi_schema.py
index c168236..62b0c77 100644
--- a/tests/test_openapi_schema.py
+++ b/tests/test_openapi_schema.py
@@ -1,4 +1,4 @@
-from typing import List
+from typing import List, Union
 
 import pytest
 from django.test import Client, override_settings
@@ -14,6 +14,14 @@ class Payload(Schema):
     f: float
 
 
+class TypeA(Schema):
+    a: str
+
+
+class TypeB(Schema):
+    b: str
+
+
 def to_camel(string: str) -> str:
     return "".join(word.capitalize() for word in string.split("_"))
 
@@ -86,6 +94,11 @@ def method_body_file(
     return dict(i=body.i, f=body.f)
 
 
+@api.post("/test-union-type", response=Response)
+def method_union_payload(request, data: Union[TypeA, TypeB]):
+    return dict(i=data.i, f=data.f)
+
+
 @api.post(
     "/test-title-description/",
     tags=["a-tag"],
@@ -164,6 +177,22 @@ def test_schema(schema):
             },
             "required": ["i", "f"],
         },
+        "TypeA": {
+            "properties": {
+                "a": {"title": "A", "type": "string"},
+            },
+            "required": ["a"],
+            "title": "TypeA",
+            "type": "object",
+        },
+        "TypeB": {
+            "properties": {
+                "b": {"title": "B", "type": "string"},
+            },
+            "required": ["b"],
+            "title": "TypeB",
+            "type": "object",
+        },
     }
 
 
@@ -251,6 +280,22 @@ def test_schema_list(schema):
             "title": "Payload",
             "type": "object",
         },
+        "TypeA": {
+            "properties": {
+                "a": {"title": "A", "type": "string"},
+            },
+            "required": ["a"],
+            "title": "TypeA",
+            "type": "object",
+        },
+        "TypeB": {
+            "properties": {
+                "b": {"title": "B", "type": "string"},
+            },
+            "required": ["b"],
+            "title": "TypeB",
+            "type": "object",
+        },
         "Response": {
             "properties": {
                 "f": {"description": "f desc", "title": "f title", "type": "number"},
@@ -566,6 +611,25 @@ def test_schema_title_description(schema):
     }
 
 
+def test_union_payload_type(schema):
+    method = schema["paths"]["/api/test-union-type"]["post"]
+
+    assert method["requestBody"] == {
+        "content": {
+            "application/json": {
+                "schema": {
+                    "anyOf": [
+                        {"$ref": "#/components/schemas/TypeA"},
+                        {"$ref": "#/components/schemas/TypeB"},
+                    ],
+                    "title": "Data",
+                }
+            }
+        },
+        "required": True,
+    }
+
+
 def test_get_openapi_urls():
 
     api = NinjaAPI(openapi_url=None)