Repository: vitalik/django-ninja
Pull request #504 (2022-07-11 18:44:04)

feat: openapi schema improvements
Implemented options `examples, example, deprecated, include_in_schema` for param functions. 
Also `openapi_extra` option added on routes.


Patch:
diff --git a/docs/docs/reference/operations-parameters.md b/docs/docs/reference/operations-parameters.md
index 8834006..b255fa7 100644
--- a/docs/docs/reference/operations-parameters.md
+++ b/docs/docs/reference/operations-parameters.md
@@ -183,3 +183,51 @@ reverse('api-1.0.0:tasks')
 ```
 
 See the [Reverse Resolution of URLs](../guides/urls.md) guide for more details.
+
+
+## openapi_extra
+You can customize your OpenAPI schema for specific endpoint (detail [OpenAPI Customize Options](https://swagger.io/docs/specification/about/))
+```Python hl_lines="1 26"
+# You can set requestBody from openapi_extra
+@api.get(
+    "/tasks",
+    openapi_extra={
+        "requestBody": {
+            "content": {
+                "application/json": {
+                    "schema": {
+                        "required": ["email"],
+                        "type": "object",
+                        "properties": {
+                            "name": {"type": "string"},
+                            "phone": {"type": "number"},
+                            "email": {"type": "string"},
+                        },
+                    }
+                }
+            },
+            "required": True,
+        }
+    },
+)
+def some_operation(request):
+    pass
+    
+# You can add additional responses to the automatically generated schema
+@api.post(
+    "/tasks",
+    openapi_extra={
+        "responses": {
+            "400": {
+                "description": "Error Response",
+            },
+            "404": {
+                "description": "Not Found Response",
+            },
+        },
+    },
+)
+def some_operation_2(request):
+    pass
+
+```
diff --git a/ninja/main.py b/ninja/main.py
index 2dfa434..a28e608 100644
--- a/ninja/main.py
+++ b/ninja/main.py
@@ -113,6 +113,7 @@ class NinjaAPI:
         exclude_none: bool = False,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
+        openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> Callable[[TCallable], TCallable]:
         """
         `GET` operation. See <a href="../operations-parameters">operations
@@ -133,6 +134,7 @@ class NinjaAPI:
             exclude_none=exclude_none,
             url_name=url_name,
             include_in_schema=include_in_schema,
+            openapi_extra=openapi_extra,
         )
 
     def post(
@@ -152,6 +154,7 @@ class NinjaAPI:
         exclude_none: bool = False,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
+        openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> Callable[[TCallable], TCallable]:
         """
         `POST` operation. See <a href="../operations-parameters">operations
@@ -172,6 +175,7 @@ class NinjaAPI:
             exclude_none=exclude_none,
             url_name=url_name,
             include_in_schema=include_in_schema,
+            openapi_extra=openapi_extra,
         )
 
     def delete(
@@ -191,6 +195,7 @@ class NinjaAPI:
         exclude_none: bool = False,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
+        openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> Callable[[TCallable], TCallable]:
         """
         `DELETE` operation. See <a href="../operations-parameters">operations
@@ -211,6 +216,7 @@ class NinjaAPI:
             exclude_none=exclude_none,
             url_name=url_name,
             include_in_schema=include_in_schema,
+            openapi_extra=openapi_extra,
         )
 
     def patch(
@@ -230,6 +236,7 @@ class NinjaAPI:
         exclude_none: bool = False,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
+        openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> Callable[[TCallable], TCallable]:
         """
         `PATCH` operation. See <a href="../operations-parameters">operations
@@ -250,6 +257,7 @@ class NinjaAPI:
             exclude_none=exclude_none,
             url_name=url_name,
             include_in_schema=include_in_schema,
+            openapi_extra=openapi_extra,
         )
 
     def put(
@@ -269,6 +277,7 @@ class NinjaAPI:
         exclude_none: bool = False,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
+        openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> Callable[[TCallable], TCallable]:
         """
         `PUT` operation. See <a href="../operations-parameters">operations
@@ -289,6 +298,7 @@ class NinjaAPI:
             exclude_none=exclude_none,
             url_name=url_name,
             include_in_schema=include_in_schema,
+            openapi_extra=openapi_extra,
         )
 
     def api_operation(
@@ -309,6 +319,7 @@ class NinjaAPI:
         exclude_none: bool = False,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
+        openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> Callable[[TCallable], TCallable]:
         return self.default_router.api_operation(
             methods,
@@ -326,6 +337,7 @@ class NinjaAPI:
             exclude_none=exclude_none,
             url_name=url_name,
             include_in_schema=include_in_schema,
+            openapi_extra=openapi_extra,
         )
 
     def add_router(
diff --git a/ninja/openapi/schema.py b/ninja/openapi/schema.py
index 0c59c0a..019fbd3 100644
--- a/ninja/openapi/schema.py
+++ b/ninja/openapi/schema.py
@@ -88,6 +88,21 @@ class OpenAPISchema(dict):
                     result[method.lower()] = operation_details
         return result
 
+    def deep_dict_update(
+        self, main_dict: Dict[Any, Any], update_dict: Dict[Any, Any]
+    ) -> None:
+        for key in update_dict:
+            if (
+                key in main_dict
+                and isinstance(main_dict[key], dict)
+                and isinstance(update_dict[key], dict)
+            ):
+                self.deep_dict_update(
+                    main_dict[key], update_dict[key]
+                )  # pragma: no cover
+            else:
+                main_dict[key] = update_dict[key]
+
     def operation_details(self, operation: Operation) -> DictStrAny:
         op_id = operation.operation_id or self.api.get_openapi_operation_id(operation)
         if op_id in self.all_operation_ids:
@@ -119,6 +134,9 @@ class OpenAPISchema(dict):
         if security:
             result["security"] = security
 
+        if operation.openapi_extra:
+            self.deep_dict_update(result, operation.openapi_extra)
+
         return result
 
     def operation_parameters(self, operation: Operation) -> List[DictStrAny]:
@@ -145,6 +163,9 @@ class OpenAPISchema(dict):
             for p_name, p_schema, p_required in flatten_properties(
                 name, details, is_required, schema.get("definitions", {})
             ):
+                if not p_schema.get("include_in_schema", True):
+                    continue
+
                 param = {
                     "in": model._param_source,
                     "name": p_name,
@@ -155,6 +176,12 @@ class OpenAPISchema(dict):
                 # copy description from schema description to param description
                 if "description" in p_schema:
                     param["description"] = p_schema["description"]
+                if "examples" in p_schema:
+                    param["examples"] = p_schema["examples"]
+                elif "example" in p_schema:
+                    param["example"] = p_schema["example"]
+                if "deprecated" in p_schema:
+                    param["deprecated"] = p_schema["deprecated"]
 
                 result.append(param)
 
diff --git a/ninja/operation.py b/ninja/operation.py
index 4737843..76b3a6f 100644
--- a/ninja/operation.py
+++ b/ninja/operation.py
@@ -50,6 +50,7 @@ class Operation:
         exclude_defaults: bool = False,
         exclude_none: bool = False,
         include_in_schema: bool = True,
+        openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> None:
         self.is_async = False
         self.path: str = path
@@ -78,6 +79,7 @@ class Operation:
         self.tags = tags
         self.deprecated = deprecated
         self.include_in_schema = include_in_schema
+        self.openapi_extra = openapi_extra
 
         # Exporting models params
         self.by_alias = by_alias
@@ -287,6 +289,7 @@ class PathView:
         exclude_none: bool = False,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
+        openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> Operation:
         if url_name:
             self.url_name = url_name
@@ -312,6 +315,7 @@ class PathView:
             exclude_defaults=exclude_defaults,
             exclude_none=exclude_none,
             include_in_schema=include_in_schema,
+            openapi_extra=openapi_extra,
         )
 
         self.operations.append(operation)
diff --git a/ninja/params.py b/ninja/params.py
index a87fade..286a780 100644
--- a/ninja/params.py
+++ b/ninja/params.py
@@ -1,4 +1,4 @@
-from typing import Any, Optional
+from typing import Any, Dict, Optional
 
 from pydantic.fields import FieldInfo, ModelField
 
@@ -22,7 +22,10 @@ class Param(FieldInfo):
         min_length: int = None,
         max_length: int = None,
         regex: str = None,
+        example: Any = None,
+        examples: Optional[Dict[str, Any]] = None,
         deprecated: bool = None,
+        include_in_schema: bool = True,
         # param_name: str = None,
         # param_type: Any = None,
         **extra: Any,
@@ -31,6 +34,14 @@ class Param(FieldInfo):
         # self.param_name: str = None
         # self.param_type: Any = None
         self.model_field: Optional[ModelField] = None
+        if example:
+            extra["example"] = example
+        if examples:
+            extra["examples"] = examples
+        if deprecated:
+            extra["deprecated"] = deprecated
+        if not include_in_schema:
+            extra["include_in_schema"] = include_in_schema
         super().__init__(
             default,
             alias=alias,
diff --git a/ninja/params_functions.py b/ninja/params_functions.py
index a8fe5db..e54777b 100644
--- a/ninja/params_functions.py
+++ b/ninja/params_functions.py
@@ -3,7 +3,7 @@
 # what it basically does makes function XXX that create instance of params.XXX
 # and annotates function with result = Any
 # idea from https://github.com/tiangolo/fastapi/blob/master/fastapi/param_functions.py
-from typing import Any, Optional
+from typing import Any, Dict, Optional
 
 from ninja import params
 
@@ -21,7 +21,10 @@ def Path(  # noqa: N802
     min_length: Optional[int] = None,
     max_length: Optional[int] = None,
     regex: Optional[str] = None,
+    example: Any = None,
+    examples: Optional[Dict[str, Any]] = None,
     deprecated: Optional[bool] = None,
+    include_in_schema: bool = True,
     **extra: Any,
 ) -> Any:
     return params.Path(
@@ -36,7 +39,10 @@ def Path(  # noqa: N802
         min_length=min_length,
         max_length=max_length,
         regex=regex,
+        example=example,
+        examples=examples,
         deprecated=deprecated,
+        include_in_schema=include_in_schema,
         **extra,
     )
 
@@ -54,7 +60,10 @@ def Query(  # noqa: N802
     min_length: Optional[int] = None,
     max_length: Optional[int] = None,
     regex: Optional[str] = None,
+    example: Any = None,
+    examples: Optional[Dict[str, Any]] = None,
     deprecated: Optional[bool] = None,
+    include_in_schema: bool = True,
     **extra: Any,
 ) -> Any:
     return params.Query(
@@ -69,7 +78,10 @@ def Query(  # noqa: N802
         min_length=min_length,
         max_length=max_length,
         regex=regex,
+        example=example,
+        examples=examples,
         deprecated=deprecated,
+        include_in_schema=include_in_schema,
         **extra,
     )
 
@@ -87,7 +99,10 @@ def Header(  # noqa: N802
     min_length: Optional[int] = None,
     max_length: Optional[int] = None,
     regex: Optional[str] = None,
+    example: Any = None,
+    examples: Optional[Dict[str, Any]] = None,
     deprecated: Optional[bool] = None,
+    include_in_schema: bool = True,
     **extra: Any,
 ) -> Any:
     return params.Header(
@@ -102,7 +117,10 @@ def Header(  # noqa: N802
         min_length=min_length,
         max_length=max_length,
         regex=regex,
+        example=example,
+        examples=examples,
         deprecated=deprecated,
+        include_in_schema=include_in_schema,
         **extra,
     )
 
@@ -120,7 +138,10 @@ def Cookie(  # noqa: N802
     min_length: Optional[int] = None,
     max_length: Optional[int] = None,
     regex: Optional[str] = None,
+    example: Any = None,
+    examples: Optional[Dict[str, Any]] = None,
     deprecated: Optional[bool] = None,
+    include_in_schema: bool = True,
     **extra: Any,
 ) -> Any:
     return params.Cookie(
@@ -135,7 +156,10 @@ def Cookie(  # noqa: N802
         min_length=min_length,
         max_length=max_length,
         regex=regex,
+        example=example,
+        examples=examples,
         deprecated=deprecated,
+        include_in_schema=include_in_schema,
         **extra,
     )
 
@@ -153,7 +177,10 @@ def Body(  # noqa: N802
     min_length: Optional[int] = None,
     max_length: Optional[int] = None,
     regex: Optional[str] = None,
+    example: Any = None,
+    examples: Optional[Dict[str, Any]] = None,
     deprecated: Optional[bool] = None,
+    include_in_schema: bool = True,
     **extra: Any,
 ) -> Any:
     return params.Body(
@@ -168,7 +195,10 @@ def Body(  # noqa: N802
         min_length=min_length,
         max_length=max_length,
         regex=regex,
+        example=example,
+        examples=examples,
         deprecated=deprecated,
+        include_in_schema=include_in_schema,
         **extra,
     )
 
@@ -186,7 +216,10 @@ def Form(  # noqa: N802
     min_length: Optional[int] = None,
     max_length: Optional[int] = None,
     regex: Optional[str] = None,
+    example: Any = None,
+    examples: Optional[Dict[str, Any]] = None,
     deprecated: Optional[bool] = None,
+    include_in_schema: bool = True,
     **extra: Any,
 ) -> Any:
     return params.Form(
@@ -201,7 +234,10 @@ def Form(  # noqa: N802
         min_length=min_length,
         max_length=max_length,
         regex=regex,
+        example=example,
+        examples=examples,
         deprecated=deprecated,
+        include_in_schema=include_in_schema,
         **extra,
     )
 
@@ -219,7 +255,10 @@ def File(  # noqa: N802
     min_length: Optional[int] = None,
     max_length: Optional[int] = None,
     regex: Optional[str] = None,
+    example: Any = None,
+    examples: Optional[Dict[str, Any]] = None,
     deprecated: Optional[bool] = None,
+    include_in_schema: bool = True,
     **extra: Any,
 ) -> Any:
     return params.File(
@@ -234,6 +273,9 @@ def File(  # noqa: N802
         min_length=min_length,
         max_length=max_length,
         regex=regex,
+        example=example,
+        examples=examples,
         deprecated=deprecated,
+        include_in_schema=include_in_schema,
         **extra,
     )
diff --git a/ninja/router.py b/ninja/router.py
index 2bfaa11..200f605 100644
--- a/ninja/router.py
+++ b/ninja/router.py
@@ -42,6 +42,7 @@ class Router:
         exclude_none: bool = False,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
+        openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> Callable[[TCallable], TCallable]:
         return self.api_operation(
             ["GET"],
@@ -59,6 +60,7 @@ class Router:
             exclude_none=exclude_none,
             url_name=url_name,
             include_in_schema=include_in_schema,
+            openapi_extra=openapi_extra,
         )
 
     def post(
@@ -78,6 +80,7 @@ class Router:
         exclude_none: bool = False,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
+        openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> Callable[[TCallable], TCallable]:
         return self.api_operation(
             ["POST"],
@@ -95,6 +98,7 @@ class Router:
             exclude_none=exclude_none,
             url_name=url_name,
             include_in_schema=include_in_schema,
+            openapi_extra=openapi_extra,
         )
 
     def delete(
@@ -114,6 +118,7 @@ class Router:
         exclude_none: bool = False,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
+        openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> Callable[[TCallable], TCallable]:
         return self.api_operation(
             ["DELETE"],
@@ -131,6 +136,7 @@ class Router:
             exclude_none=exclude_none,
             url_name=url_name,
             include_in_schema=include_in_schema,
+            openapi_extra=openapi_extra,
         )
 
     def patch(
@@ -150,6 +156,7 @@ class Router:
         exclude_none: bool = False,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
+        openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> Callable[[TCallable], TCallable]:
         return self.api_operation(
             ["PATCH"],
@@ -167,6 +174,7 @@ class Router:
             exclude_none=exclude_none,
             url_name=url_name,
             include_in_schema=include_in_schema,
+            openapi_extra=openapi_extra,
         )
 
     def put(
@@ -186,6 +194,7 @@ class Router:
         exclude_none: bool = False,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
+        openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> Callable[[TCallable], TCallable]:
         return self.api_operation(
             ["PUT"],
@@ -203,6 +212,7 @@ class Router:
             exclude_none=exclude_none,
             url_name=url_name,
             include_in_schema=include_in_schema,
+            openapi_extra=openapi_extra,
         )
 
     def api_operation(
@@ -223,6 +233,7 @@ class Router:
         exclude_none: bool = False,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
+        openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> Callable[[TCallable], TCallable]:
         def decorator(view_func: TCallable) -> TCallable:
             self.add_api_operation(
@@ -242,6 +253,7 @@ class Router:
                 exclude_none=exclude_none,
                 url_name=url_name,
                 include_in_schema=include_in_schema,
+                openapi_extra=openapi_extra,
             )
             return view_func
 
@@ -266,6 +278,7 @@ class Router:
         exclude_none: bool = False,
         url_name: Optional[str] = None,
         include_in_schema: bool = True,
+        openapi_extra: Optional[Dict[str, Any]] = None,
     ) -> None:
         if path not in self.path_operations:
             path_view = PathView()
@@ -289,6 +302,7 @@ class Router:
             exclude_none=exclude_none,
             url_name=url_name,
             include_in_schema=include_in_schema,
+            openapi_extra=openapi_extra,
         )
         if self.api:
             path_view.set_api_instance(self.api, self)


Test patch:
diff --git a/tests/test_openapi_params.py b/tests/test_openapi_params.py
index 51922f3..243606b 100644
--- a/tests/test_openapi_params.py
+++ b/tests/test_openapi_params.py
@@ -27,6 +27,31 @@ def operation4(request):
     return {"tags": True}
 
 
+@api.get(
+    "/operation5",
+    openapi_extra={
+        "requestBody": {
+            "content": {
+                "application/json": {
+                    "schema": {
+                        "required": ["email"],
+                        "type": "object",
+                        "properties": {
+                            "name": {"type": "string"},
+                            "phone": {"type": "number"},
+                            "email": {"type": "string"},
+                        },
+                    }
+                }
+            },
+            "required": True,
+        },
+    },
+)
+def operation5(request):
+    return {"openapi_extra": True}
+
+
 @api.get("/not-included", include_in_schema=False)
 def not_included(request):
     return True
@@ -40,16 +65,19 @@ def test_schema():
     op2 = schema["paths"]["/api/operation2"]["get"]
     op3 = schema["paths"]["/api/operation3"]["get"]
     op4 = schema["paths"]["/api/operation4"]["get"]
+    op5 = schema["paths"]["/api/operation5"]["get"]
 
     assert op1["operationId"] == "my_id"
     assert op2["operationId"] == "test_openapi_params_operation2"
     assert op3["operationId"] == "test_openapi_params_operation3"
     assert op4["operationId"] == "test_openapi_params_operation4"
+    assert op5["operationId"] == "test_openapi_params_operation5"
 
     assert op1["summary"] == "Operation 1"
     assert op2["summary"] == "Operation2"
     assert op3["summary"] == "Summary from argument"
     assert op4["summary"] == "Operation4"
+    assert op5["summary"] == "Operation5"
 
     assert op1["description"] == "This will be in description"
 
@@ -60,6 +88,23 @@ def test_schema():
 
     assert op4["tags"] == ["tag1", "tag2"]
 
+    assert op5["requestBody"] == {
+        "content": {
+            "application/json": {
+                "schema": {
+                    "required": ["email"],
+                    "type": "object",
+                    "properties": {
+                        "name": {"type": "string"},
+                        "phone": {"type": "number"},
+                        "email": {"type": "string"},
+                    },
+                }
+            }
+        },
+        "required": True,
+    }
+
 
 def test_not_included():
     assert list(schema["paths"].keys()) == [
@@ -67,5 +112,6 @@ def test_not_included():
         "/api/operation2",
         "/api/operation3",
         "/api/operation4",
+        "/api/operation5",
     ]
     # checking that "/not-included" is not there
diff --git a/tests/test_openapi_schema.py b/tests/test_openapi_schema.py
index a5fb00b..fae82e7 100644
--- a/tests/test_openapi_schema.py
+++ b/tests/test_openapi_schema.py
@@ -121,6 +121,31 @@ def method_test_title_description(
     return dict(i=param1, f=param2)
 
 
+@api.post("/test-deprecated-example-examples/")
+def method_test_deprecated_example_examples(
+    request,
+    param1: int = Query(None, deprecated=True),
+    param2: str = Query(..., example="Example Value"),
+    param3: str = Query(
+        ...,
+        max_length=5,
+        examples={
+            "normal": {
+                "summary": "A normal example",
+                "description": "A **normal** string works correctly.",
+                "value": "Foo",
+            },
+            "invalid": {
+                "summary": "Invalid data is rejected with an error",
+                "value": "MoreThan5Length",
+            },
+        },
+    ),
+    param4: int = Query(None, deprecated=True, include_in_schema=False),
+):
+    return dict(i=param2, f=param3)
+
+
 def test_schema_views(client: Client):
     assert client.get("/api/").status_code == 404
     assert client.get("/api/docs").status_code == 200
@@ -619,6 +644,65 @@ def test_schema_title_description(schema):
     }
 
 
+def test_schema_deprecated_example_examples(schema):
+    method_list = schema["paths"]["/api/test-deprecated-example-examples/"]["post"]
+
+    assert method_list["parameters"] == [
+        {
+            "deprecated": True,
+            "in": "query",
+            "name": "param1",
+            "required": False,
+            "schema": {"title": "Param1", "type": "integer", "deprecated": True},
+        },
+        {
+            "in": "query",
+            "name": "param2",
+            "required": True,
+            "schema": {"title": "Param2", "type": "string", "example": "Example Value"},
+            "example": "Example Value",
+        },
+        {
+            "in": "query",
+            "name": "param3",
+            "required": True,
+            "schema": {
+                "maxLength": 5,
+                "title": "Param3",
+                "type": "string",
+                "examples": {
+                    "invalid": {
+                        "summary": "Invalid data is rejected with an error",
+                        "value": "MoreThan5Length",
+                    },
+                    "normal": {
+                        "description": "A **normal** string works correctly.",
+                        "summary": "A normal example",
+                        "value": "Foo",
+                    },
+                },
+            },
+            "examples": {
+                "invalid": {
+                    "summary": "Invalid data is rejected with an error",
+                    "value": "MoreThan5Length",
+                },
+                "normal": {
+                    "description": "A **normal** string works correctly.",
+                    "summary": "A normal example",
+                    "value": "Foo",
+                },
+            },
+        },
+    ]
+
+    assert method_list["responses"] == {
+        200: {
+            "description": "OK",
+        }
+    }
+
+
 def test_union_payload_type(schema):
     method = schema["paths"]["/api/test-union-type"]["post"]
 
@@ -659,7 +743,6 @@ def test_union_payload_simple(schema):
 
 
 def test_get_openapi_urls():
-
     api = NinjaAPI(openapi_url=None)
     paths = get_openapi_urls(api)
     assert len(paths) == 0
@@ -676,7 +759,6 @@ def test_get_openapi_urls():
 
 
 def test_unique_operation_ids():
-
     api = NinjaAPI()
 
     @api.get("/1")