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

Issue:
[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



Pull request description:
Better validation handling for optional types
Closes #105 

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
index a9afb07..4865a25 100644
--- a/sanic_ext/utils/typing.py
+++ b/sanic_ext/utils/typing.py
@@ -13,3 +13,10 @@ def is_generic(item):
         or isinstance(item, UnionType)
         or hasattr(item, "__origin__")
     )
+
+
+def is_optional(item):
+    if is_generic(item):
+        args = typing.get_args(item)
+        return len(args) == 2 and type(None) in args
+    return False


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: