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

Issue:
[Bug] 22.9.0 - TypeError: <class 'str'> is not JSON serializable for OpenAPI blueprint
**Describe the bug**

I've upgraded my [project](https://gitlab.com/morph027/python-signal-cli-rest-api/-/tree/release-22.9.1) to 22.9.0 and when trying to open swagger UI, it fails with the following stacktrace:

```
[2022-09-29 17:12:42 +0000] [7] [INFO] 
  ┌─────────────────────────────────────────────────────────────────────────────────────┐
  │                                    Sanic v22.9.0                                    │
  │                           Goin' Fast @ http://0.0.0.0:8080                          │
  ├───────────────────────┬─────────────────────────────────────────────────────────────┤
  │                       │     mode: production, single worker                         │
  │     ▄███ █████ ██     │   server: sanic, HTTP/1.1                                   │
  │    ██                 │   python: 3.8.10                                            │
  │     ▀███████ ███▄     │ platform: Linux-5.19.5-051905-generic-x86_64-with-glibc2.29 │
  │                 ██    │ packages: sanic-routing==22.8.0, sanic-ext==22.9.0          │
  │    ████ ████████▀     │                                                             │
  │                       │                                                             │
  │ Build Fast. Run Fast. │                                                             │
  └───────────────────────┴─────────────────────────────────────────────────────────────┘

[2022-09-29 17:12:42 +0000] [7] [WARNING] Sanic is running in PRODUCTION mode. Consider using '--debug' or '--dev' while actively developing your application.
[2022-09-29 17:12:42 +0000] [17] [INFO] Sanic Extensions:
[2022-09-29 17:12:42 +0000] [17] [INFO]   > injection [0 added]
[2022-09-29 17:12:42 +0000] [17] [INFO]   > openapi [http://0.0.0.0:8080/docs]
[2022-09-29 17:12:42 +0000] [17] [INFO]   > http 
[2022-09-29 17:12:42 +0000] [17] [INFO] Starting worker [17]
[2022-09-29 17:12:45 +0000] [17] [ERROR] Exception occurred while handling uri: 'http://localhost:8080/docs/openapi.json'
Traceback (most recent call last):
  File "handle_request", line 94, in handle_request
  File "/var/lib/python-signal-cli-rest-api/.venv/lib/python3.8/site-packages/sanic_ext/extensions/openapi/blueprint.py", line 52, in spec
    return json(SpecificationBuilder().build(request.app).serialize())
  File "/var/lib/python-signal-cli-rest-api/.venv/lib/python3.8/site-packages/sanic/response.py", line 244, in json
    dumps(body, **kwargs),
TypeError: <class 'str'> is not JSON serializable
```

**To Reproduce**

Not sure if this happens on my project only. Will try to re-create with a blank dummy project.

**Expected behavior**

Render openapi.json

Pull request description:
Allow dict of annotations
Closes #136

Fixes a side effect of #130 to allow for both:

```python
@openapi.definition(
    body={"application/json": Test.schema()},
)
```

```python
@openapi.definition(
    body={"application/json": {"test": str}}
)
```

Patch:
diff --git a/sanic_ext/extensions/openapi/definitions.py b/sanic_ext/extensions/openapi/definitions.py
index 878214a..ab476a8 100644
--- a/sanic_ext/extensions/openapi/definitions.py
+++ b/sanic_ext/extensions/openapi/definitions.py
@@ -20,6 +20,8 @@ from typing import (
 
 from sanic.exceptions import SanicException
 
+from sanic_ext.utils.typing import contains_annotations
+
 from .types import Definition, Schema
 
 
@@ -79,7 +81,9 @@ class MediaType(Definition):
     schema: Schema
     example: Any
 
-    def __init__(self, schema: Schema, **kwargs):
+    def __init__(self, schema: Union[Schema, Dict[str, Any]], **kwargs):
+        if isinstance(schema, dict) and contains_annotations(schema):
+            schema = Schema.make(schema)
         super().__init__(schema=schema, **kwargs)
 
     @staticmethod
diff --git a/sanic_ext/extensions/openapi/types.py b/sanic_ext/extensions/openapi/types.py
index ae3c222..9f04c51 100644
--- a/sanic_ext/extensions/openapi/types.py
+++ b/sanic_ext/extensions/openapi/types.py
@@ -320,9 +320,10 @@ def _properties(value: object) -> Dict:
         fields = {}
 
     cls = value if callable(value) else value.__class__
+    extra = value if isinstance(value, dict) else {}
     return {
         k: v
-        for k, v in {**get_type_hints(cls), **fields}.items()
+        for k, v in {**get_type_hints(cls), **fields, **extra}.items()
         if not k.startswith("_")
     }
 
diff --git a/sanic_ext/utils/typing.py b/sanic_ext/utils/typing.py
index c34ab05..ec5d9f3 100644
--- a/sanic_ext/utils/typing.py
+++ b/sanic_ext/utils/typing.py
@@ -1,10 +1,11 @@
 import types
 import typing
+from inspect import isclass
 
 try:
     UnionType = types.UnionType  # type: ignore
 except AttributeError:
-    UnionType = type("UnionType", (), {})
+    UnionType = type("UnionType", (), {})  # type: ignore
 
 try:
     from pydantic import BaseModel
@@ -44,3 +45,24 @@ def is_pydantic(model):
 
 def is_attrs(model):
     return ATTRS and (hasattr(model, "__attrs_attrs__"))
+
+
+def flat_values(
+    item: typing.Union[
+        typing.Dict[str, typing.Any], typing.Iterable[typing.Any]
+    ]
+) -> typing.Set[typing.Any]:
+    values = set()
+    if isinstance(item, dict):
+        item = item.values()
+    for value in item:
+        if isinstance(value, dict) or isinstance(value, list):
+            values.update(flat_values(value))
+        else:
+            values.add(value)
+    return values
+
+
+def contains_annotations(d: typing.Dict[str, typing.Any]) -> bool:
+    values = flat_values(d)
+    return any(isclass(q) or is_generic(q) for q in values)
diff --git a/tox.ini b/tox.ini
index 0b8032d..b0c2c22 100644
--- a/tox.ini
+++ b/tox.ini
@@ -9,6 +9,7 @@ python =
     3.10: py310
 
 [testenv]
+extras = test
 deps =
     git+https://github.com/sanic-org/sanic.git#egg=sanic
     pydantic
@@ -17,7 +18,6 @@ deps =
 ;     sanic21.6: sanic_testing
 
 commands =
-    pip install -e .['test']
     pytest {posargs:tests}
 
 


Test patch:
diff --git a/tests/extensions/injection/test_injection_registry.py b/tests/extensions/injection/test_injection_registry.py
index 3f067e0..d248421 100644
--- a/tests/extensions/injection/test_injection_registry.py
+++ b/tests/extensions/injection/test_injection_registry.py
@@ -103,6 +103,7 @@ async def test_finalize_allowed_types(raises, allowed):
         injections.finalize(Mock(), ConstantRegistry({}), allowed)
 
 
+@pytest.mark.asyncio
 async def test_constructor():
     injections = InjectionRegistry()
     injections.register(Foo, Foo.create)
@@ -118,6 +119,7 @@ async def test_constructor():
     foo.mock.assert_awaited_once_with(request, 999)
 
 
+@pytest.mark.asyncio
 async def test_constructor_nested():
     injections = InjectionRegistry()
     injections.register(Foo, Foo.create)
@@ -135,6 +137,7 @@ async def test_constructor_nested():
     bar.foo.mock.assert_awaited_once_with(request, 999)
 
 
+@pytest.mark.asyncio
 async def test_constructor_failure_kwargs():
     injections = InjectionRegistry()
     injections.register(Foo, Foo.create)
@@ -150,6 +153,7 @@ async def test_constructor_failure_kwargs():
         await constructor(request)
 
 
+@pytest.mark.asyncio
 async def test_constructor_failure_nested():
     injections = InjectionRegistry()
     injections.register(Foo, Foo.create)
@@ -166,6 +170,7 @@ async def test_constructor_failure_nested():
         await constructor(request)
 
 
+@pytest.mark.asyncio
 async def test_constructor_with_inherited_request():
     injections = InjectionRegistry()
     injections.register(Baz, Baz.create)
diff --git a/tests/extensions/openapi/test_decorators.py b/tests/extensions/openapi/test_decorators.py
index a7c3e7b..b5c690f 100644
--- a/tests/extensions/openapi/test_decorators.py
+++ b/tests/extensions/openapi/test_decorators.py
@@ -453,6 +453,25 @@ def test_definition_decorator_body_dict_only_schema_head(app):
     }
 
 
+def test_definition_decorator_body_dict_types_schema_head(app):
+    @app.route("/")
+    @openapi.definition(body={"application/json": {"schema": {"name": str}}})
+    async def handler(_):
+        ...
+
+    body = get_path(app, "/")["requestBody"]
+    assert body == {
+        "content": {
+            "application/json": {
+                "schema": {
+                    "type": "object",
+                    "properties": {"name": {"type": "string"}},
+                }
+            }
+        }
+    }
+
+
 def test_definition_decorator_body_dict_only_schema_root(app):
     @app.route("/")
     @openapi.definition(
@@ -466,6 +485,37 @@ def test_definition_decorator_body_dict_only_schema_root(app):
     async def handler(_):
         ...
 
+    body = get_path(app, "/")["requestBody"]
+    assert body == {
+        "content": {
+            "application/json": {
+                "schema": {
+                    "type": "object",
+                    "properties": {"name": {"type": "string"}},
+                }
+            }
+        }
+    }
+
+
+def test_definition_decorator_body_dict_types_schema_root(app):
+    @app.route("/")
+    @openapi.definition(body={"application/json": {"name": str}})
+    async def handler(_):
+        ...
+
+    body = get_path(app, "/")["requestBody"]
+    assert body == {
+        "content": {
+            "application/json": {
+                "schema": {
+                    "type": "object",
+                    "properties": {"name": {"type": "string"}},
+                }
+            }
+        }
+    }
+
 
 def test_definition_decorator_httpmethodview(app):
     class View(HTTPMethodView, uri="/", attach=app):
diff --git a/tests/extensions/openapi/test_typing.py b/tests/extensions/openapi/test_typing.py
new file mode 100644
index 0000000..05c0c34
--- /dev/null
+++ b/tests/extensions/openapi/test_typing.py
@@ -0,0 +1,48 @@
+import sys
+from typing import List, Optional
+
+import pytest
+
+from sanic_ext.utils.typing import contains_annotations, flat_values
+
+
+class Foo:
+    ...
+
+
+def test_dict_values_nested():
+    values = flat_values(
+        {
+            "level1": {
+                "level2": {
+                    "level3": {
+                        "level4": {"item": 999, "items": [888, 777]},
+                        "item": 666,
+                    },
+                    "item": True,
+                },
+                "multiple": [
+                    {"nested": {"object": None}},
+                    {"nested": {"object": None}},
+                ],
+            },
+            "item": True,
+        }
+    )
+    assert values == {999, 888, 777, 666, None, True}
+
+
+params = [
+    ({"foo": str}, True),
+    ({"foo": List[str]}, True),
+    ({"foo": Optional[str]}, True),
+    ({"foo": Foo}, True),
+    ({"foo": "str"}, False),
+]
+if sys.version_info >= (3, 9):
+    params.append(({"foo": list[str]}, True))
+
+
+@pytest.mark.parametrize("item,expected", params)
+def test_contains_annotations(item, expected):
+    assert contains_annotations(item) == expected