Repository: sanic-org/sanic-ext (2022-08-10 ~ 2022-11-06)

problem_statement: [Bug] Dataclass optional not working in validate query
**Describe the bug**
I'm following this tutorial https://sanic.dev/en/plugins/sanic-ext/validation.html#implementation .
with define `dataclass` attribute with optional paramater,
 it will return 400 , if the query params `q` have value ex `q=123123` 
 without value it will work without problem
 
 I also found the same issue when using `pydantic`

**Screenshots**
<img width="1290" alt="Screen Shot 2022-07-05 at 16 21 59" src="https://user-images.githubusercontent.com/7445136/177295868-db8253c4-6fb5-4cc2-90aa-2d82ab465b2e.png">


**To Reproduce**
```
from dataclasses import dataclass, asdict
from typing import Optional

from sanic import Sanic
from sanic.response import json
from sanic_ext import validate


app = Sanic("Sanic-APP")


@dataclass
class SearchParams:
    q: Optional[str] = None


@app.route("/")
@validate(query=SearchParams)
async def handler(request, query: SearchParams):
    return json(asdict(query))

```

**Expected behavior**
when the dataclass attribute is `Optional`, it should not error when we send the query params with value ex `?q=123456`

**Environment (please complete the following information):**
 - OS: Mac Catalina v10.15.7 
 - Browser Chrome
 - Version 103.0.5060.53


date: 2022-08-10
patch: diff --git a/sanic_ext/extras/validation/check.py b/sanic_ext/extras/validation/check.py
index 79cf0f9..42dc7bc 100644
--- a/sanic_ext/extras/validation/check.py
+++ b/sanic_ext/extras/validation/check.py
@@ -1,7 +1,9 @@
 from __future__ import annotations
 
 from dataclasses import _HAS_DEFAULT_FACTORY  # type: ignore
-from typing import Any, Literal, NamedTuple, Optional, Tuple, Union
+from typing import Any, Literal, NamedTuple, Optional, Tuple, Union, get_args
+
+from sanic_ext.utils.typing import UnionType, is_generic, is_optional
 
 MISSING: Tuple[Any, ...] = (_HAS_DEFAULT_FACTORY,)
 
@@ -47,10 +49,11 @@ class Hint(NamedTuple):
                     allow_multiple=allow_multiple,
                     allow_coerce=allow_coerce,
                 )
+
             if (
                 allow_multiple
                 and isinstance(value, list)
-                and self.hint is not list
+                and self.coerce_type is not list
                 and len(value) == 1
             ):
                 value = value[0]
@@ -58,18 +61,22 @@ class Hint(NamedTuple):
                 _check_types(value, self.literal, self.hint)
             except ValueError as e:
                 if allow_coerce:
-                    if isinstance(value, list):
-                        value = [self.hint(item) for item in value]
-                    else:
-                        value = self.hint(value)
+                    value = self.coerce(value)
                     _check_types(value, self.literal, self.hint)
                 else:
                     raise e
         else:
-            _check_nullability(value, self.nullable, self.allowed, schema)
+            value = _check_nullability(
+                value,
+                self.nullable,
+                self.allowed,
+                schema,
+                allow_multiple,
+                allow_coerce,
+            )
 
             if not self.nullable:
-                if self.origin in (Union, Literal):
+                if self.origin in (Union, Literal, UnionType):
                     value = _check_inclusion(
                         value,
                         self.allowed,
@@ -96,8 +103,38 @@ class Hint(NamedTuple):
                         allow_coerce,
                     )
 
+            if allow_coerce:
+                value = self.coerce(value)
+
+        return value
+
+    def coerce(self, value):
+        if is_generic(self.coerce_type):
+            args = get_args(self.coerce_type)
+            if type(None) in args and value is None:
+                return None
+            coerce_types = [arg for arg in args if not isinstance(None, arg)]
+        else:
+            coerce_types = [self.coerce_type]
+        for coerce_type in coerce_types:
+            try:
+                if isinstance(value, list):
+                    value = [coerce_type(item) for item in value]
+                else:
+                    value = coerce_type(value)
+            except (ValueError, TypeError):
+                ...
+            else:
+                return value
         return value
 
+    @property
+    def coerce_type(self):
+        coerce_type = self.hint
+        if is_optional(coerce_type):
+            coerce_type = get_args(self.hint)[0]
+        return coerce_type
+
 
 def check_data(model, data, schema, allow_multiple=False, allow_coerce=False):
     if not isinstance(data, dict):
@@ -140,11 +177,34 @@ def _check_types(value, literal, expected):
             raise ValueError(f"Value '{value}' is not of type {expected}")
 
 
-def _check_nullability(value, nullable, allowed, schema):
+def _check_nullability(
+    value, nullable, allowed, schema, allow_multiple, allow_coerce
+):
     if not nullable and value is None:
         raise ValueError("Value cannot be None")
     if nullable and value is not None:
-        allowed[0].validate(value, schema)
+        exc = None
+        for hint in allowed:
+            try:
+                value = hint.validate(
+                    value, schema, allow_multiple, allow_coerce
+                )
+            except ValueError as e:
+                exc = e
+            else:
+                break
+        else:
+            if exc:
+                if len(allowed) == 1:
+                    raise exc
+                else:
+                    options = ", ".join(
+                        [str(option.hint) for option in allowed]
+                    )
+                    raise ValueError(
+                        f"Value '{value}' must be one of {options}, or None"
+                    )
+    return value
 
 
 def _check_inclusion(value, allowed, schema, allow_multiple, allow_coerce):
diff --git a/sanic_ext/extras/validation/decorator.py b/sanic_ext/extras/validation/decorator.py
index dad89a7..7e5ede8 100644
--- a/sanic_ext/extras/validation/decorator.py
+++ b/sanic_ext/extras/validation/decorator.py
@@ -26,7 +26,6 @@ def validate(
             ("query", query),
         )
     }
-    print(schemas)
 
     if json and form:
         raise InitError("Cannot define both a form and json route validator")
diff --git a/sanic_ext/extras/validation/schema.py b/sanic_ext/extras/validation/schema.py
index ff33e3c..0d5460e 100644
--- a/sanic_ext/extras/validation/schema.py
+++ b/sanic_ext/extras/validation/schema.py
@@ -94,10 +94,21 @@ def parse_hint(hint, field: Optional[Union[Field, Attribute]] = None):
         nullable = origin in (Union, UnionType) and type(None) in args
 
         if nullable:
-            allowed = (args[0],)
+            allowed = tuple(
+                [
+                    arg
+                    for arg in args
+                    if is_generic(arg) or not isinstance(None, arg)
+                ]
+            )
         elif origin is dict:
             allowed = (args[1],)
-        elif origin is list or origin is Literal or origin is Union:
+        elif (
+            origin is list
+            or origin is Literal
+            or origin is Union
+            or origin is UnionType
+        ):
             allowed = args
 
     return Hint(
diff --git a/sanic_ext/utils/typing.py b/sanic_ext/utils/typing.py
--- a/sanic_ext/utils/typing.py
+++ b/sanic_ext/utils/typing.py
@@ -29,6 +29,7 @@
     )


+def is_optional(item):
     if is_generic(item):
         args = typing.get_args(item)
         return len(args) == 2 and type(None) in args

test_patch: diff --git a/tests/extra/__models__.py b/tests/extra/__models__.py
index ec6f365..a9cb822 100644
--- a/tests/extra/__models__.py
+++ b/tests/extra/__models__.py
@@ -38,6 +38,26 @@ class ModelUnionModels:
     foo: Union[ModelInt, ModelFloat]
 
 
+@dataclass
+class ModelUnionStrInt:
+    foo: Union[str, int]
+
+
+@dataclass
+class ModelUnionIntStr:
+    foo: Union[int, str]
+
+
+@dataclass
+class ModelOptionalUnionStrInt:
+    foo: Optional[Union[str, int]]
+
+
+@dataclass
+class ModelOptionalUnionIntStr:
+    foo: Optional[Union[int, str]]
+
+
 @dataclass
 class ModelListStr:
     foo: List[str]
@@ -128,3 +148,11 @@ if sys.version_info > (3, 10):
     @dataclass
     class ModelUnionTypeStrNone:
         foo: str | None
+
+    @dataclass
+    class ModelUnionTypeStrIntNone:
+        foo: str | int | None
+
+    @dataclass
+    class ModelUnionTypeStrInt:
+        foo: str | int
diff --git a/tests/extra/test_validation_dataclass.py b/tests/extra/test_validation_dataclass.py
index 58ca8b6..cd7778f 100644
--- a/tests/extra/test_validation_dataclass.py
+++ b/tests/extra/test_validation_dataclass.py
@@ -122,6 +122,26 @@ def test_should_not_hydrate(data):
         (models.ModelUnionModels, False, {"foo": 1}),
         (models.ModelUnionModels, False, {"foo": 1.1}),
         (models.ModelUnionModels, False, {"foo": None}),
+        (models.ModelUnionStrInt, True, {"foo": "1"}),
+        (models.ModelUnionStrInt, True, {"foo": "1q"}),
+        (models.ModelUnionStrInt, True, {"foo": 1}),
+        (models.ModelUnionStrInt, False, {"foo": 1.1}),
+        (models.ModelUnionStrInt, False, {"foo": None}),
+        (models.ModelUnionIntStr, True, {"foo": "1"}),
+        (models.ModelUnionIntStr, True, {"foo": "1q"}),
+        (models.ModelUnionIntStr, True, {"foo": 1}),
+        (models.ModelUnionIntStr, False, {"foo": 1.1}),
+        (models.ModelUnionIntStr, False, {"foo": None}),
+        (models.ModelOptionalUnionStrInt, True, {"foo": "1"}),
+        (models.ModelOptionalUnionStrInt, True, {"foo": "1q"}),
+        (models.ModelOptionalUnionStrInt, True, {"foo": 1}),
+        (models.ModelOptionalUnionStrInt, False, {"foo": 1.1}),
+        (models.ModelOptionalUnionStrInt, True, {"foo": None}),
+        (models.ModelOptionalUnionIntStr, True, {"foo": "1"}),
+        (models.ModelOptionalUnionIntStr, True, {"foo": "1q"}),
+        (models.ModelOptionalUnionIntStr, True, {"foo": 1}),
+        (models.ModelOptionalUnionIntStr, False, {"foo": 1.1}),
+        (models.ModelOptionalUnionIntStr, True, {"foo": None}),
         (models.ModelListStr, True, {"foo": ["bar"]}),
         (models.ModelListStr, True, {"foo": ["one", "two"]}),
         (models.ModelListStr, False, {"foo": "bar"}),
@@ -235,7 +255,7 @@ def test_modeling(model, okay, data):
 @pytest.mark.skipif(
     sys.version_info < (3, 10), reason="UnionType added in 3.10"
 )
-def test_modeling_union_type():
+def test_modeling_union_type_ModelUnionTypeStrNone():
     schema = make_schema({}, models.ModelUnionTypeStrNone)
 
     check_data(models.ModelUnionTypeStrNone, {"foo": "bar"}, schema)
@@ -244,6 +264,37 @@ def test_modeling_union_type():
         check_data(models.ModelUnionTypeStrNone, {"foo": 0}, schema)
 
 
+@pytest.mark.skipif(
+    sys.version_info < (3, 10), reason="UnionType added in 3.10"
+)
+def test_modeling_union_type_ModelUnionTypeStrIntNone():
+    schema = make_schema({}, models.ModelUnionTypeStrIntNone)
+
+    check_data(models.ModelUnionTypeStrIntNone, {"foo": "1"}, schema)
+    check_data(models.ModelUnionTypeStrIntNone, {"foo": "bar"}, schema)
+    check_data(models.ModelUnionTypeStrIntNone, {"foo": None}, schema)
+    check_data(models.ModelUnionTypeStrIntNone, {"foo": 1}, schema)
+    check_data(models.ModelUnionTypeStrIntNone, {"foo": 0}, schema)
+    with pytest.raises(TypeError):
+        check_data(models.ModelUnionTypeStrIntNone, {"foo": 1.1}, schema)
+
+
+@pytest.mark.skipif(
+    sys.version_info < (3, 10), reason="UnionType added in 3.10"
+)
+def test_modeling_union_type_ModelUnionTypeStrInt():
+    schema = make_schema({}, models.ModelUnionTypeStrInt)
+
+    check_data(models.ModelUnionTypeStrInt, {"foo": "1"}, schema)
+    check_data(models.ModelUnionTypeStrInt, {"foo": "bar"}, schema)
+    check_data(models.ModelUnionTypeStrInt, {"foo": 1}, schema)
+    check_data(models.ModelUnionTypeStrInt, {"foo": 0}, schema)
+    with pytest.raises(TypeError):
+        check_data(models.ModelUnionTypeStrInt, {"foo": None}, schema)
+    with pytest.raises(TypeError):
+        check_data(models.ModelUnionTypeStrInt, {"foo": 1.1}, schema)
+
+
 def test_validate_decorator(app):
     @dataclass
     class Pet:


----

problem_statement: [Feature Request] Make pydantic models (or dicts) usable for openapi definition
**Is your feature request related to a problem? Please describe your use case.**
Pydantic models are not really usable for openapi definitions. It would be nice to use the models for validation and openapi.
The openapi decorator generates correct output for pydantic models in terms of properties and type, but nothing more. It would be nice to use pydantics `Field` for more details (example value, description, ...).
Pydantic is able to export a schema compatible to the openapi specs with `model.schema()`. This generates a dict, which means it would be enough to support dicts for the openapi definition.

**Describe the solution you'd like**
This should generate an openapi definition with an example field
```python
from sanic import Sanic, json
from sanic_ext import validate, openapi
from pydantic import BaseModel, Field

class Test(BaseModel):
    foo: str = Field(description="Foo Description", example="FOOO")
    bar: str = "test"


app = Sanic("test")

@app.get("/")
@openapi.definition(
    body={'application/json': Test.schema()},
)
@validate(json=Test)
async def get(request):
    return json({})
```

**Additional context**
-

date: 2022-09-22
patch: diff --git a/sanic_ext/extensions/openapi/definitions.py b/sanic_ext/extensions/openapi/definitions.py
index 69c28cd..878214a 100644
--- a/sanic_ext/extensions/openapi/definitions.py
+++ b/sanic_ext/extensions/openapi/definitions.py
@@ -84,6 +84,12 @@ class MediaType(Definition):
 
     @staticmethod
     def make(value: Any):
+        if isinstance(value, dict):
+            kwargs = {}
+            if "schema" in value:
+                kwargs = {**value}
+                value = kwargs.pop("schema")
+            return MediaType(value, **kwargs)
         return MediaType(Schema.make(value))
 
     @staticmethod

test_patch: diff --git a/tests/extensions/openapi/test_decorators.py b/tests/extensions/openapi/test_decorators.py
index 7651746..a7c3e7b 100644
--- a/tests/extensions/openapi/test_decorators.py
+++ b/tests/extensions/openapi/test_decorators.py
@@ -406,7 +406,7 @@ def test_tag_decorator(app, decorator, tags):
     assert list(tagged) == list(tags)
 
 
-def test_definition_decorator_body_dict(app):
+def test_definition_decorator_body_dict_w_obj(app):
     @app.route("/")
     @openapi.definition(body={"application/json": Bar})
     async def handler(_):
@@ -425,6 +425,48 @@ def test_definition_decorator_body_dict(app):
     }
 
 
+def test_definition_decorator_body_dict_only_schema_head(app):
+    @app.route("/")
+    @openapi.definition(
+        body={
+            "application/json": {
+                "schema": {
+                    "type": "object",
+                    "properties": {"name": {"type": "string"}},
+                }
+            }
+        }
+    )
+    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(
+        body={
+            "application/json": {
+                "type": "object",
+                "properties": {"name": {"type": "string"}},
+            }
+        }
+    )
+    async def handler(_):
+        ...
+
+
 def test_definition_decorator_httpmethodview(app):
     class View(HTTPMethodView, uri="/", attach=app):
         @openapi.definition(body={"application/json": Bar})


----

problem_statement: [Bug] Dependency injection not working for HEAD requests
**Describe the bug**
Sanic automatically creates `HEAD` endpoints, but dependencies are not injected into these endpoints, causing 500 errors when users HEAD these endpoints.

**Screenshots**
![image](https://user-images.githubusercontent.com/6646473/202834223-30bdbf41-1ae9-4a8e-91a2-091ae194ac56.png)


**To Reproduce**
```python
from sanic import Sanic
from sanic.response import text

app = Sanic('test')

class Foo:
    pass
    
app.ext.add_dependency(Foo, lambda *_: None)

@app.route('/download')
async def handler(req, foo: Foo):
    return text('file')
    
if __name__ == '__main__':
    app.run(port=4321, debug=True)
```

And then `curl -I http://127.0.0.1:4321/download`.

It shows an HTTP 500 response. Sanic reports the following error:

`TypeError: handler() missing 1 required positional argument: 'foo'`

**Expected behavior**

It should receive an HTTP 200 response.

**Environment (please complete the following information):**
 - OS: Windows-10-10.0.19045-SP0
 - Browser: curl 7.83.1
 - Version: Sanic 22.9.0, sanic-ext 22.9.0, sanic-routing 22.8.0

**Additional context**
Many downloaders send a HEAD request before actually downloading. This bug breaks these downloaders.

date: 2022-11-06
patch: diff --git a/sanic_ext/extensions/http/methods.py b/sanic_ext/extensions/http/methods.py
index 7c9700e..756411d 100644
--- a/sanic_ext/extensions/http/methods.py
+++ b/sanic_ext/extensions/http/methods.py
@@ -84,6 +84,7 @@ def add_auto_handlers(
                                 description="Retrieve HEAD details",
                             )(partial(head_handler, get_handler=route.handler))
                             handler.__auto_handler__ = True
+                            handler.__route_handler__ = route.handler
                             app.add_route(
                                 handler=handler,
                                 uri=group.uri,
diff --git a/sanic_ext/extensions/injection/injector.py b/sanic_ext/extensions/injection/injector.py
index 66c6d16..a375671 100644
--- a/sanic_ext/extensions/injection/injector.py
+++ b/sanic_ext/extensions/injection/injector.py
@@ -79,8 +79,10 @@ def _setup_signature_registry(
                     )
                 ]
             for name, handler in handlers:
-                if hasattr(handler, "__auto_handler__"):
-                    continue
+                if route_handler := getattr(
+                    handler, "__route_handler__", None
+                ):
+                    handler = route_handler
                 if isinstance(handler, partial):
                     if handler.func == app._websocket_handler:
                         handler = handler.args[0]

test_patch: diff --git a/tests/extensions/injection/test_dependency.py b/tests/extensions/injection/test_dependency.py
index b0b08dd..3f064ab 100644
--- a/tests/extensions/injection/test_dependency.py
+++ b/tests/extensions/injection/test_dependency.py
@@ -29,3 +29,17 @@ def test_dependency_injection(app):
     _, response = app.test_client.get("/getfoo")
 
     assert response.text == "foobar"
+
+
+def test_dependency_injection_head(app):
+    foo = Foo()
+
+    app.ext.dependency(foo)
+
+    @app.get("/getfoo")
+    async def getfoo(request: Request, foo: Foo):
+        return text(foo.bar())
+
+    _, response = app.test_client.head("/getfoo")
+
+    assert int(response.headers.get("content-length", 0)) == 6