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

Issue:
Are OpenAPI example objects possible with Sanic's OpenAPI extension?
Hi - I am creating this issue because I have not been able to find an answer to this question in any of the Sanic repositories nor using Sanic docs.

My setup is as follows:
* Sanic based API
* Sanic Extensions based OpenAPI documentation
* Using dataclasses to represent response structure.

By specifying `@openapi.response(...)` decorators using my dataclasses, I am able to specify the response structure of my API. I am now trying to add example objects, too, which should illustrate a specific response example for a specific API route.

I have not been able to identify if this is possible using the decorator or in any other way. I do notice that it is possible to specify example objects per the OpenAPI 3 docs, and thus possibly via the advanced way of specifying Sanic OpenAPI - via the operation level YAML (https://sanic.dev/en/plugins/sanic-ext/openapi/autodoc.html#operation-level-yaml). However, this gets ugly quite soon as some of my JSONs are rather large.

I have looked at the code and see that the `response` decorator supports `kwargs`. Is there any way that such example objects can be specified through these keywords arguments, or in any other way, in such a way that I can just instantiate objects from my dataclasses and pass them to e.g. Sanic's OpenAPI decorators?

That would be really helpful!

Thanks a lot :) 

Pull request description:
Allow field based spec def on models
Closes #133 

Supports adding data to fields using the supported model types.

```py
@dataclass
class FooDataclass:
    priority: int = field(
        metadata={"openapi": {"exclusiveMinimum": 1, "exclusiveMaximum": 10}}
    )
    ident: str = field(
        default="XXXX", metadata={"openapi": {"example": "ABC123"}}
    )


@attrs.define
class FooAttrs:
    priority: int = attrs.field(
        metadata={"openapi": {"exclusiveMinimum": 1, "exclusiveMaximum": 10}}
    )
    ident: str = attrs.field(
        default="XXXX", metadata={"openapi": {"example": "ABC123"}}
    )


class FooPydanticBaseModel(BaseModel):
    priority: int = Field(gt=1, lt=10)
    ident: str = Field("XXXX", example="ABC123")


@pydataclass
class FooPydanticDataclass:
    priority: int = Field(gt=1, lt=10)
    ident: str = Field("XXXX", example="ABC123")
```

Patch:
diff --git a/sanic_ext/extensions/openapi/types.py b/sanic_ext/extensions/openapi/types.py
index ad087ca..228838f 100644
--- a/sanic_ext/extensions/openapi/types.py
+++ b/sanic_ext/extensions/openapi/types.py
@@ -1,5 +1,6 @@
 import json
 import uuid
+from dataclasses import MISSING, is_dataclass
 from datetime import date, datetime, time
 from enum import Enum
 from inspect import getmembers, isclass, isfunction, ismethod
@@ -16,7 +17,14 @@ from typing import (
 
 from sanic_routing.patterns import nonemptystr
 
-from sanic_ext.utils.typing import is_generic
+from sanic_ext.utils.typing import is_attrs, is_generic, is_pydantic
+
+try:
+    import attrs
+
+    NOTHING = attrs.NOTHING
+except ImportError:
+    NOTHING = object()
 
 
 class Definition:
@@ -103,7 +111,9 @@ class Schema(Definition):
             return Schema(
                 oneOf=[Schema.make(arg) for arg in filtered], **kwargs
             )
-            # return Schema.make(value, **kwargs)
+
+        for field in ("type", "format"):
+            kwargs.pop(field, None)
 
         if isinstance(value, Schema):
             return value
@@ -277,8 +287,40 @@ class Object(Schema):
 
     @classmethod
     def make(cls, value: Any, **kwargs):
+        extra: Dict[str, Any] = {}
+
+        # Extract from field metadata if pydantic, attrs, or dataclass
+        if isclass(value):
+            fields = ()
+            if is_pydantic(value):
+                try:
+                    value = value.__pydantic_model__
+                except AttributeError:
+                    ...
+                extra = value.schema()["properties"]
+            elif is_attrs(value):
+                fields = value.__attrs_attrs__
+            elif is_dataclass(value):
+                fields = value.__dataclass_fields__.values()
+            if fields:
+                extra = {
+                    field.name: {
+                        "title": field.name.title(),
+                        **(
+                            {"default": field.default}
+                            if field.default not in (MISSING, NOTHING)
+                            else {}
+                        ),
+                        **dict(field.metadata).get("openapi", {}),
+                    }
+                    for field in fields
+                }
+
         return cls(
-            {k: Schema.make(v) for k, v in _properties(value).items()},
+            {
+                k: Schema.make(v, **extra.get(k, {}))
+                for k, v in _properties(value).items()
+            },
             **kwargs,
         )
 


Test patch:
diff --git a/tests/extensions/openapi/test_model_fields.py b/tests/extensions/openapi/test_model_fields.py
new file mode 100644
index 0000000..881f054
--- /dev/null
+++ b/tests/extensions/openapi/test_model_fields.py
@@ -0,0 +1,76 @@
+from dataclasses import dataclass, field
+
+import attrs
+import pytest
+from pydantic import BaseModel, Field
+from pydantic.dataclasses import dataclass as pydataclass
+
+from sanic_ext import openapi
+
+from .utils import get_spec
+
+
+@dataclass
+class FooDataclass:
+    priority: int = field(
+        metadata={"openapi": {"exclusiveMinimum": 1, "exclusiveMaximum": 10}}
+    )
+    ident: str = field(
+        default="XXXX", metadata={"openapi": {"example": "ABC123"}}
+    )
+
+
+@attrs.define
+class FooAttrs:
+    priority: int = attrs.field(
+        metadata={"openapi": {"exclusiveMinimum": 1, "exclusiveMaximum": 10}}
+    )
+    ident: str = attrs.field(
+        default="XXXX", metadata={"openapi": {"example": "ABC123"}}
+    )
+
+
+class FooPydanticBaseModel(BaseModel):
+    priority: int = Field(gt=1, lt=10)
+    ident: str = Field("XXXX", example="ABC123")
+
+
+@pydataclass
+class FooPydanticDataclass:
+    priority: int = Field(gt=1, lt=10)
+    ident: str = Field("XXXX", example="ABC123")
+
+
+@pytest.mark.parametrize(
+    "Foo",
+    (
+        FooDataclass,
+        FooAttrs,
+        FooPydanticBaseModel,
+        FooPydanticDataclass,
+    ),
+)
+def test_pydantic_base_model(app, Foo):
+    @app.get("/")
+    @openapi.definition(body={"application/json": Foo})
+    async def handler(_):
+        ...
+
+    spec = get_spec(app)
+    foo_props = spec["paths"]["/"]["get"]["requestBody"]["content"][
+        "application/json"
+    ]["schema"]["properties"]
+
+    assert foo_props["ident"] == {
+        "title": "Ident",
+        "type": "string",
+        "default": "XXXX",
+        "example": "ABC123",
+    }
+    assert foo_props["priority"] == {
+        "title": "Priority",
+        "type": "integer",
+        "format": "int32",
+        "exclusiveMinimum": 1,
+        "exclusiveMaximum": 10,
+    }