Repository: sanic-org/sanic-ext (python)

Issue:
[Bug] When Pydantic model refers to another model, swagger errors out and redoc crashes with an error
**Describe the bug**
Invalid openapi spec is created when nested models are present in Pydantic models.

**Screenshots**
Redoc error:
![image](https://user-images.githubusercontent.com/22248828/203652682-b41c0b46-8b6e-493d-a503-0198164cb891.png)

SwaggeUI error:
![image](https://user-images.githubusercontent.com/22248828/203652720-1b57ef7d-9def-484b-b212-dfc3b4d3a1d4.png)

**To Reproduce**
Create an endpoint having a response set by Pydantic
```py
from pydantic import BaseModel
from datetime import datetime

class Alert(BaseModel):
    hit: dict[str, Any]
    last_updated: datetime

class AlertResponse(BaseModel):
    alert: Alert
    rule_id: str

...
@openapi.definition(
    response={'application/json': AlertResponse.schema()}
)
def get_alert(request: Request) -> HTTPResponse:
...
```

The resulting openapi spec is:
```yaml
openapi: 3.0.3
info:
  title: Test API
  version: 0.0.1
paths:
  /api/v1/alerts/{id}:
    get:
      operationId: get~alerts.get_alert
      summary: Get Alert
      tags:
        - alerts
      parameters:
        - name: id
          schema:
            type: string
          required: true
          in: path
      responses:
        default:
          content:
            application/json:
              schema:
                title: AlertResponse
                type: object
                properties:
                  alert:
                    $ref: '#/definitions/Alert'
                  rule_id:
                    title: Rule Id
                    type: string
                required:
                  - alert
                definitions:
                  Alert:
                    title: Alert
                    type: object
                    properties:
                      hit:
                        title: Hit
                        type: object
                      last_updated:
                        title: Last Updated
                        type: string
                        format: date-time
                    required:
                      - hit
                      - last_updated
          description: Default Response
tags:
  - name: alerts
```

And should be

```diff
openapi: 3.0.3
info:
  title: Test API
  version: 0.0.1
+definitions:
+  Alert:
+    title: Alert
+    type: object
+    properties:
+      hit:
+        title: Hit
+        type: object
+      last_updated:
+        title: Last Updated
+        type: string
+        format: date-time
+    required:
+      - hit
+      - last_updated
paths:
  /api/v1/alerts/{id}:
    get:
      operationId: get~alerts.get_alert
      summary: Get Alert
      tags:
        - alerts
      parameters:
        - name: id
          schema:
            type: string
          required: true
          in: path
      responses:
        default:
          content:
            application/json:
              schema:
                title: AlertResponse
                type: object
                properties:
                  alert:
                    $ref: '#/definitions/Alert'
                  rule_id:
                    title: Rule Id
                    type: string
                required:
                  - alert
-               definitions:
-                 Alert:
-                   title: Alert
-                   type: object
-                   properties:
-                     hit:
-                       title: Hit
-                       type: object
-                     last_updated:
-                       title: Last Updated
-                       type: string
-                       format: date-time
-                   required:
-                     - hit
-                     - last_updated
          description: Default Response
tags:
  - name: alerts
```

Putting it in https://editor.swagger.io/ also points out the errors in the spec

**Expected behavior**
For definitions to be placed at the root level of the spec

**Environment (please complete the following information):**
```
Sanic v22.9.1
mode: debug, single worker
server: sanic, HTTP/1.1
python: 3.10.5
platform: Linux-5.4.0-113-generic-x86_64-with-glibc2.28
auto-reload: enabled
packages: sanic-routing==22.8.0, sanic-ext==22.9.1
```

**Additional context**
Digging a bit, it seems like it makes sense this error happens since Pydantic does not have access to the greater openapi spec to add it's global definitions.

Would be great if there's a way to have it fixed without adding any openapi function calls, but I think a possible solution would be to add definitions from Pydantic schema models to the root of the spec via a function call, `openapi.add_definition(Alert)`?

Pull request description:
Elegantly handle Pydantic model schema
Closes #152

```python
class Alert(BaseModel):
    hit: dict[str, Any]
    last_updated: datetime


@pydataclass
class AlertResponse:
    alert: Alert
    rule_id: str
```

This will automatically drop all references and pydantic classes into components:

```json
{
  "openapi": "3.0.3",
  "info": {
    "title": "API",
    "version": "1.0.0",
    "contact": {}
  },
  "paths": {
    "/": {
      "get": {
        "operationId": "get~handler",
        "summary": "Handler",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AlertResponse"
              }
            }
          }
        },
        "responses": {
          "default": {
            "description": "OK"
          }
        }
      }
    }
  },
  "tags": [],
  "servers": [],
  "security": [],
  "components": {
    "schemas": {
      "Alert": {
        "title": "Alert",
        "type": "object",
        "properties": {
          "hit": {
            "title": "Hit",
            "type": "object"
          },
          "last_updated": {
            "title": "Last Updated",
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "hit",
          "last_updated"
        ]
      },
      "AlertResponse": {
        "title": "AlertResponse",
        "type": "object",
        "properties": {
          "alert": {
            "$ref": "#/components/schemas/Alert"
          },
          "rule_id": {
            "title": "Rule Id",
            "type": "string"
          }
        },
        "required": [
          "alert",
          "rule_id"
        ]
      }
    }
  }
}
```

Patch:
diff --git a/sanic_ext/extensions/openapi/definitions.py b/sanic_ext/extensions/openapi/definitions.py
index ab476a8..af20519 100644
--- a/sanic_ext/extensions/openapi/definitions.py
+++ b/sanic_ext/extensions/openapi/definitions.py
@@ -20,7 +20,7 @@ from typing import (
 
 from sanic.exceptions import SanicException
 
-from sanic_ext.utils.typing import contains_annotations
+from sanic_ext.utils.typing import contains_annotations, is_pydantic
 
 from .types import Definition, Schema
 
@@ -94,6 +94,12 @@ class MediaType(Definition):
                 kwargs = {**value}
                 value = kwargs.pop("schema")
             return MediaType(value, **kwargs)
+        # See https://github.com/sanic-org/sanic-ext/issues/152
+        # The following lines will automatically inject pydantic models as
+        # components if that feature is desired. Until that decision is made
+        # this commented out code will remain.
+        # elif isclass(value) and is_pydantic(value):
+        #     return MediaType(Component(value))
         return MediaType(Schema.make(value))
 
     @staticmethod
@@ -128,7 +134,7 @@ class Response(Definition):
         )
 
     @staticmethod
-    def make(content, description: str = None, **kwargs):
+    def make(content, description: Optional[str] = None, **kwargs):
         if not description:
             description = "Default Response"
 
@@ -179,7 +185,7 @@ class ExternalDocumentation(Definition):
         super().__init__(url=url, description=description)
 
     @staticmethod
-    def make(url: str, description: str = None):
+    def make(url: str, description: Optional[str] = None):
         return ExternalDocumentation(url, description)
 
 
@@ -192,7 +198,7 @@ class Header(Definition):
         super().__init__(url=url, description=description)
 
     @staticmethod
-    def make(url: str, description: str = None):
+    def make(url: str, description: Optional[str] = None):
         return Header(url, description)
 
 
@@ -319,8 +325,7 @@ class SecurityScheme(Definition):
 
     @staticmethod
     def make(_type: str, cls: Type, **kwargs):
-        params = cls.__dict__ if hasattr(cls, "__dict__") else {}
-
+        params: Dict[str, Any] = getattr(cls, "__dict__", {})
         return SecurityScheme(_type, **params, **kwargs)
 
 
@@ -341,7 +346,10 @@ class Server(Definition):
     __nullable__ = None
 
     def __init__(
-        self, url: str, description: str = None, variables: dict = None
+        self,
+        url: str,
+        description: Optional[str] = None,
+        variables: Optional[Dict[str, Any]] = None,
     ):
         super().__init__(
             url=url, description=description, variables=variables or []
@@ -401,7 +409,20 @@ def Component(
     if not spec.has_component(field, name):
         prop_info = hints[field]
         type_ = prop_info.__args__[1]
-        component = type_.make(obj) if hasattr(type_, "make") else type_(obj)
+        if is_pydantic(obj):
+            try:
+                schema = obj.schema
+            except AttributeError:
+                schema = obj.__pydantic_model__.schema
+            component = schema(ref_template="#/components/schemas/{model}")
+            definitions = component.pop("definitions", None)
+            if definitions:
+                for key, value in definitions.items():
+                    spec.add_component(field, key, value)
+        else:
+            component = (
+                type_.make(obj) if hasattr(type_, "make") else type_(obj)
+            )
 
         spec.add_component(field, name, component)
 
diff --git a/sanic_ext/extensions/openapi/openapi.py b/sanic_ext/extensions/openapi/openapi.py
index 4ae5593..ede7f0a 100644
--- a/sanic_ext/extensions/openapi/openapi.py
+++ b/sanic_ext/extensions/openapi/openapi.py
@@ -27,6 +27,7 @@ from sanic_ext.extensions.openapi.builders import (
     OperationStore,
     SpecificationBuilder,
 )
+from sanic_ext.extensions.openapi.definitions import Component
 from sanic_ext.extensions.openapi.types import (
     Array,
     Binary,
@@ -67,6 +68,7 @@ __all__ = (
     "Binary",
     "Boolean",
     "Byte",
+    "Component",
     "Date",
     "DateTime",
     "Double",
@@ -140,7 +142,8 @@ def description(text: str) -> Callable[[T], T]:
 
 
 def document(
-    url: Union[str, definitions.ExternalDocumentation], description: str = None
+    url: Union[str, definitions.ExternalDocumentation],
+    description: Optional[str] = None,
 ) -> Callable[[T], T]:
     if isinstance(url, definitions.ExternalDocumentation):
         description = url.fields["description"]
diff --git a/sanic_ext/extensions/openapi/types.py b/sanic_ext/extensions/openapi/types.py
index 9f04c51..ad087ca 100644
--- a/sanic_ext/extensions/openapi/types.py
+++ b/sanic_ext/extensions/openapi/types.py
@@ -323,7 +323,7 @@ def _properties(value: object) -> Dict:
     extra = value if isinstance(value, dict) else {}
     return {
         k: v
-        for k, v in {**get_type_hints(cls), **fields, **extra}.items()
+        for k, v in {**fields, **get_type_hints(cls), **extra}.items()
         if not k.startswith("_")
     }
 


Test patch:
diff --git a/tests/extensions/openapi/test_model_to_spec.py b/tests/extensions/openapi/test_model_to_spec.py
new file mode 100644
index 0000000..8563509
--- /dev/null
+++ b/tests/extensions/openapi/test_model_to_spec.py
@@ -0,0 +1,97 @@
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Dict
+
+import attrs
+import pytest
+from pydantic import BaseModel
+from pydantic.dataclasses import dataclass as pydataclass
+
+from sanic_ext import openapi
+
+from .utils import get_spec
+
+
+@dataclass
+class AlertDataclass:
+    hit: Dict[str, int]
+    last_updated: datetime
+
+
+@dataclass
+class AlertResponseDataclass:
+    alert: AlertDataclass
+    rule_id: str
+
+
+class AlertPydanticBaseModel(BaseModel):
+    hit: Dict[str, int]
+    last_updated: datetime
+
+
+class AlertResponsePydanticBaseModel(BaseModel):
+    alert: AlertPydanticBaseModel
+    rule_id: str
+
+
+@pydataclass
+class AlertPydanticDataclass:
+    hit: Dict[str, int]
+    last_updated: datetime
+
+
+@pydataclass
+class AlertResponsePydanticDataclass:
+    alert: AlertPydanticDataclass
+    rule_id: str
+
+
+@attrs.define
+class AlertAttrs:
+    hit: Dict[str, int]
+    last_updated: datetime
+
+
+@attrs.define
+class AlertResponseAttrs:
+    alert: AlertAttrs
+    rule_id: str
+
+
+@pytest.mark.parametrize(
+    "AlertResponse,check_alert",
+    (
+        (AlertResponseDataclass, False),
+        (AlertResponseAttrs, False),
+        (AlertResponsePydanticBaseModel, True),
+        (AlertResponsePydanticDataclass, True),
+    ),
+)
+def test_pydantic_base_model(app, AlertResponse, check_alert):
+    @app.get("/")
+    @openapi.definition(
+        body={"application/json": openapi.Component(AlertResponse)}
+    )
+    async def handler(_):
+        ...
+
+    spec = get_spec(app)
+    alert_response_name = AlertResponse.__name__
+    alert_name = "Alert" + alert_response_name[13:]
+
+    assert spec["paths"]["/"]["get"]["requestBody"] == {
+        "content": {
+            "application/json": {
+                "schema": {
+                    "$ref": f"#/components/schemas/{alert_response_name}"
+                }
+            }
+        }
+    }
+    assert alert_response_name in spec["components"]["schemas"]
+
+    if check_alert:
+        assert alert_name in spec["components"]["schemas"]
+        assert spec["components"]["schemas"][alert_response_name][
+            "properties"
+        ]["alert"] == {"$ref": f"#/components/schemas/{alert_name}"}