chore: rename project coven → hack-house ⛧

Rebrand the Rust client crate (coven/ → hh/, package+binary "hack-house"),
README, CLI strings, and branch (coven → hack-house). Gitea repo renamed
cmd-chat → hack-house to match. Crypto/server logic unchanged; selftest +
golden-vector test still green, binary is now `hack-house`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-05-30 13:29:14 -07:00
parent 82a04f3e12
commit bb1d662ee1
2730 changed files with 712933 additions and 46 deletions
@@ -0,0 +1,59 @@
import re
import pytest
from sanic import Sanic
from sanic_ext import Extend, Extension
from sanic_ext.extensions.http.extension import HTTPExtension
from sanic_ext.extensions.injection.extension import InjectionExtension
from sanic_ext.extensions.openapi.builders import (
OperationStore,
SpecificationBuilder,
)
from sanic_ext.extensions.openapi.extension import OpenAPIExtension
slugify = re.compile(r"[^a-zA-Z0-9_\-]")
@pytest.fixture(autouse=True)
def reset_globals():
yield
SpecificationBuilder.reset()
OperationStore.reset()
Extend.reset()
Extension.reset()
Sanic._app_registry.clear()
@pytest.fixture(autouse=True)
def reset_extensions():
yield
for ext in (HTTPExtension, InjectionExtension, OpenAPIExtension):
ext._singleton = None
@pytest.fixture
def bare_app(request):
app = Sanic(slugify.sub("-", request.node.name))
yield app
@pytest.fixture
def app(bare_app):
Extend(bare_app)
yield bare_app
@pytest.fixture
def get_docs(app):
def fetch():
nonlocal app
_, response = app.test_client.get("/docs/openapi.json")
return response.json
return fetch
@@ -0,0 +1,129 @@
import pytest
from sanic import Sanic
from sanic.response import empty, text
from sanic_ext.bootstrap import Extend
def test_trace_and_connect_available(app: Sanic):
@app.route("/", methods=["trace", "connect"])
async def handler(_):
return empty()
_, response = app.test_client.request("", http_method="trace")
assert response.status == 204
_, response = app.test_client.request("", http_method="connect")
assert response.status == 204
_, response = app.test_client.request("", http_method="get")
assert response.status == 405
def test_auto_head(app: Sanic, get_docs):
app.config.TOUCHUP = False
@app.get("/foo")
async def foo_handler(_):
return text("...")
assert app.config.HTTP_AUTO_HEAD
_, response = app.test_client.head("/foo")
assert response.status == 200
assert len(response.body) == 0
assert int(response.headers["content-length"]) == 3
schema = get_docs()
assert "get" in schema["paths"]["/foo"]
def test_auto_options(app: Sanic, get_docs):
@app.post("/foo")
async def foo_handler(_):
return text("...")
_, response = app.test_client.options("/foo")
assert response.status == 204
assert len(response.body) == 0
assert "POST" in response.headers["allow"]
assert "OPTIONS" in response.headers["allow"]
schema = get_docs()
assert "post" in schema["paths"]["/foo"]
def test_auto_trace(bare_app: Sanic):
Extend(bare_app, config={"http_auto_trace": True})
@bare_app.get("/foo")
async def foo_handler(_):
return text("...")
request, response = bare_app.test_client.request(
"/foo", http_method="trace"
)
assert response.status == 200
assert response.body.startswith(request.head)
def test_auto_head_with_vhosts(app: Sanic, get_docs):
@app.get("/foo", host="one.com", name="one")
async def foo_handler_one(_):
return text(".")
@app.get("/foo", host="two.com", name="two")
async def foo_handler_two(_):
return text("..")
assert app.config.HTTP_AUTO_HEAD
_, response = app.test_client.head("/foo", headers={"host": "one.com"})
assert response.status == 200
assert len(response.body) == 0
assert int(response.headers["content-length"]) == 1
_, response = app.test_client.head("/foo", headers={"host": "two.com"})
assert response.status == 200
assert len(response.body) == 0
assert int(response.headers["content-length"]) == 2
schema = get_docs()
assert "get" in schema["paths"]["/foo"]
def test_auto_options_with_vhosts(app: Sanic, get_docs):
@app.post("/foo", host="one.com", name="one")
async def foo_handler_one(_):
return text(".")
@app.post("/foo", host="two.com", name="two")
async def foo_handler_two(_):
return text("..")
assert app.config.HTTP_AUTO_OPTIONS
_, response = app.test_client.options("/foo", headers={"host": "one.com"})
assert response.status == 204
assert len(response.body) == 0
assert "POST" in response.headers["allow"]
assert "OPTIONS" in response.headers["allow"]
_, response = app.test_client.options("/foo", headers={"host": "two.com"})
assert response.status == 204
assert len(response.body) == 0
assert "POST" in response.headers["allow"]
assert "OPTIONS" in response.headers["allow"]
# This test also appears in Core Sanic tests but is added here as well
# because of: https://github.com/sanic-org/sanic-ext/issues/148
@pytest.mark.parametrize("unquote", [True, False, None])
def test_unquote_add_route(app, unquote):
async def handler1(_, foo):
return text(foo)
app.add_route(handler1, "/<foo>", unquote=unquote)
value = "" if unquote else r"%E5%95%8A"
_, response = app.test_client.get("/啊")
assert response.text == value
_, response = app.test_client.get(r"/%E5%95%8A")
assert response.text == value
@@ -0,0 +1,416 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from itertools import count
from typing import Optional
from uuid import UUID
import pytest
from sanic import Request, json, text
from sanic.exceptions import SanicException
from sanic.views import HTTPMethodView
from sanic_ext import Extend
@dataclass
class Name:
name: str
@dataclass
class PersonID:
person_id: int
@dataclass
class NamedPerson:
name: str
@dataclass
class Person:
person_id: PersonID
name: str
age: int
@classmethod
async def create(cls, person_id: int) -> Person:
return cls(person_id=PersonID(person_id), name="noname", age=111)
@dataclass
class AsyncName:
name: str
def __await__(self):
return self
counter = count()
class A:
@classmethod
def create(cls, request: Request):
next(counter)
return cls()
class B:
def __init__(self, a: A):
self.a = a
@classmethod
def create(cls, request: Request, a: A):
next(counter)
return cls(a)
class C:
def __init__(self, b: B):
self.b = b
@classmethod
def create(cls, request: Request, b: B):
next(counter)
return cls(b)
class D:
def __init__(self, a: A):
self.a = a
@classmethod
def create(cls, request: Request, a: A):
next(counter)
return cls(a)
class E:
def __init__(self, c: C, d: D):
self.c = c
self.d = d
@classmethod
def create(cls, request: Request, c: C, d: D):
next(counter)
return cls(c, d)
class Alpha: ...
class Beta:
def __init__(self, alpha: Alpha) -> None:
self.alpha = alpha
class Gamma:
def __init__(self, beta: Beta, request: Optional[Request] = None) -> None:
self.beta = beta
self.request = request
class Delta:
def __init__(self, gamma: Gamma, request: Request) -> None:
self.gamma = gamma
self.request = request
@dataclass
class Foo:
ident: UUID
def make_gamma_with_request(beta: Beta, request: Request):
return Gamma(beta, request)
def make_gamma_without_request(beta: Beta):
return Gamma(beta)
def test_injection_not_allowed_when_ext_disabled(bare_app):
ext = Extend(bare_app, built_in_extensions=False)
with pytest.raises(
SanicException, match="Injection extension not enabled"
):
ext.add_dependency(1, 2)
def test_injection_of_matched_object(app):
@app.get("/person/<name:str>")
def handler(request, name: Name):
request.ctx.name = name
return text(name.name)
app.ext.add_dependency(Name)
request, response = app.test_client.get("/person/george")
assert response.body == b"george"
assert isinstance(request.ctx.name, Name)
assert request.ctx.name.name == "george"
def test_injection_of_matched_object_as_deprecated_injection(app):
@app.get("/person/<name:str>")
def handler(request, name: Name):
request.ctx.name = name
return text(name.name)
message = (
"The 'ext.injection' method has been deprecated and will be removed "
"in v22.6. Please use 'ext.add_dependency' instead."
)
with pytest.warns(DeprecationWarning, match=message):
app.ext.injection(Name)
request, response = app.test_client.get("/person/george")
assert response.body == b"george"
assert isinstance(request.ctx.name, Name)
assert request.ctx.name.name == "george"
def test_injection_of_simple_object(app):
@app.get("/person/<name>")
def handler(request, name: str, person: NamedPerson):
request.ctx.person = person
return text(person.name)
app.ext.add_dependency(NamedPerson)
request, response = app.test_client.get("/person/george")
assert response.body == b"george"
assert isinstance(request.ctx.person, NamedPerson)
assert request.ctx.person.name == "george"
def test_injection_of_object_with_constructor(app):
@app.get("/person/<person_id:int>")
async def person_details(request, person_id: PersonID, person: Person):
request.ctx.person_id = person_id
request.ctx.person = person
return text(
f"{person.person_id.person_id}\n{person.name}\n{person.age}"
)
app.ext.add_dependency(Person, Person.create)
app.ext.add_dependency(PersonID)
request, response = app.test_client.get("/person/999")
assert response.body == b"999\nnoname\n111"
assert isinstance(request.ctx.person_id, PersonID)
assert isinstance(request.ctx.person, Person)
assert request.ctx.person.person_id == request.ctx.person_id
assert request.ctx.person.person_id.person_id == 999
assert request.ctx.person.name == "noname"
assert request.ctx.person.age == 111
def test_injection_on_cbv(app):
class View(HTTPMethodView, attach=app, uri="/person/<name:str>"):
async def get(self, request, name: Name):
request.ctx.name = name
return text(name.name)
@staticmethod
async def post(request, name: Name):
request.ctx.name = name
return text(name.name)
app.ext.add_dependency(Name)
for client in (app.test_client.get, app.test_client.post):
request, response = client("/person/george")
assert response.body == b"george"
assert isinstance(request.ctx.name, Name)
assert request.ctx.name.name == "george"
def test_nested_dependencies(app):
app.ext.add_dependency(A, A.create)
app.ext.add_dependency(B, B.create)
app.ext.add_dependency(C, C.create)
app.ext.add_dependency(D, D.create)
app.ext.add_dependency(E, E.create)
@app.get("/")
async def nested(request: Request, c: C, e: E):
return json(
[
isinstance(c, C),
isinstance(c.b, B),
isinstance(c.b.a, A),
isinstance(e, E),
isinstance(e.c, C),
isinstance(e.d, D),
isinstance(e.d.a, A),
]
)
_, response = app.test_client.get("/")
assert all(response.json)
# TODO:
# - After implementing https://github.com/sanic-org/sanic-ext/issues/77
# this should be == 5
assert next(counter) == 9
def test_injection_on_websocket(app):
ev = asyncio.Event()
app.ext.dependency(A())
@app.websocket("/foo")
async def handler(request, ws, foo: A):
assert isinstance(foo, A)
if isinstance(foo, A):
ev.set()
request, response = app.test_client.websocket("/foo")
assert ev.is_set()
def test_injection_of_awaitable_variable_in_do_cast(app):
"""Test for do_cast() iscoroutine() check"""
@app.get("/person/<name:str>")
def handler(request, name: AsyncName):
request.ctx.name = name
return text(name.name)
app.ext.add_dependency(AsyncName)
request, response = app.test_client.get("/person/george")
assert response.body == b"george"
assert isinstance(request.ctx.name, AsyncName)
assert request.ctx.name.name == "george"
def test_injection_of_awaitable_variable_in_call(app):
"""Test for __call__() iscoroutine() check"""
@app.get("/person/<name:str>")
def handler(request, name: AsyncName):
request.ctx.name = name
return text(name.name)
def test():
return AsyncName("george")
app.ext.dependency(test())
request, response = app.test_client.get("/person/george")
assert response.body == b"george"
assert isinstance(request.ctx.name, AsyncName)
assert request.ctx.name.name == "george"
def test_injection_class_constructors(app):
app.ext.add_dependency(Alpha)
app.ext.add_dependency(Beta)
@app.get("/")
def handler(request: Request, beta: Beta):
return json({"is_beta": isinstance(beta, Beta)})
_, response = app.test_client.get("/")
assert response.json == {"is_beta": True}
def test_injection_class_constructors_with_optional_request(app):
app.ext.add_dependency(Alpha)
app.ext.add_dependency(Beta)
app.ext.add_dependency(Gamma)
@app.get("/")
def handler(request: Request, gamma: Gamma):
return json(
{
"is_gamma": isinstance(gamma, Gamma),
"has_request": bool(gamma.request),
}
)
_, response = app.test_client.get("/")
assert response.json == {"is_gamma": True, "has_request": True}
def test_injection_class_constructors_with_request(app):
app.ext.add_dependency(Alpha)
app.ext.add_dependency(Beta)
app.ext.add_dependency(Gamma)
app.ext.add_dependency(Delta)
@app.get("/")
def handler(request: Request, delta: Delta):
return json(
{
"is_delta": isinstance(delta, Delta),
"has_request": bool(delta.request),
}
)
_, response = app.test_client.get("/")
assert response.json == {"is_delta": True, "has_request": True}
def test_injection_class_constructors_with_func_and_request(app):
app.ext.add_dependency(Alpha)
app.ext.add_dependency(Beta)
app.ext.add_dependency(Gamma, make_gamma_with_request)
@app.get("/")
def handler(request: Request, gamma: Gamma):
return json(
{
"is_gamma": isinstance(gamma, Gamma),
"has_request": bool(gamma.request),
}
)
_, response = app.test_client.get("/")
assert response.json == {"is_gamma": True, "has_request": True}
def test_injection_class_constructors_with_func_and_no_request(app):
app.ext.add_dependency(Alpha)
app.ext.add_dependency(Beta)
app.ext.add_dependency(Gamma, make_gamma_without_request)
@app.get("/")
def handler(request: Request, gamma: Gamma):
return json(
{
"is_gamma": isinstance(gamma, Gamma),
"has_request": bool(gamma.request),
}
)
_, response = app.test_client.get("/")
assert response.json == {"is_gamma": True, "has_request": False}
def test_injection_of_lambda_properties(app):
@app.get("/foo")
def handler(request, foo: Foo):
return json(request.id == foo.ident and isinstance(foo.ident, UUID))
app.ext.add_dependency(Foo, lambda request: Foo(request.id), "request")
_, response = app.test_client.get("/foo")
assert response.body == b"true"
@@ -0,0 +1,69 @@
from dataclasses import dataclass
import pytest
from sanic import Sanic, json
@dataclass
class Foo:
bar: int
def test_constant_in_registry(app: Sanic):
assert len(app.ext._constant_registry._registry) == 0
app.ext.add_constant("bar", 999)
assert len(app.ext._constant_registry._registry) == 1
assert "bar" in app.ext._constant_registry
def test_constant_is_injected(app: Sanic):
app.ext.add_constant("bar", 999)
@app.get("/")
async def handler(_, bar: int):
return json({"bar": bar})
_, response = app.test_client.get("/")
assert response.json["bar"] == 999
assert app.config.BAR == 999
def test_constant_is_injected_into_constructor(app: Sanic):
app.ext.add_constant("bar", 999)
app.ext.add_dependency(Foo)
@app.get("/")
async def handler(_, foo: Foo):
return json({"foo": foo.bar})
_, response = app.test_client.get("/")
assert response.json["foo"] == 999
assert app.config.BAR == 999
def test_load_config(app: Sanic):
app.ext.load_constants()
assert len(app.ext._constant_registry._registry) == 0
app.ext.load_constants({**app.config}, overwrite=True)
assert len(app.ext._constant_registry._registry) == len(
[k for k in app.config.keys() if k.isupper()]
)
assert "request_buffer_size" in app.ext._constant_registry
def test_load_custom(app: Sanic):
app.ext.load_constants({"foo": "bar"})
assert len(app.ext._constant_registry._registry) == 1
assert app.ext._constant_registry.get("foo") == "bar"
def test_load_overwrite(app: Sanic):
app.ext.load_constants({"foo": "bar"})
with pytest.raises(
ValueError, match="A value for FOO has already been assigned"
):
app.ext.load_constants({"foo": "bar"})
app.ext.load_constants({"foo": "bar"}, True)
@@ -0,0 +1,45 @@
from sanic import Request, text
class Foo:
def bar(self):
return "foobar"
def test_dependency_added(app):
foo = Foo()
foobar = Foo()
app.ext.dependency(foo)
app.ext.dependency(foobar, name="something")
assert app.ctx._dependencies.foo is foo
assert app.ctx._dependencies.something is foobar
def test_dependency_injection(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.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
@@ -0,0 +1,42 @@
from unittest.mock import Mock
import pytest
from sanic.exceptions import SanicException
from sanic.signals import Event
from sanic_ext.config import Config
from sanic_ext.extensions.injection.injector import add_injection
def test_default_config_signal():
config = Config()
assert config.INJECTION_SIGNAL == Event.HTTP_ROUTING_AFTER
def test_not_allowed_signals_error():
with pytest.raises(SanicException):
Config(injection_signal=Event.HTTP_LIFECYCLE_REQUEST)
def test_http_routing_after_succeeds():
Config(injection_signal=Event.HTTP_ROUTING_AFTER)
@pytest.mark.skipif(
not hasattr(Event, "HTTP_HANDLER_BEFORE"),
reason="Sanic version does not support HTTP_HANDLER_BEFORE",
)
def test_http_handler_before_succeeds():
Config(injection_signal=Event.HTTP_HANDLER_BEFORE)
def test_add_injection_uses_signal_config():
app = Mock()
app.signal = Mock(return_value=Mock())
app.ext.config.INJECTION_SIGNAL = "random_string"
app.ext.config.INJECTION_PRIORITY = 99999
add_injection(app, Mock(), Mock())
app.signal.assert_called_once_with("random_string", priority=99999)
@@ -0,0 +1,185 @@
from __future__ import annotations
from unittest.mock import AsyncMock, Mock, call
import pytest
from sanic import Request
from sanic.exceptions import ServerError
from sanic_ext.exceptions import InitError
from sanic_ext.extensions.injection.constructor import Constructor, gather_args
from sanic_ext.extensions.injection.registry import (
ConstantRegistry,
InjectionRegistry,
)
class Foo:
def __init__(self, num: int):
self.num = num
self.mock = AsyncMock()
@classmethod
async def create(cls, request: Request, bar: int):
instance = cls(bar)
await instance.mock(request, bar)
return instance
class Bar:
def __init__(self, foo: Foo):
self.foo = foo
@classmethod
async def create(cls, request: Request, foo: Foo):
return cls(foo)
class Chicken:
@classmethod
def create(cls, egg: Egg):
return cls()
class Egg:
@classmethod
def create(cls, chicken: Chicken):
return cls()
class InheritedRequest(Request): ...
class Baz:
@classmethod
async def create(cls, request: InheritedRequest):
return cls()
@pytest.mark.asyncio
async def test_gather_args():
func = AsyncMock()
func.return_value = 999
injections = {"some_foo": (Foo, func)}
request = object()
kwargs = {"zero": 0, "one": True, "two": "2", "three": None}
args = await gather_args(injections, request, **kwargs)
assert args == {"some_foo": 999}
assert func.call_args == call(request, **kwargs)
@pytest.mark.asyncio
async def test_circular_refs():
injections = InjectionRegistry()
injections.register(Chicken, Chicken.create)
injections.register(Egg, Egg.create)
assert isinstance(injections[Chicken], Constructor)
assert isinstance(injections[Egg], Constructor)
message = (
"Circular dependency injection detected on 'create'. Check "
"dependencies of 'create' which may contain circular dependency "
f"chain with {Chicken}."
)
with pytest.raises(InitError, match=message):
injections.finalize(Mock(), ConstantRegistry({}), [])
@pytest.mark.asyncio
@pytest.mark.parametrize("raises,allowed", ((False, (int,)), (True, [])))
async def test_finalize_allowed_types(raises, allowed):
injections = InjectionRegistry()
injections.register(Foo, Foo.create)
if raises:
with pytest.raises(
InitError, match=".Could not find the following dependencies."
):
injections.finalize(Mock(), ConstantRegistry({}), allowed)
else:
injections.finalize(Mock(), ConstantRegistry({}), allowed)
@pytest.mark.asyncio
async def test_constructor():
injections = InjectionRegistry()
injections.register(Foo, Foo.create)
injections.finalize(Mock(), ConstantRegistry({}), {int})
request = object()
constructor = injections[Foo]
foo = await constructor(request, bar=999)
assert isinstance(foo, Foo)
assert foo.num == 999
assert constructor.pass_kwargs
foo.mock.assert_awaited_once_with(request, 999)
@pytest.mark.asyncio
async def test_constructor_nested():
injections = InjectionRegistry()
injections.register(Foo, Foo.create)
injections.register(Bar, Bar.create)
injections.finalize(Mock(), ConstantRegistry({}), {int})
request = object()
constructor_bar = injections[Bar]
bar = await constructor_bar(request, bar=999)
assert isinstance(bar, Bar)
assert isinstance(bar.foo, Foo)
assert bar.foo.num == 999
assert not constructor_bar.pass_kwargs
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)
injections.finalize(Mock(), ConstantRegistry({}), {int})
request = object()
constructor = injections[Foo]
message = (
"Failure to inject dependencies. Make sure that all dependencies "
"for 'create' have been registered."
)
with pytest.raises(ServerError, match=message):
await constructor(request)
@pytest.mark.asyncio
async def test_constructor_failure_nested():
injections = InjectionRegistry()
injections.register(Foo, Foo.create)
injections.register(Bar, Bar.create)
injections.finalize(Mock(), ConstantRegistry({}), {int})
request = object()
constructor: Bar = injections[Bar]
message = (
"Failure to inject dependencies. Make sure that all dependencies "
"for 'create' have been registered."
)
with pytest.raises(ServerError, match=message):
await constructor(request)
@pytest.mark.asyncio
async def test_constructor_with_inherited_request():
injections = InjectionRegistry()
injections.register(Baz, Baz.create)
injections.finalize(Mock(), ConstantRegistry({}), [])
request = object()
constructor_baz = injections[Baz]
baz = await constructor_baz(request)
assert isinstance(baz, Baz)
assert not constructor_baz.pass_kwargs
@@ -0,0 +1,20 @@
from sanic import Sanic
from sanic_ext.extensions.logging.logger import Logger
def test_defult_config(app: Sanic):
assert hasattr(app.config, "LOGGERS")
def test_custom_background_logger(app: Sanic):
assert Logger.LOGGERS == []
Logger.prepare(app)
assert hasattr(app.shared_ctx, "logger_queue")
assert Logger.LOGGERS == app.config.LOGGERS
assert "sanic.root" in Logger.LOGGERS
logger = Logger()
assert (
len(logger.loggers) == len(Logger.LOGGERS) == len(app.config.LOGGERS)
)
@@ -0,0 +1,56 @@
from sanic_ext.extensions.openapi.autodoc import YamlStyleParametersParser
tests = []
_ = ""
tests.append({"doc": _, "expects": {}})
_ = "one line docstring"
tests.append({"doc": _, "expects": {"summary": "one line docstring"}})
_ = """
first line
more lines
"""
tests.append(
{
"doc": _,
"expects": {"summary": "first line", "description": "more lines"},
}
)
_ = """
first line
more lines
openapi:
---
responses:
'200':
description: OK
"""
tests.append(
{
"doc": _,
"expects": {
"summary": "first line",
"description": "more lines",
"responses": {"200": {"description": "OK"}},
},
}
)
def test_autodoc():
for t in tests:
parser = YamlStyleParametersParser(t["doc"])
assert parser.to_openAPI_2() == t["expects"]
assert parser.to_openAPI_3() == t["expects"]
@@ -0,0 +1,83 @@
from dataclasses import dataclass
from sanic import Request, Sanic
from sanic.response import text
from sanic_ext import openapi
from sanic_ext.extensions.openapi.definitions import RequestBody
from .utils import get_spec
@dataclass
class UserProfile:
name: str
age: int
email: str
def test_body(app: Sanic):
@app.route("/test0")
async def handler0(request: Request):
"""
openapi:
---
summary: Updates a pet in the store with form data
description: some description
operationId: someId
requestBody:
description: user to add to the system
required: true
content:
application/json:
schema:
type: object
required:
- name
- age
- email
properties:
name:
type: string
email:
type: string
age:
type: integer
format: int32
minimum: 0
"""
return text("ok")
@app.route("/test1")
@openapi.body({"application/json": UserProfile})
async def handler1(request: Request):
return text("ok")
@app.route("/test2")
@openapi.body(RequestBody({"application/json": UserProfile}))
async def handler2(request: Request):
return text("ok")
@app.route("/test3")
@openapi.body(RequestBody(UserProfile))
async def handler3(request: Request):
return text("ok")
@app.route("/test4")
@openapi.body(UserProfile)
async def handler4(request: Request):
return text("ok")
spec = get_spec(app)
for i in range(5):
assert f"/test{i}" in spec["paths"]
content = spec["paths"][f"/test{i}"]["get"]["requestBody"]["content"]
media_type = next(iter(content.keys()))
if i <= 2:
assert media_type == "application/json"
else:
assert media_type == "*/*"
properties = content[media_type]["schema"]["properties"]
assert properties["name"]["type"] == "string"
assert properties["age"]["type"] == "integer"
assert properties["email"]["type"] == "string"
@@ -0,0 +1,34 @@
import pytest
from .utils import get_spec
@pytest.mark.parametrize(
"schemes,expected",
(
(None, ("http://example.com/",)),
("https", ("https://example.com/",)),
(
"http,https",
(
"http://example.com/",
"https://example.com/",
),
),
(
["http", "https"],
(
"http://example.com/",
"https://example.com/",
),
),
),
)
def test_config_scheme(app, schemes, expected):
app.config.API_SCHEMES = schemes
app.config.API_HOST = "example.com"
spec = get_spec(app)
urls = {s["url"] for s in spec["servers"]}
assert urls == set(expected)
@@ -0,0 +1,890 @@
from datetime import date, datetime
from enum import Enum
from typing import Any, Optional, Union
from uuid import UUID
import pytest
from sanic.exceptions import SanicException
from sanic.views import HTTPMethodView
from sanic_ext import openapi
from .utils import get_path, get_spec
class Choice(Enum):
ONE = 1
TWO = 2
class Bar:
name: str
class LittleFoo:
iid: int
bar: Bar
class BigFoo:
iid: int
uid: UUID
created: date
updated: datetime
multi: Union[str, int]
nullable_single: Optional[bool]
nullable_multi: Optional[Union[str, int]]
bar: Bar
adict: dict[str, Any]
bdict: dict[str, bool]
anything: Any
choice: Choice
@pytest.mark.parametrize(
"args,content_type",
(
((LittleFoo,), "*/*"),
(({"application/json": LittleFoo},), "application/json"),
((openapi.definitions.RequestBody(LittleFoo),), "*/*"),
(
(
openapi.definitions.RequestBody(
{"application/json": LittleFoo}
),
),
"application/json",
),
),
)
def test_body_decorator(app, args, content_type):
@app.route("/")
@openapi.body(*args, description="something")
async def handler(_): ...
spec = get_path(app, "/")
assert spec["requestBody"]["description"] == "something"
content = spec["requestBody"]["content"]
assert content_type in content
schema = content[content_type]["schema"]
assert schema["type"] == "object"
assert schema["properties"]["bar"]["type"] == "object"
assert (
schema["properties"]["bar"]["properties"]["name"]["type"] == "string"
)
@pytest.mark.parametrize(
"decorator", (openapi.deprecated(), openapi.deprecated)
)
def test_deprecated_decorator(app, decorator):
@app.route("/")
@decorator
async def handler(_): ...
spec = get_path(app, "/")
assert spec["deprecated"]
def test_description_decorator(app):
@app.route("/")
@openapi.description("foo")
async def handler(_): ...
spec = get_path(app, "/")
assert spec["description"] == "foo"
@pytest.mark.parametrize(
"args",
(
("http://example.com/docs",),
(
openapi.definitions.ExternalDocumentation(
"http://example.com/docs"
),
),
),
)
def test_document_decorator(app, args):
@app.route("/")
@openapi.document(*args)
async def handler(_): ...
spec = get_path(app, "/")
assert spec["externalDocs"]["url"] == "http://example.com/docs"
@pytest.mark.parametrize(
"decorator,excluded",
(
(openapi.exclude(), True),
(openapi.exclude(True), True),
(openapi.exclude(False), False),
),
)
def test_exclude_decorator(app, decorator, excluded):
@app.route("/")
@decorator
async def handler(_): ...
if excluded:
with pytest.raises(KeyError):
get_path(app, "/")
else:
get_path(app, "/")
def test_operation_decorator(app):
@app.route("/")
@openapi.operation("foo")
async def handler(_): ...
spec = get_path(app, "/")
assert spec["operationId"] == "foo"
@pytest.mark.parametrize(
"decorator,expected",
(
(
openapi.parameter("thing"),
{
"name": "thing",
"schema": {"type": "string"},
"in": "query",
},
),
(
openapi.parameter("Authorization", str, "header"),
{
"name": "Authorization",
"schema": {"type": "string"},
"in": "header",
},
),
(
openapi.parameter(
parameter=openapi.definitions.Parameter(
"foobar", deprecated=True
)
),
{
"name": "foobar",
"schema": {"type": "string"},
"deprecated": True,
"in": "query",
},
),
(
openapi.parameter("thing", required=True),
{
"name": "thing",
"schema": {"type": "string"},
"required": True,
"in": "query",
},
),
),
)
def test_parameter_decorator(app, decorator, expected):
@app.route("/")
@decorator
def handler_one(_): ...
parameters = get_path(app, "/")["parameters"]
assert parameters[0] == expected
@pytest.mark.parametrize(
"decorator,expected",
(
(
openapi.response(),
{
"default": {
"content": {"*/*": {"schema": {"type": "string"}}},
"description": "Default Response",
}
},
),
(
openapi.response(200, str, "foobar"),
{
"200": {
"content": {"*/*": {"schema": {"type": "string"}}},
"description": "foobar",
}
},
),
(
openapi.response(200, Bar, "..."),
{
"200": {
"content": {
"*/*": {
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
}
}
},
"description": "...",
}
},
),
(
openapi.response(
content={"application/json": Bar}, description="..."
),
{
"default": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
}
}
},
"description": "...",
}
},
),
(
openapi.response(
response=openapi.definitions.Response(
{"application/json": Bar}, description="...", status=201
)
),
{
"201": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
}
}
},
"description": "...",
}
},
),
(
openapi.response(content={"application/json": BigFoo}),
{
"default": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"iid": {
"type": "integer",
"format": "int32",
},
"uid": {
"type": "string",
"format": "uuid",
},
"created": {
"type": "string",
"format": "date",
},
"updated": {
"type": "string",
"format": "date-time",
},
"multi": {
"oneOf": [
{"type": "string"},
{
"type": "integer",
"format": "int32",
},
]
},
"nullable_single": {
"type": "boolean",
"nullable": True,
},
"nullable_multi": {
"nullable": True,
"oneOf": [
{
"type": "string",
},
{
"type": "integer",
"format": "int32",
},
],
},
"bar": {
"type": "object",
"properties": {
"name": {"type": "string"}
},
},
"adict": {
"type": "object",
"additionalProperties": {},
},
"bdict": {
"type": "object",
"additionalProperties": {
"type": "boolean"
},
},
"anything": {},
"choice": {
"type": "integer",
"format": "int32",
"enum": [1, 2],
},
},
}
}
},
"description": "Default Response",
}
},
),
),
)
def test_response_decorator(app, decorator, expected):
@app.route("/")
@decorator
def handler_one(_): ...
responses = get_path(app, "/")["responses"]
assert responses == expected
def test_summary_decorator(app):
@app.route("/")
@openapi.summary("foo")
async def handler(_): ...
spec = get_path(app, "/")
assert spec["summary"] == "foo"
@pytest.mark.parametrize(
"decorator,tags",
(
(openapi.tag("foo"), ("foo",)),
(openapi.tag("foo", openapi.definitions.Tag("bar")), ("foo", "bar")),
),
)
def test_tag_decorator(app, decorator, tags):
@app.route("/")
@decorator
def handler_one(_): ...
spec = get_spec(app)
for tag in tags:
assert {"name": tag} in spec["tags"]
tagged = get_path(app, "/")["tags"]
assert list(tagged) == list(tags)
def test_definition_decorator_body_dict_w_obj(app):
@app.route("/")
@openapi.definition(body={"application/json": Bar})
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_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_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(
body={
"application/json": {
"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_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):
@openapi.definition(body={"application/json": Bar})
async def get(self, request): ...
body = get_path(app, "/")["requestBody"]
assert body == {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
}
}
}
}
def test_definition_decorator_body_dict_multi(app):
@app.route("/")
@openapi.definition(body={"application/json": Bar, "text/plain": str})
async def handler(_): ...
body = get_path(app, "/")["requestBody"]
assert body == {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
}
},
"text/plain": {"schema": {"type": "string"}},
}
}
def test_definition_decorator_body_request_body(app):
@app.route("/")
@openapi.definition(
body=openapi.definitions.RequestBody(str, required=True)
)
async def handler(_): ...
body = get_path(app, "/")["requestBody"]
assert body == {
"content": {"*/*": {"schema": {"type": "string"}}},
"required": True,
}
def test_definition_decorator_body_model(app):
@app.route("/")
@openapi.definition(body=Bar)
async def handler(_): ...
body = get_path(app, "/")["requestBody"]
assert body == {
"content": {
"*/*": {
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
}
}
}
}
def test_definition_decorator_deprecated(app):
@app.route("/")
@openapi.definition(deprecated=True)
async def handler(_): ...
assert get_path(app, "/")["deprecated"]
def test_definition_decorator_description(app):
@app.route("/")
@openapi.definition(description="foo")
async def handler(_): ...
assert get_path(app, "/")["description"] == "foo"
def test_definition_decorator_document_string(app):
@app.route("/")
@openapi.definition(document="foo")
async def handler(_): ...
assert get_path(app, "/")["externalDocs"] == {"url": "foo"}
def test_definition_decorator_document_obj(app):
@app.route("/")
@openapi.definition(
document=openapi.definitions.ExternalDocumentation("foo", "bar")
)
async def handler(_): ...
assert get_path(app, "/")["externalDocs"] == {
"url": "foo",
"description": "bar",
}
def test_definition_decorator_operation(app):
@app.route("/")
@openapi.definition(operation="foo")
async def handler(_): ...
assert get_path(app, "/")["operationId"] == "foo"
def test_definition_decorator_parameter_string(app):
@app.route("/")
@openapi.definition(parameter="foo")
async def handler(_): ...
parameters = get_path(app, "/")["parameters"]
assert {
"name": "foo",
"schema": {"type": "string"},
"in": "query",
} in parameters
def test_definition_decorator_parameter_dict(app):
@app.route("/")
@openapi.definition(parameter={"name": "foo"})
async def handler(_): ...
parameters = get_path(app, "/")["parameters"]
assert {
"name": "foo",
"schema": {"type": "string"},
"in": "query",
} in parameters
def test_definition_decorator_parameter_obj(app):
@app.route("/")
@openapi.definition(
parameter=openapi.definitions.Parameter("foo", description="bar")
)
async def handler(_): ...
parameters = get_path(app, "/")["parameters"]
assert {
"name": "foo",
"schema": {"type": "string"},
"in": "query",
"description": "bar",
} in parameters
def test_definition_decorator_parameter_string_multi(app):
@app.route("/")
@openapi.definition(parameter=["foo", "bar"])
async def handler(_): ...
parameters = get_path(app, "/")["parameters"]
assert {
"name": "foo",
"schema": {"type": "string"},
"in": "query",
} in parameters
assert {
"name": "bar",
"schema": {"type": "string"},
"in": "query",
} in parameters
def test_definition_decorator_parameter_dict_multi(app):
@app.route("/")
@openapi.definition(
parameter=[{"name": "foo"}, {"name": "bar", "location": "header"}]
)
async def handler(_): ...
parameters = get_path(app, "/")["parameters"]
assert {
"name": "foo",
"schema": {"type": "string"},
"in": "query",
} in parameters
assert {
"name": "bar",
"schema": {"type": "string"},
"in": "header",
} in parameters
def test_definition_decorator_parameter_obj_multi(app):
@app.route("/")
@openapi.definition(
parameter=[
openapi.definitions.Parameter("foo"),
openapi.definitions.Parameter("bar"),
]
)
async def handler(_): ...
parameters = get_path(app, "/")["parameters"]
assert {
"name": "foo",
"schema": {"type": "string"},
"in": "query",
} in parameters
assert {
"name": "bar",
"schema": {"type": "string"},
"in": "query",
} in parameters
def test_definition_decorator_response_dict(app):
@app.route("/")
@openapi.definition(response={"application/json": Bar})
async def handler(_): ...
responses = get_path(app, "/")["responses"]
assert responses["default"] == {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
}
}
},
"description": "Default Response",
}
def test_definition_decorator_response_obj(app):
@app.route("/")
@openapi.definition(
response=openapi.definitions.Response(
{"application/json": Bar}, status=201
)
)
async def handler(_): ...
responses = get_path(app, "/")["responses"]
assert responses["201"] == {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
}
}
},
"description": "Default Response",
}
def test_definition_decorator_response_model(app):
@app.route("/")
@openapi.definition(response=Bar)
async def handler(_): ...
responses = get_path(app, "/")["responses"]
assert responses["default"] == {
"content": {
"*/*": {
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
}
}
},
"description": "Default Response",
}
def test_definition_decorator_response_dict_multi(app):
@app.route("/")
@openapi.definition(
response=[
{"application/json": Bar, "text/plain": str},
{"text/html": str},
]
)
async def handler(_): ...
responses = get_path(app, "/")["responses"]
assert responses["default"] == {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
}
},
"text/plain": {"schema": {"type": "string"}},
"text/html": {"schema": {"type": "string"}},
},
"description": "Default Response",
}
def test_definition_decorator_response_obj_multi(app):
@app.route("/")
@openapi.definition(
response=[
openapi.definitions.Response(
{"application/json": Bar}, status=201
),
openapi.definitions.Response(
{"*/*": str}, status=400, description="Something bad"
),
]
)
async def handler(_): ...
responses = get_path(app, "/")["responses"]
assert responses["201"] == {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
}
}
},
"description": "Default Response",
}
assert responses["400"] == {
"content": {"*/*": {"schema": {"type": "string"}}},
"description": "Something bad",
}
def test_definition_decorator_response_model_multi(app):
with pytest.raises(SanicException):
@app.route("/")
@openapi.definition(response=[Bar, LittleFoo])
async def handler(_): ...
def test_definition_decorator_summary(app):
@app.route("/")
@openapi.definition(summary="foo")
async def handler(_): ...
assert get_path(app, "/")["summary"] == "foo"
def test_definition_decorator_tag_string(app):
@app.route("/")
@openapi.definition(tag="foo")
async def handler(_): ...
assert list(get_path(app, "/")["tags"]) == ["foo"]
def test_definition_decorator_tag_obj(app):
@app.route("/")
@openapi.definition(tag=openapi.definitions.Tag("foo"))
async def handler(_): ...
assert list(get_path(app, "/")["tags"]) == ["foo"]
def test_definition_decorator_tag_string_multi(app):
@app.route("/")
@openapi.definition(tag=["foo", "bar"])
async def handler(_): ...
assert list(get_path(app, "/")["tags"]) == ["foo", "bar"]
def test_definition_decorator_tag_obj_multi(app):
@app.route("/")
@openapi.definition(
tag=[openapi.definitions.Tag("foo"), openapi.definitions.Tag("bar")]
)
async def handler(_): ...
assert list(get_path(app, "/")["tags"]) == ["foo", "bar"]
@@ -0,0 +1,56 @@
from typing import Optional
import pytest
from sanic_ext.extensions.openapi.definitions import Server
from sanic_ext.extensions.openapi.types import Definition
@pytest.fixture
def Thing():
class Thing(Definition):
name: str
foo: Optional[bool]
bar: Optional[bool]
def __init__(self, name, foo=None, bar=None):
super().__init__(name=name, foo=foo, bar=bar)
return Thing
def test_serialize_null_show_by_default(Thing):
thing = Thing(name="ok")
serialized = thing.serialize()
assert serialized["name"] == "ok"
assert serialized["foo"] is None
assert serialized["bar"] is None
def test_serialize_some_nullable(Thing):
Thing.__nullable__ = ["foo"]
thing = Thing(name="ok")
serialized = thing.serialize()
assert serialized["name"] == "ok"
assert serialized["foo"] is None
assert "bar" not in serialized
def test_serialize_no_nullable(Thing):
Thing.__nullable__ = False
thing = Thing(name="ok")
serialized = thing.serialize()
assert serialized["name"] == "ok"
assert "foo" not in serialized
assert "bar" not in serialized
def test_server_definitions_defaults():
assert Server(url="url").fields == {
"url": "url",
"description": None,
"variables": {},
}
@@ -0,0 +1,39 @@
from sanic import Request, Sanic
from sanic.response import text
from sanic_ext import openapi
from .utils import get_spec
def test_deprecated(app: Sanic):
@app.route("/test0")
@openapi.deprecated()
async def handler0(request: Request):
return text("ok")
@app.route("/test1")
@openapi.deprecated
async def handler1(request: Request):
return text("ok")
@app.route("/test2")
async def handler2(request: Request):
"""
openapi:
---
summary: This is a summary.
deprecated: true
"""
return text("ok")
@app.route("/test3")
@openapi.definition(deprecated=True)
async def handler3(request: Request):
return text("ok")
spec = get_spec(app)
paths = spec["paths"]
assert len(paths) == 4
for i in range(4):
assert paths[f"/test{i}"]["get"]["deprecated"]
@@ -0,0 +1,52 @@
from sanic import Blueprint, Request, Sanic, text
from sanic_ext.extensions.openapi import openapi
from .utils import get_spec
def test_exclude_decorator(app: Sanic):
@app.route("/test0")
@openapi.exclude()
async def handler0(request: Request):
"""
openapi:
---
summary: This is a summary.
"""
return text("ok")
@app.route("/test1")
@openapi.definition(summary="This is a summary.", exclude=True)
async def handler1(request: Request):
return text("ok")
spec = get_spec(app)
paths = spec["paths"]
assert len(paths) == 0
def test_exclude_bp(app: Sanic):
bp1 = Blueprint("blueprint1")
bp2 = Blueprint("blueprint2")
@bp1.route("/op1")
@openapi.summary("handler 1")
async def handler1(request: Request):
return text("bp1, ok")
@bp2.route("/op2")
@openapi.summary("handler 2")
async def handler2(request: Request):
return text("bp2, ok")
app.blueprint(bp1)
app.blueprint(bp2)
openapi.exclude(bp=bp1)
spec = get_spec(app)
paths = spec["paths"]
assert len(paths) == 1
assert "/op2" in paths
assert "/op1" not in paths
assert paths["/op2"]["get"]["summary"] == "handler 2"
@@ -0,0 +1,56 @@
from sanic import Request, Sanic
from sanic.response import text
from sanic_ext import openapi
from sanic_ext.extensions.openapi.definitions import ExternalDocumentation
from .utils import get_spec
def test_external_docs(app: Sanic):
@app.route("/test0")
@openapi.document("http://example.com/more", "Find more info here")
async def handler0(request: Request):
return text("ok")
@app.route("/test1")
@openapi.definition(
document=ExternalDocumentation(
"http://example.com/more", "Find more info here"
)
)
async def handler1(request: Request):
return text("ok")
@app.route("/test2")
@openapi.definition(document="http://example.com/more")
async def handler2(request: Request):
return text("ok")
@app.route("/test3")
async def handler3(request: Request):
"""
openapi:
---
summary: This is a summary.
externalDocs:
description: Find more info here
url: http://example.com/more
"""
return text("ok")
@app.route("/test4")
@openapi.document(
ExternalDocumentation("http://example.com/more", "Find more info here")
)
async def handler4(request: Request):
return text("ok")
spec = get_spec(app)
paths = spec["paths"]
assert len(paths) == 5
for i in range(5):
doc_obj = paths[f"/test{i}"]["get"]["externalDocs"]
assert doc_obj["url"] == "http://example.com/more"
if i != 2:
assert doc_obj["description"] == "Find more info here"
@@ -0,0 +1,51 @@
from sanic import Sanic
from sanic.response import text
from sanic_ext import openapi
from .utils import get_spec
def test_func_handlers(app: Sanic):
class TestClass:
@staticmethod
@openapi.definition(
summary="staticmethod custom summary",
tag="Test",
)
async def staticmethod_handler(request):
return text("...")
@classmethod
@openapi.definition(
summary="classmethod custom summary",
tag="Test",
)
async def classmethod_handler(cls, request):
return text("...")
@openapi.definition(
summary="instance method custom summary",
tag="Test",
)
async def instance_method_handler(self, request):
return text("...")
app.add_route(TestClass.staticmethod_handler, "/staticmethod")
app.add_route(TestClass.classmethod_handler, "/classmethod")
app.add_route(TestClass().instance_method_handler, "/instance_method")
spec = get_spec(app)
paths = spec["paths"]
assert len(paths) == 3
assert (
paths["/staticmethod"]["get"]["summary"]
== "staticmethod custom summary"
)
assert (
paths["/classmethod"]["get"]["summary"] == "classmethod custom summary"
)
assert (
paths["/instance_method"]["get"]["summary"]
== "instance method custom summary"
)
@@ -0,0 +1,103 @@
from dataclasses import dataclass, field
from typing import Annotated
from uuid import UUID
import attrs
import pytest
from msgspec import Meta, Struct
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:
links: list[UUID]
priority: int = field(
metadata={"openapi": {"exclusiveMinimum": 1, "exclusiveMaximum": 10}}
)
ident: str = field(
default="XXXX", metadata={"openapi": {"example": "ABC123"}}
)
@attrs.define
class FooAttrs:
links: list[UUID]
priority: int = attrs.field(
metadata={"openapi": {"exclusiveMinimum": 1, "exclusiveMaximum": 10}}
)
ident: str = attrs.field(
default="XXXX", metadata={"openapi": {"example": "ABC123"}}
)
class FooPydanticBaseModel(BaseModel):
links: list[UUID]
priority: int = Field(gt=1, lt=10)
ident: str = Field("XXXX", example="ABC123")
@pydataclass
class FooPydanticDataclass:
links: list[UUID]
priority: int = Field(gt=1, lt=10)
ident: str = Field("XXXX", example="ABC123")
class FooStruct(Struct):
links: list[UUID]
priority: Annotated[
int,
Meta(
extra={"openapi": {"exclusiveMinimum": 1, "exclusiveMaximum": 10}}
),
]
ident: Annotated[str, Meta(extra={"openapi": {"example": "ABC123"}})] = (
"XXXX"
)
models = [
FooDataclass,
FooAttrs,
FooPydanticBaseModel,
FooPydanticDataclass,
]
models.append(FooStruct)
@pytest.mark.parametrize("Foo", models)
def test_models(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["links"] == {
"title": "Links",
"type": "array",
"items": {"type": "string", "format": "uuid"},
}
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,
}
@@ -0,0 +1,108 @@
from dataclasses import dataclass
from datetime import datetime
import attrs
import pytest
from msgspec import Struct
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
class AlertMsgspecBaseModel(Struct):
hit: dict[str, int]
last_updated: datetime
class AlertResponseMsgspecBaseModel(Struct):
alert: AlertMsgspecBaseModel
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),
(AlertResponseMsgspecBaseModel, True),
(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}"}
@@ -0,0 +1,149 @@
from sanic import Request, Sanic, text
from sanic_ext.extensions.openapi import openapi
from sanic_ext.extensions.openapi.definitions import Parameter
from .utils import get_spec
def test_parameter(app: Sanic):
DESCRIPTION = "val1 path param"
NAME = "val1"
LOCATION = "path"
TYPE = "integer"
@app.route("/test1/<val1>")
async def handler1(request: Request, val1: int):
"""
openapi:
---
operationId: get.test1
parameters:
- name: val1
in: path
description: val1 path param
required: true
schema:
type: integer
format: int32
"""
return text("ok")
@app.route("/test2/<val1>")
@openapi.parameter(
parameter=Parameter(
name="val1",
schema=int,
location=LOCATION,
description=DESCRIPTION,
required=True,
)
)
async def handler2(request: Request, val1: int):
return text("ok")
@app.route("/test3/<val1>")
@openapi.parameter(
"val1",
description=DESCRIPTION,
required=True,
schema=int,
location=LOCATION,
)
async def handler3(request: Request, val1: int):
return text("ok")
@app.route("/test4/<val1>")
@openapi.definition(
parameter={
"name": "val1",
"description": DESCRIPTION,
"required": True,
"schema": int,
"location": LOCATION,
}
)
async def handler4(request: Request, val1: int):
return text("ok")
@app.route("/test5/<val1>")
@openapi.definition(
parameter=Parameter(
name="val1",
schema=int,
location=LOCATION,
description=DESCRIPTION,
required=True,
)
)
async def handler5(request: Request, val1: int):
return text("ok")
@app.route("/test6/<val1:strorempty>")
async def handler6(request: Request, val1: str):
"""
openapi:
---
operationId: get.test1
parameters:
- name: val1
in: path
description: val1 path param
required: false
"""
return text("ok")
@app.route("/test7/<val1:ext>")
@openapi.parameter(
parameter=Parameter(
name="val1",
location=LOCATION,
description=DESCRIPTION,
required=True,
)
)
async def handler7(request: Request, val1: int):
return text("ok")
@app.route("/test8/<val1:alpha>")
@openapi.parameter(
parameter=Parameter(
name="val1",
location=LOCATION,
description=DESCRIPTION,
required=True,
)
)
async def handler8(request: Request, val1: int):
return text("ok")
@app.route("/test9/<val1:slug>")
@openapi.parameter(
parameter=Parameter(
name="val1",
location=LOCATION,
description=DESCRIPTION,
required=True,
)
)
async def handler9(request: Request, val1: int):
return text("ok")
spec = get_spec(app)
for i in range(1, 6):
assert f"/test{i}/{{val1}}" in spec["paths"]
parameter = spec["paths"][f"/test{i}/{{val1}}"]["get"]["parameters"][0]
assert parameter["name"] == NAME
assert parameter["in"] == LOCATION
assert parameter["required"] is True
assert parameter["schema"]["type"] == TYPE
assert parameter["description"] == DESCRIPTION
for i in range(6, 10):
assert f"/test{i}/{{val1}}" in spec["paths"]
parameter = spec["paths"][f"/test{i}/{{val1}}"]["get"]["parameters"][0]
assert parameter["name"] == NAME
assert parameter["in"] == LOCATION
assert parameter["required"] is not (i == 6)
assert parameter["schema"]["type"] == "string"
assert parameter["description"] == DESCRIPTION
@@ -0,0 +1,58 @@
import pytest
from sanic import Sanic
from .utils import get_spec
@pytest.fixture(autouse=True)
def handlers(app: Sanic):
@app.route("/test1")
async def handler1(_): ...
@app.route("/test2/1")
async def handler2(_): ...
@app.route("/test3/")
async def handler3(_): ...
@app.route("/test4/2/")
async def handler4(_): ...
def test_paths_with_all_uri_filter(app: Sanic):
app.config.API_URI_FILTER = "all"
spec = get_spec(app)
assert list(spec["paths"].keys()) == [
"/test1",
"/test1/",
"/test2/1",
"/test2/1/",
"/test3",
"/test3/",
"/test4/2",
"/test4/2/",
]
def test_paths_with_slash_uri_filter(app: Sanic):
app.config.API_URI_FILTER = "slash"
spec = get_spec(app)
assert list(spec["paths"].keys()) == [
"/test1/",
"/test2/1/",
"/test3/",
"/test4/2/",
]
def test_paths_without_uri_filter(app: Sanic):
spec = get_spec(app)
assert list(spec["paths"].keys()) == [
"/test1",
"/test2/1",
"/test3",
"/test4/2",
]
@@ -0,0 +1,75 @@
from sys import version_info
import pytest
from sanic_ext.extensions.openapi.types import Schema, String
@pytest.mark.skipif(version_info < (3, 9), reason="Not needed on 3.8")
def test_schema_list():
class Foo:
list1: list[int]
list2: list[int]
@property
def show(self) -> bool:
return True
def no_show_method(self) -> None: ...
@classmethod
def no_show_classmethod(self) -> None: ...
@staticmethod
def no_show_staticmethod() -> None: ...
schema = Schema.make(Foo)
serialized = schema.serialize()
assert "no_show_method" not in serialized
assert "no_show_classmethod" not in serialized
assert "no_show_staticmethod" not in serialized
assert serialized == {
"type": "object",
"properties": {
"list1": {
"type": "array",
"items": {"type": "integer", "format": "int32"},
},
"list2": {
"type": "array",
"items": {"type": "integer", "format": "int32"},
},
"show": {"type": "boolean"},
},
}
def test_schema_fields():
class Pet:
name = String(example="Snoopy")
class Single:
pet = Pet
class Ignore: ...
class Multiple:
pets = [Pet]
pet_schema = Schema.make(Pet).serialize()
single_schema = Schema.make(Single).serialize()
multiple_schema = Schema.make(Multiple).serialize()
assert pet_schema == {
"type": "object",
"properties": {"name": {"type": "string", "example": "Snoopy"}},
}
assert single_schema == {
"type": "object",
"properties": {"pet": pet_schema},
}
assert multiple_schema == {
"type": "object",
"properties": {"pets": {"type": "array", "items": pet_schema}},
}
@@ -0,0 +1,98 @@
from sanic import Sanic
from sanic_ext import openapi
from tests.extensions.openapi.utils import get_path, get_spec
def test_secured(app: Sanic):
@app.route("/one")
async def handler1(request):
"""
openapi:
---
security:
- foo: []
"""
@app.route("/two")
@openapi.secured("foo")
@openapi.secured({"bar": []})
@openapi.secured(baz=[])
async def handler2(request): ...
@app.route("/three")
@openapi.definition(secured="foo")
@openapi.definition(secured={"bar": []})
async def handler3(request): ...
spec = get_path(app, "/one")
assert {"foo": []} in spec["security"]
spec = get_path(app, "/two")
assert {"foo": []} in spec["security"]
assert {"bar": []} in spec["security"]
assert {"baz": []} in spec["security"]
spec = get_path(app, "/three")
assert {"foo": []} in spec["security"]
assert {"bar": []} in spec["security"]
def test_security_scheme(app: Sanic):
app.ext.openapi.add_security_scheme("api_key", "apiKey")
app.ext.openapi.add_security_scheme("token1", "http")
app.ext.openapi.add_security_scheme(
"token2", "http", scheme="bearer", bearer_format="JWT"
)
app.ext.openapi.add_security_scheme("oldschool", "http", scheme="basic")
app.ext.openapi.add_security_scheme(
"oa2",
"oauth2",
flows={
"implicit": {
"authorizationUrl": "http://example.com/auth",
"scopes": {
"one:two": "something",
"three:four": "something else",
},
}
},
)
spec = get_spec(app)
schemes = spec["components"]["securitySchemes"]
assert schemes["api_key"] == {
"type": "apiKey",
"name": "authorization",
"in": "header",
}
assert schemes["token1"] == {
"type": "http",
"scheme": "Bearer",
}
assert schemes["token2"] == {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
}
assert schemes["oa2"] == {
"type": "oauth2",
"flows": {
"implicit": {
"authorizationUrl": "http://example.com/auth",
"scopes": {
"one:two": "something",
"three:four": "something else",
},
}
},
}
def test_raw(app: Sanic):
app.ext.openapi.raw({"security": [{}, {"foo": []}]})
spec = get_spec(app)
assert {} in spec["security"]
assert {"foo": []} in spec["security"]
@@ -0,0 +1,37 @@
from pathlib import Path
from sanic_ext.extensions.openapi.builders import SpecificationBuilder
def test_specification_singleton():
spec = SpecificationBuilder()
spec.tag("foo")
new_spec = SpecificationBuilder()
assert spec is new_spec
assert spec.tags == new_spec.tags
def test_specification_reset(get_docs):
spec = SpecificationBuilder()
docs = get_docs()
assert len(docs["tags"]) == 0
spec.tag("foo")
docs = get_docs()
assert len(docs["tags"]) == 1
spec.reset()
docs = get_docs()
assert len(docs["tags"]) == 0
def test_custom_specification(app):
petstore_file = Path(__file__).parent / "static" / "petstore.json"
petstore_data = petstore_file.read_text()
app.config.OAS_CUSTOM_FILE = petstore_file
_, response = app.test_client.get("/docs/openapi.json")
assert response.text == petstore_data
@@ -0,0 +1,38 @@
from sanic import Request, Sanic
from sanic.response import text
from sanic_ext import openapi
from .utils import get_spec
def test_summary(app: Sanic):
@app.route("/test0")
async def handler0(request: Request):
"""This is a summary."""
return text("ok")
@app.route("/test1")
@openapi.summary("This is a summary.")
async def handler1(request: Request):
return text("ok")
@app.route("/test2")
@openapi.definition(summary="This is a summary.")
async def handler2(request: Request):
return text("ok")
@app.route("/test3")
async def handler3(request: Request):
"""
openapi:
---
summary: This is a summary.
"""
return text("ok")
spec = get_spec(app)
paths = spec["paths"]
assert len(paths) == 4
for i in range(4):
assert paths[f"/test{i}"]["get"]["summary"] == "This is a summary."
@@ -0,0 +1,35 @@
from sanic import Request, Sanic, text
from sanic_ext.extensions.openapi import openapi
from .utils import get_spec
def test_tag(app: Sanic):
TAG_NAME = "test-tag-1"
@app.route("/test0")
async def handler0(request: Request, val1: int):
"""
openapi:
---
tags:
- test-tag-1
"""
return text("ok")
@app.route("/test1")
@openapi.tag(TAG_NAME)
async def handler1(request: Request, val1: int):
return text("ok")
@app.route("/test2")
@openapi.definition(tag=TAG_NAME)
async def handler2(request: Request, val1: int):
return text("ok")
spec = get_spec(app)
for i in range(3):
assert f"/test{i}" in spec["paths"]
tag = spec["paths"][f"/test{i}"]["get"]["tags"][0]
assert tag == TAG_NAME
@@ -0,0 +1,49 @@
import sys
from typing import 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),
]
params.append(({"foo": list[str]}, True))
if sys.version_info >= (3, 10):
params.append(({"foo": str | None}, True))
@pytest.mark.parametrize("item,expected", params)
def test_contains_annotations(item, expected):
assert contains_annotations(item) == expected
@@ -0,0 +1,14 @@
from typing import Any
from sanic import Sanic
def get_spec(app: Sanic) -> dict[str, Any]:
test_client = app.test_client
_, response = test_client.get("/docs/openapi.json")
return response.json
def get_path(app: Sanic, path: str, method: str = "get") -> dict[str, Any]:
spec = get_spec(app)
return spec["paths"][path][method.lower()]
@@ -0,0 +1,153 @@
from pathlib import Path
from sanic import Sanic
from sanic_ext import render
def test_default_templates():
app = Sanic("templating")
app.extend(
config={
"templating_path_to_templates": Path(__file__).parent / "templates"
}
)
@app.get("/1")
@app.ext.template("foo.html")
async def handler(_):
return {"seq": ["one", "two"]}
@app.get("/2")
async def handler2(_):
return await render(
"foo.html", context={"seq": ["three", "four"]}, app=app
)
@app.get("/3")
@app.ext.template("foo.html")
async def handler3(_):
return await render(
context={"seq": ["five", "six"]}, status=201, app=app
)
_, response = app.test_client.get("/1")
assert response.content_type == "text/html; charset=utf-8"
assert "<li>one</li>" in response.text
assert "<li>two</li>" in response.text
_, response = app.test_client.get("/2")
assert response.content_type == "text/html; charset=utf-8"
assert "<li>three</li>" in response.text
assert "<li>four</li>" in response.text
_, response = app.test_client.get("/3")
assert response.content_type == "text/html; charset=utf-8"
assert "<li>five</li>" in response.text
assert "<li>six</li>" in response.text
assert response.status == 201
def test_render_from_string():
app = Sanic("templating-from-string")
app.extend()
template = """
<!DOCTYPE html>
<html lang="en">
<head>
<title>My Webpage</title>
</head>
<body>
<h1>Hello, world!!!!</h1>
<ul>
{% for item in seq %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</body>
</html>
"""
@app.get("/2")
async def handler2(_):
return await render(
template_source=template,
context={"seq": ["three", "four"]},
app=app,
)
_, response = app.test_client.get("/2")
assert response.content_type == "text/html; charset=utf-8"
assert "<li>three</li>" in response.text
assert "<li>four</li>" in response.text
def test_config_templating_dir():
app = Sanic("templating")
app.config.TEMPLATING_PATH_TO_TEMPLATES = (
Path(__file__).parent / "templates"
)
assert app.ext.templating.environment.get_template(
"foo.html"
).filename == str(Path(__file__).parent / "templates" / "foo.html")
def test_url_for():
app = Sanic("templating-from-string")
app.extend()
template = r"url: {{ url_for('handler') }}"
@app.get("/one/two/three")
async def handler(_):
return await render(template_source=template)
_, response = app.test_client.get("/one/two/three")
assert response.text == "url: /one/two/three"
def test_default_context():
app = Sanic("templating-from-string")
app.extend(
config={
"templating_path_to_templates": Path(__file__).parent / "templates"
}
)
template = r"{{ request.args.get('test') }}"
@app.get("/1")
async def handler1(_):
return await render(template_source=template)
@app.get("/2")
@app.ext.template("request_test.html")
async def handler2(_):
return {}
@app.get("/3")
async def handler3(_):
return await render("request_test.html", context={}, app=app)
@app.get("/4")
@app.ext.template("request_test.html")
async def handler4(_):
return await render(context={}, app=app)
_, response = app.test_client.get("/1?test=passing")
assert response.text == "passing"
_, response = app.test_client.get("/2?test=passing")
assert response.text == "passing"
_, response = app.test_client.get("/3?test=passing")
assert response.text == "passing"
_, response = app.test_client.get("/4?test=passing")
assert response.text == "passing"
@@ -0,0 +1,63 @@
from typing import Union
from unittest.mock import Mock
import pytest
from sanic import Sanic
from sanic_ext import Extend, Extension
def test_multiple_extensions(bare_app: Sanic):
mock = Mock()
class FooExtension(Extension):
name = "foo"
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
def startup(self, _) -> None:
mock()
class BarExtension(Extension):
name = "bar"
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
def startup(self, _) -> None:
mock()
bare_app.extend(extensions=[FooExtension, BarExtension, BarExtension])
assert mock.call_count == 2
@pytest.mark.parametrize("instance", (True, False))
def test_multiple_extensions_pre_register(bare_app: Sanic, instance: bool):
mock = Mock()
class FooExtension(Extension):
name = "foo"
def startup(self, _) -> None:
mock()
class BarExtension(Extension):
name = "bar"
def startup(self, _) -> None:
mock()
foo: Union[type[Extension], Extension] = FooExtension
bar: Union[type[Extension], Extension] = BarExtension
if instance:
foo = foo() # type: ignore
bar = bar() # type: ignore
Extend.register(foo)
Extend.register(bar)
bare_app.extend()
assert mock.call_count == 2
@@ -0,0 +1,159 @@
import sys
from dataclasses import dataclass, field
from typing import Literal, Optional, Union
@dataclass
class ModelStr:
foo: str
@dataclass
class ModelInt:
foo: int
@dataclass
class ModelFloat:
foo: float
@dataclass
class ModelBool:
foo: bool
@dataclass
class ModelOptionalStr:
foo: Optional[str]
@dataclass
class ModelUnion:
foo: Union[int, float]
@dataclass
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]
@dataclass
class ModelListModel:
foo: list[ModelStr]
@dataclass
class ModelOptionalList:
foo: Optional[list[str]]
@dataclass
class ModelListUnion:
foo: list[Union[int, float]]
@dataclass
class ModelOptionalListUnion:
foo: Optional[list[Union[int, float]]]
@dataclass
class ModelModel:
foo: ModelStr
@dataclass
class ModelOptionalModel:
foo: Optional[ModelStr]
@dataclass
class ModelDictStr:
foo: dict[str, str]
@dataclass
class ModelDictModel:
foo: dict[str, ModelStr]
@dataclass
class ModelOptionalDict:
foo: Optional[dict[str, str]]
@dataclass
class ModelDictUnion:
foo: dict[str, Union[int, float]]
@dataclass
class ModelOptionalDictUnion:
foo: Optional[dict[str, Union[int, float]]]
@dataclass
class ModelSingleLiteral:
foo: Literal[True]
@dataclass
class ModelMultipleLiteral:
foo: Literal[True, "y", "Y", 1]
@dataclass
class ModelOptionalSingleLiteral:
foo: Optional[Literal[True]]
@dataclass
class ModelOptionalMultipleLiteral:
foo: Optional[Literal[True, "y", "Y", 1]]
@dataclass
class ModelListStrWithDefaultFactory:
foo: list[str] = field(default_factory=list)
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
@@ -0,0 +1,24 @@
from sys import version_info
from typing import Any
import pytest
from sanic_ext.extras.validation.schema import parse_hint
@pytest.mark.skipif(version_info < (3, 9), reason="Not needed on 3.8")
def test_parse_generic_list():
hint_1 = parse_hint(list[int])
hint_2 = parse_hint(list[int])
assert hint_1.origin == hint_2.origin
assert hint_1.allowed == hint_2.allowed
@pytest.mark.skipif(version_info < (3, 9), reason="Not needed on 3.8")
def test_parse_generic_dict():
hint_1 = parse_hint(dict[str, Any])
hint_2 = parse_hint(dict[str, Any])
assert hint_1.origin == hint_2.origin
assert hint_1.allowed == hint_2.allowed
@@ -0,0 +1,43 @@
from unittest.mock import Mock
import pytest
from sanic import Sanic
from sanic.compat import Header
from sanic.response import json
from sanic_testing.reusable import ReusableClient
from sanic_ext import CountedRequest
@pytest.fixture(autouse=True)
def reset_counter():
yield
CountedRequest.reset_count()
def test_counter_increments(app: Sanic):
app.request_class = CountedRequest
@app.get("/")
async def handler(request: CountedRequest):
return json({"count": request.count})
@app.get("/info")
async def info(request: CountedRequest):
return json({"state": request.app.m.state})
with ReusableClient(app) as client:
for i in range(1, 10):
_, response = client.get("/")
assert response.json["count"] == i
def test_counter_increment_on_state(app: Sanic):
mock = Mock()
mock.state = {}
app.multiplexer = mock
for i in range(1, 10):
CountedRequest(b"/", Header({}), "", "", Mock(), app)
assert CountedRequest.count == i
@@ -0,0 +1,70 @@
from sanic import text
from sanic.response import json
from sanic_ext import serializer
def test_serializer_with_builtin(app):
@app.get("/")
@serializer(text)
async def handler(request):
return "hello"
@app.get("/201")
@serializer(text, status=201)
async def handler_201(request):
return "hello"
_, response = app.test_client.get("/")
assert response.status_code == 200
assert response.text == "hello"
assert response.content_type == "text/plain; charset=utf-8"
_, response = app.test_client.get("/201")
assert response.status_code == 201
assert response.content_type == "text/plain; charset=utf-8"
def test_serializer_with_custom(app):
def custom(message, status):
return json({"message": message}, status=status)
@app.get("/")
@serializer(custom)
async def handler(request):
return "hello"
@app.get("/201")
@serializer(custom, status=201)
async def handler_201(request):
return "hello"
_, response = app.test_client.get("/")
assert response.status_code == 200
assert response.content_type == "application/json"
assert response.json["message"] == "hello"
_, response = app.test_client.get("/201")
assert response.status_code == 201
assert response.content_type == "application/json"
def test_serializer_with_params(app):
def message(retval, request, action, status):
return json(
{
"request_id": str(request.id),
"action": action,
"message": retval,
},
status=status,
)
@app.get("/<action>")
@serializer(message)
async def do_action(request, action: str):
return "This is a message"
_, response = app.test_client.get("/this")
assert response.status_code == 200
assert response.json["action"] == "this"
@@ -0,0 +1,100 @@
from sys import version_info
from typing import Literal, Union
import pytest
from sanic import text
from sanic_ext import validate
def _dataclass_spec(annotation):
from dataclasses import dataclass
@dataclass
class Spec:
name: annotation
return Spec
def _attrs_spec(annotation):
import attrs
@attrs.define
class Spec:
name: annotation
return Spec
def _msgspec_spec(annotation):
from msgspec import Struct
class Spec(Struct):
name: annotation
return Spec
def _pydantic_spec(annotation):
from pydantic.dataclasses import dataclass as pydataclass
@pydataclass
class Spec:
name: annotation
return Spec
@pytest.mark.parametrize(
"annotation",
(
(
Literal["foo"],
Literal["foo", "bar"],
Union[Literal["foo"], Literal["bar"]],
)
),
)
@pytest.mark.parametrize(
"spec_builder",
(
_dataclass_spec,
_attrs_spec,
_msgspec_spec,
_pydantic_spec,
),
)
def test_literal(app, annotation, spec_builder):
Spec = spec_builder(annotation)
@app.get("/")
@validate(query=Spec)
def route(_, query: Spec):
return text(query.name)
_, response = app.test_client.get("", params={"name": "foo"})
assert response.text == "foo"
@pytest.mark.skipif(version_info < (3, 10), reason="Not needed on 3.10")
@pytest.mark.parametrize(
"spec_builder",
(
_dataclass_spec,
_attrs_spec,
_msgspec_spec,
_pydantic_spec,
),
)
def test_literal_3_10(app, spec_builder):
Spec = spec_builder(Literal["foo"] | Literal["bar"])
@app.get("/")
@validate(query=Spec)
def route(_, query: Spec):
return text(query.name)
_, response = app.test_client.get("", params={"name": "foo"})
assert response.text == "foo"
@@ -0,0 +1,112 @@
import attrs
from sanic import json
from sanic.views import HTTPMethodView
from sanic_ext import validate
SNOOPY_DATA = {"name": "Snoopy", "alter_ego": ["Flying Ace", "Joe Cool"]}
def test_validate_json(app):
@attrs.define
class Pet:
name: str
alter_ego: list[str]
@app.post("/function")
@validate(json=Pet)
async def handler(_, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
}
)
class MethodView(HTTPMethodView, attach=app, uri="/method"):
decorators = [validate(json=Pet)]
async def post(self, _, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
}
)
_, response = app.test_client.post("/function", json=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
_, response = app.test_client.post("/method", json=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
def test_validate_form(app):
@attrs.define
class Pet:
name: str
alter_ego: list[str]
@app.post("/function")
@validate(form=Pet)
async def handler(_, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
}
)
class MethodView(HTTPMethodView, attach=app, uri="/method"):
decorators = [validate(form=Pet)]
async def post(self, _, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
}
)
_, response = app.test_client.post("/function", data=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
_, response = app.test_client.post("/method", data=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
def test_validate_query(app):
@attrs.define
class Search:
q: str
@app.get("/function")
@validate(query=Search)
async def handler(_, query: Search):
return json({"q": query.q, "is_search": isinstance(query, Search)})
class MethodView(HTTPMethodView, attach=app, uri="/method"):
decorators = [validate(query=Search)]
async def get(self, _, query: Search):
return json({"q": query.q, "is_search": isinstance(query, Search)})
_, response = app.test_client.get("/function", params={"q": "Snoopy"})
assert response.status == 200
assert response.json["is_search"]
assert response.json["q"] == "Snoopy"
_, response = app.test_client.get("/method", params={"q": "Snoopy"})
assert response.status == 200
assert response.json["is_search"]
assert response.json["q"] == "Snoopy"
@@ -0,0 +1,410 @@
import sys
from dataclasses import dataclass
from typing import Optional
import pytest
from sanic import json
from sanic.views import HTTPMethodView
from sanic_ext import validate
from sanic_ext.extras.validation.check import check_data
from sanic_ext.extras.validation.schema import make_schema, parse_hint
from . import __models__ as models
SNOOPY_DATA = {"name": "Snoopy", "alter_ego": ["Flying Ace", "Joe Cool"]}
def test_schema():
@dataclass
class Pet:
name: str
@dataclass
class Person:
name: str
age: int
pets: Optional[list[Pet]]
schema = make_schema({}, Person)
assert "Person" in schema
assert schema["Person"]["hints"]["name"] == parse_hint(str)
assert schema["Person"]["hints"]["age"] == parse_hint(int)
assert schema["Person"]["hints"]["pets"] == parse_hint(Optional[list[Pet]])
assert "Pet" in schema
assert schema["Pet"]["hints"]["name"] == parse_hint(str)
def test_should_hydrate():
@dataclass
class Pet:
name: str
@dataclass
class Person:
name: str
age: int
pets: list[Pet]
data = {"name": "Charlie Brown", "age": 8, "pets": [{"name": "Snoopy"}]}
schema = make_schema({}, Person)
cb = check_data(Person, data, schema)
assert cb.name == "Charlie Brown"
assert cb.age == 8
assert cb.pets[0].name == "Snoopy"
@pytest.mark.parametrize(
"data",
(
{"name": "Charlie Brown", "age": 8, "pets": {"name": "Snoopy"}},
{"name": "Charlie Brown", "age": 8, "pets": [{"name": 123}]},
{"name": "Charlie Brown", "age": 8, "pets": [123]},
{"name": "Charlie Brown", "age": 8, "pets": 123},
{"name": "Charlie Brown", "age": "8", "pets": {"name": "Snoopy"}},
{"name": True, "age": 8, "pets": {"name": "Snoopy"}},
),
)
def test_should_not_hydrate(data):
@dataclass
class Pet:
name: str
@dataclass
class Person:
name: str
age: int
pets: list[Pet]
schema = make_schema({}, Person)
with pytest.raises(TypeError):
check_data(Person, data, schema)
@pytest.mark.parametrize(
"model,okay,data",
(
(models.ModelStr, True, {"foo": "bar"}),
(models.ModelStr, False, {"foo": 1}),
(models.ModelStr, False, {"foo": True}),
(models.ModelStr, False, {"foo": ["bar"]}),
(models.ModelStr, False, {"bar": "bar"}),
(models.ModelStr, False, {"foo": None}),
(models.ModelStr, False, 123),
(models.ModelInt, True, {"foo": 1}),
(models.ModelInt, True, {"foo": True}),
(models.ModelInt, False, {"foo": "1"}),
(models.ModelInt, False, {"foo": 1.1}),
(models.ModelInt, False, {"foo": None}),
(models.ModelFloat, True, {"foo": 1.1}),
(models.ModelFloat, False, {"foo": 1}),
(models.ModelFloat, False, {"foo": "1.1"}),
(models.ModelFloat, False, {"foo": None}),
(models.ModelBool, True, {"foo": True}),
(models.ModelBool, True, {"foo": False}),
(models.ModelBool, False, {"foo": 1}),
(models.ModelBool, False, {"foo": 0}),
(models.ModelBool, False, {"foo": 2}),
(models.ModelBool, False, {"foo": "True"}),
(models.ModelBool, False, {"foo": None}),
(models.ModelOptionalStr, True, {"foo": "bar"}),
(models.ModelOptionalStr, True, {"foo": None}),
(models.ModelOptionalStr, False, {"foo": 0}),
(models.ModelUnion, True, {"foo": 1}),
(models.ModelUnion, True, {"foo": 1.1}),
(models.ModelUnion, False, {"foo": "1.1"}),
(models.ModelUnion, False, {"foo": None}),
(models.ModelUnionModels, True, {"foo": {"foo": 1}}),
(models.ModelUnionModels, True, {"foo": {"foo": 1.1}}),
(models.ModelUnionModels, False, {"foo": {"foo": "1.1"}}),
(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"}),
(models.ModelListStr, False, {"foo": ["one", 2]}),
(models.ModelListStr, False, {"foo": ["one", None]}),
(models.ModelListStr, False, {"foo": None}),
(models.ModelListModel, True, {"foo": [{"foo": "bar"}]}),
(
models.ModelListModel,
True,
{"foo": [{"foo": "one"}, {"foo": "two"}]},
),
(models.ModelListModel, False, {"foo": {"foo": "bar"}}),
(models.ModelListModel, False, {"foo": [{"foo": "bar"}, 2]}),
(models.ModelListModel, False, {"foo": [{"foo": "bar"}, None]}),
(models.ModelListModel, False, {"foo": None}),
(models.ModelOptionalList, True, {"foo": None}),
(models.ModelOptionalList, True, {"foo": ["bar"]}),
(models.ModelOptionalList, False, {"foo": [1]}),
(models.ModelOptionalList, False, {"foo": [None]}),
(models.ModelListUnion, True, {"foo": [1]}),
(models.ModelListUnion, True, {"foo": [1.1]}),
(models.ModelListUnion, True, {"foo": [1, 1.1]}),
(models.ModelListUnion, False, {"foo": [1, 1.1, "one"]}),
(models.ModelListUnion, False, {"foo": [1, 1.1, None]}),
(models.ModelListUnion, False, {"foo": 1}),
(models.ModelListUnion, False, {"foo": 1.1}),
(models.ModelListUnion, False, {"foo": None}),
(models.ModelOptionalListUnion, True, {"foo": [1]}),
(models.ModelOptionalListUnion, True, {"foo": [1.1]}),
(models.ModelOptionalListUnion, True, {"foo": [1, 1.1]}),
(models.ModelOptionalListUnion, True, {"foo": None}),
(models.ModelOptionalListUnion, False, {"foo": [1, 1.1, "one"]}),
(models.ModelOptionalListUnion, False, {"foo": [1, 1.1, None]}),
(models.ModelOptionalListUnion, False, {"foo": 1}),
(models.ModelOptionalListUnion, False, {"foo": 1.1}),
(models.ModelModel, True, {"foo": {"foo": "one"}}),
(models.ModelModel, False, {"foo": {"foo": 1}}),
(models.ModelModel, False, {"foo": {"foo": None}}),
(models.ModelModel, False, {"foo": "one"}),
(models.ModelModel, False, {"foo": None}),
(models.ModelOptionalModel, True, {"foo": {"foo": "one"}}),
(models.ModelOptionalModel, True, {"foo": None}),
(models.ModelOptionalModel, False, {"foo": {"foo": 1}}),
(models.ModelOptionalModel, False, {"foo": {"foo": None}}),
(models.ModelOptionalModel, False, {"foo": "one"}),
(models.ModelDictStr, True, {"foo": {"foo": "one"}}),
(models.ModelDictStr, False, {"foo": {"foo": 1}}),
(models.ModelDictStr, False, {"foo": {"foo": None}}),
(models.ModelDictStr, False, {"foo": "one"}),
(models.ModelDictStr, False, {"foo": None}),
(models.ModelDictModel, True, {"foo": {"foo": {"foo": "one"}}}),
(models.ModelDictModel, False, {"foo": {"foo": {"foo": 1}}}),
(models.ModelDictModel, False, {"foo": {"foo": 1}}),
(models.ModelDictModel, False, {"foo": {"foo": None}}),
(models.ModelDictModel, False, {"foo": "one"}),
(models.ModelDictModel, False, {"foo": None}),
(models.ModelOptionalDict, True, {"foo": {"foo": "one"}}),
(models.ModelOptionalDict, True, {"foo": None}),
(models.ModelOptionalDict, False, {"foo": {"foo": 1}}),
(models.ModelOptionalDict, False, {"foo": {"foo": None}}),
(models.ModelOptionalDict, False, {"foo": "one"}),
(models.ModelDictUnion, True, {"foo": {"foo": 1}}),
(models.ModelDictUnion, True, {"foo": {"foo": 1.1}}),
(models.ModelDictUnion, False, {"foo": {"foo": "one"}}),
(models.ModelDictUnion, False, {"foo": {"foo": None}}),
(models.ModelDictUnion, False, {"foo": "one"}),
(models.ModelDictUnion, False, {"foo": 1}),
(models.ModelDictUnion, False, {"foo": 1.1}),
(models.ModelDictUnion, False, {"foo": None}),
(models.ModelOptionalDictUnion, True, {"foo": {"foo": 1}}),
(models.ModelOptionalDictUnion, True, {"foo": {"foo": 1.1}}),
(models.ModelOptionalDictUnion, True, {"foo": None}),
(models.ModelOptionalDictUnion, False, {"foo": {"foo": "one"}}),
(models.ModelOptionalDictUnion, False, {"foo": {"foo": None}}),
(models.ModelOptionalDictUnion, False, {"foo": "one"}),
(models.ModelOptionalDictUnion, False, {"foo": 1}),
(models.ModelOptionalDictUnion, False, {"foo": 1.1}),
(models.ModelSingleLiteral, True, {"foo": True}),
(models.ModelSingleLiteral, False, {"foo": False}),
(models.ModelSingleLiteral, False, {"foo": "True"}),
(models.ModelSingleLiteral, False, {"foo": None}),
(models.ModelOptionalSingleLiteral, True, {"foo": True}),
(models.ModelOptionalSingleLiteral, True, {"foo": None}),
(models.ModelOptionalSingleLiteral, False, {"foo": False}),
(models.ModelOptionalSingleLiteral, False, {"foo": "True"}),
(models.ModelOptionalMultipleLiteral, True, {"foo": True}),
(models.ModelOptionalMultipleLiteral, True, {"foo": 1}),
(models.ModelOptionalMultipleLiteral, True, {"foo": "y"}),
(models.ModelOptionalMultipleLiteral, True, {"foo": "Y"}),
(models.ModelOptionalMultipleLiteral, True, {"foo": None}),
(models.ModelOptionalMultipleLiteral, False, {"foo": "n"}),
(models.ModelOptionalMultipleLiteral, False, {"foo": False}),
(models.ModelListStrWithDefaultFactory, True, {}),
(models.ModelListStrWithDefaultFactory, True, {"foo": ["bar"]}),
(models.ModelListStrWithDefaultFactory, True, {"foo": []}),
(models.ModelListStrWithDefaultFactory, False, {"foo": [1]}),
(models.ModelListStrWithDefaultFactory, False, {"foo": None}),
),
)
def test_modeling(model, okay, data):
schema = make_schema({}, model)
if okay:
check_data(model, data, schema)
else:
with pytest.raises(TypeError):
check_data(model, data, schema)
@pytest.mark.skipif(
sys.version_info < (3, 10), reason="UnionType added in 3.10"
)
def test_modeling_union_type_ModelUnionTypeStrNone():
schema = make_schema({}, models.ModelUnionTypeStrNone)
check_data(models.ModelUnionTypeStrNone, {"foo": "bar"}, schema)
check_data(models.ModelUnionTypeStrNone, {"foo": None}, schema)
with pytest.raises(TypeError):
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_json(app):
@dataclass
class Pet:
name: str
alter_ego: list[str]
@app.post("/function")
@validate(json=Pet)
async def handler(_, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
}
)
class MethodView(HTTPMethodView, attach=app, uri="/method"):
decorators = [validate(json=Pet)]
async def post(self, _, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
}
)
_, response = app.test_client.post("/function", json=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
_, response = app.test_client.post("/method", json=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
def test_validate_form(app):
@dataclass
class Pet:
name: str
alter_ego: list[str]
description: Optional[str] = None
@app.post("/function")
@validate(form=Pet)
async def handler(_, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
"description": body.description if body.description else "",
}
)
class MethodView(HTTPMethodView, attach=app, uri="/method"):
decorators = [validate(form=Pet)]
async def post(self, _, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
"description": body.description
if body.description
else "",
}
)
_, response = app.test_client.post("/function", data=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
assert response.json["description"] == ""
_, response = app.test_client.post("/method", data=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
assert response.json["description"] == ""
def test_validate_query(app):
@dataclass
class Search:
q: str
@app.get("/function")
@validate(query=Search)
async def handler(_, query: Search):
return json({"q": query.q, "is_search": isinstance(query, Search)})
class MethodView(HTTPMethodView, attach=app, uri="/method"):
decorators = [validate(query=Search)]
async def get(self, _, query: Search):
return json({"q": query.q, "is_search": isinstance(query, Search)})
_, response = app.test_client.get("/function", params={"q": "Snoopy"})
assert response.status == 200
assert response.json["is_search"]
assert response.json["q"] == "Snoopy"
_, response = app.test_client.get("/method", params={"q": "Snoopy"})
assert response.status == 200
assert response.json["is_search"]
assert response.json["q"] == "Snoopy"
@@ -0,0 +1,394 @@
import sys
from typing import Optional
import pytest
from msgspec import Struct
from sanic import json
from sanic.views import HTTPMethodView
from sanic_ext import validate
from sanic_ext.extras.validation.check import check_data
from sanic_ext.extras.validation.schema import make_schema, parse_hint
from . import __models__ as models
SNOOPY_DATA = {"name": "Snoopy", "alter_ego": ["Flying Ace", "Joe Cool"]}
def test_schema():
class Pet(Struct):
name: str
class Person(Struct):
name: str
age: int
pets: Optional[list[Pet]]
schema = make_schema({}, Person)
assert "Person" in schema
assert schema["Person"]["hints"]["name"] == parse_hint(str)
assert schema["Person"]["hints"]["age"] == parse_hint(int)
assert schema["Person"]["hints"]["pets"] == parse_hint(Optional[list[Pet]])
assert "Pet" in schema
assert schema["Pet"]["hints"]["name"] == parse_hint(str)
def test_should_hydrate():
class Pet(Struct):
name: str
class Person(Struct):
name: str
age: int
pets: list[Pet]
data = {"name": "Charlie Brown", "age": 8, "pets": [{"name": "Snoopy"}]}
schema = make_schema({}, Person)
cb = check_data(Person, data, schema)
assert cb.name == "Charlie Brown"
assert cb.age == 8
assert cb.pets[0].name == "Snoopy"
@pytest.mark.parametrize(
"data",
(
{"name": "Charlie Brown", "age": 8, "pets": {"name": "Snoopy"}},
{"name": "Charlie Brown", "age": 8, "pets": [{"name": 123}]},
{"name": "Charlie Brown", "age": 8, "pets": [123]},
{"name": "Charlie Brown", "age": 8, "pets": 123},
{"name": "Charlie Brown", "age": "8", "pets": {"name": "Snoopy"}},
{"name": True, "age": 8, "pets": {"name": "Snoopy"}},
),
)
def test_should_not_hydrate(data):
class Pet(Struct):
name: str
class Person(Struct):
name: str
age: int
pets: list[Pet]
schema = make_schema({}, Person)
with pytest.raises(TypeError):
check_data(Person, data, schema)
@pytest.mark.parametrize(
"model,okay,data",
(
(models.ModelStr, True, {"foo": "bar"}),
(models.ModelStr, False, {"foo": 1}),
(models.ModelStr, False, {"foo": True}),
(models.ModelStr, False, {"foo": ["bar"]}),
(models.ModelStr, False, {"bar": "bar"}),
(models.ModelStr, False, {"foo": None}),
(models.ModelStr, False, 123),
(models.ModelInt, True, {"foo": 1}),
(models.ModelInt, True, {"foo": True}),
(models.ModelInt, False, {"foo": "1"}),
(models.ModelInt, False, {"foo": 1.1}),
(models.ModelInt, False, {"foo": None}),
(models.ModelFloat, True, {"foo": 1.1}),
(models.ModelFloat, False, {"foo": 1}),
(models.ModelFloat, False, {"foo": "1.1"}),
(models.ModelFloat, False, {"foo": None}),
(models.ModelBool, True, {"foo": True}),
(models.ModelBool, True, {"foo": False}),
(models.ModelBool, False, {"foo": 1}),
(models.ModelBool, False, {"foo": 0}),
(models.ModelBool, False, {"foo": 2}),
(models.ModelBool, False, {"foo": "True"}),
(models.ModelBool, False, {"foo": None}),
(models.ModelOptionalStr, True, {"foo": "bar"}),
(models.ModelOptionalStr, True, {"foo": None}),
(models.ModelOptionalStr, False, {"foo": 0}),
(models.ModelUnion, True, {"foo": 1}),
(models.ModelUnion, True, {"foo": 1.1}),
(models.ModelUnion, False, {"foo": "1.1"}),
(models.ModelUnion, False, {"foo": None}),
(models.ModelUnionModels, True, {"foo": {"foo": 1}}),
(models.ModelUnionModels, True, {"foo": {"foo": 1.1}}),
(models.ModelUnionModels, False, {"foo": {"foo": "1.1"}}),
(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"}),
(models.ModelListStr, False, {"foo": ["one", 2]}),
(models.ModelListStr, False, {"foo": ["one", None]}),
(models.ModelListStr, False, {"foo": None}),
(models.ModelListModel, True, {"foo": [{"foo": "bar"}]}),
(
models.ModelListModel,
True,
{"foo": [{"foo": "one"}, {"foo": "two"}]},
),
(models.ModelListModel, False, {"foo": {"foo": "bar"}}),
(models.ModelListModel, False, {"foo": [{"foo": "bar"}, 2]}),
(models.ModelListModel, False, {"foo": [{"foo": "bar"}, None]}),
(models.ModelListModel, False, {"foo": None}),
(models.ModelOptionalList, True, {"foo": None}),
(models.ModelOptionalList, True, {"foo": ["bar"]}),
(models.ModelOptionalList, False, {"foo": [1]}),
(models.ModelOptionalList, False, {"foo": [None]}),
(models.ModelListUnion, True, {"foo": [1]}),
(models.ModelListUnion, True, {"foo": [1.1]}),
(models.ModelListUnion, True, {"foo": [1, 1.1]}),
(models.ModelListUnion, False, {"foo": [1, 1.1, "one"]}),
(models.ModelListUnion, False, {"foo": [1, 1.1, None]}),
(models.ModelListUnion, False, {"foo": 1}),
(models.ModelListUnion, False, {"foo": 1.1}),
(models.ModelListUnion, False, {"foo": None}),
(models.ModelOptionalListUnion, True, {"foo": [1]}),
(models.ModelOptionalListUnion, True, {"foo": [1.1]}),
(models.ModelOptionalListUnion, True, {"foo": [1, 1.1]}),
(models.ModelOptionalListUnion, True, {"foo": None}),
(models.ModelOptionalListUnion, False, {"foo": [1, 1.1, "one"]}),
(models.ModelOptionalListUnion, False, {"foo": [1, 1.1, None]}),
(models.ModelOptionalListUnion, False, {"foo": 1}),
(models.ModelOptionalListUnion, False, {"foo": 1.1}),
(models.ModelModel, True, {"foo": {"foo": "one"}}),
(models.ModelModel, False, {"foo": {"foo": 1}}),
(models.ModelModel, False, {"foo": {"foo": None}}),
(models.ModelModel, False, {"foo": "one"}),
(models.ModelModel, False, {"foo": None}),
(models.ModelOptionalModel, True, {"foo": {"foo": "one"}}),
(models.ModelOptionalModel, True, {"foo": None}),
(models.ModelOptionalModel, False, {"foo": {"foo": 1}}),
(models.ModelOptionalModel, False, {"foo": {"foo": None}}),
(models.ModelOptionalModel, False, {"foo": "one"}),
(models.ModelDictStr, True, {"foo": {"foo": "one"}}),
(models.ModelDictStr, False, {"foo": {"foo": 1}}),
(models.ModelDictStr, False, {"foo": {"foo": None}}),
(models.ModelDictStr, False, {"foo": "one"}),
(models.ModelDictStr, False, {"foo": None}),
(models.ModelDictModel, True, {"foo": {"foo": {"foo": "one"}}}),
(models.ModelDictModel, False, {"foo": {"foo": {"foo": 1}}}),
(models.ModelDictModel, False, {"foo": {"foo": 1}}),
(models.ModelDictModel, False, {"foo": {"foo": None}}),
(models.ModelDictModel, False, {"foo": "one"}),
(models.ModelDictModel, False, {"foo": None}),
(models.ModelOptionalDict, True, {"foo": {"foo": "one"}}),
(models.ModelOptionalDict, True, {"foo": None}),
(models.ModelOptionalDict, False, {"foo": {"foo": 1}}),
(models.ModelOptionalDict, False, {"foo": {"foo": None}}),
(models.ModelOptionalDict, False, {"foo": "one"}),
(models.ModelDictUnion, True, {"foo": {"foo": 1}}),
(models.ModelDictUnion, True, {"foo": {"foo": 1.1}}),
(models.ModelDictUnion, False, {"foo": {"foo": "one"}}),
(models.ModelDictUnion, False, {"foo": {"foo": None}}),
(models.ModelDictUnion, False, {"foo": "one"}),
(models.ModelDictUnion, False, {"foo": 1}),
(models.ModelDictUnion, False, {"foo": 1.1}),
(models.ModelDictUnion, False, {"foo": None}),
(models.ModelOptionalDictUnion, True, {"foo": {"foo": 1}}),
(models.ModelOptionalDictUnion, True, {"foo": {"foo": 1.1}}),
(models.ModelOptionalDictUnion, True, {"foo": None}),
(models.ModelOptionalDictUnion, False, {"foo": {"foo": "one"}}),
(models.ModelOptionalDictUnion, False, {"foo": {"foo": None}}),
(models.ModelOptionalDictUnion, False, {"foo": "one"}),
(models.ModelOptionalDictUnion, False, {"foo": 1}),
(models.ModelOptionalDictUnion, False, {"foo": 1.1}),
(models.ModelSingleLiteral, True, {"foo": True}),
(models.ModelSingleLiteral, False, {"foo": False}),
(models.ModelSingleLiteral, False, {"foo": "True"}),
(models.ModelSingleLiteral, False, {"foo": None}),
(models.ModelOptionalSingleLiteral, True, {"foo": True}),
(models.ModelOptionalSingleLiteral, True, {"foo": None}),
(models.ModelOptionalSingleLiteral, False, {"foo": False}),
(models.ModelOptionalSingleLiteral, False, {"foo": "True"}),
(models.ModelOptionalMultipleLiteral, True, {"foo": True}),
(models.ModelOptionalMultipleLiteral, True, {"foo": 1}),
(models.ModelOptionalMultipleLiteral, True, {"foo": "y"}),
(models.ModelOptionalMultipleLiteral, True, {"foo": "Y"}),
(models.ModelOptionalMultipleLiteral, True, {"foo": None}),
(models.ModelOptionalMultipleLiteral, False, {"foo": "n"}),
(models.ModelOptionalMultipleLiteral, False, {"foo": False}),
(models.ModelListStrWithDefaultFactory, True, {}),
(models.ModelListStrWithDefaultFactory, True, {"foo": ["bar"]}),
(models.ModelListStrWithDefaultFactory, True, {"foo": []}),
(models.ModelListStrWithDefaultFactory, False, {"foo": [1]}),
(models.ModelListStrWithDefaultFactory, False, {"foo": None}),
),
)
def test_modeling(model, okay, data):
schema = make_schema({}, model)
if okay:
check_data(model, data, schema)
else:
with pytest.raises(TypeError):
check_data(model, data, schema)
@pytest.mark.skipif(
sys.version_info < (3, 10), reason="UnionType added in 3.10"
)
def test_modeling_union_type_ModelUnionTypeStrNone():
schema = make_schema({}, models.ModelUnionTypeStrNone)
check_data(models.ModelUnionTypeStrNone, {"foo": "bar"}, schema)
check_data(models.ModelUnionTypeStrNone, {"foo": None}, schema)
with pytest.raises(TypeError):
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_json(app):
class Pet(Struct):
name: str
alter_ego: list[str]
@app.post("/function")
@validate(json=Pet)
async def handler(_, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
}
)
class MethodView(HTTPMethodView, attach=app, uri="/method"):
decorators = [validate(json=Pet)]
async def post(self, _, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
}
)
_, response = app.test_client.post("/function", json=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
_, response = app.test_client.post("/method", json=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
def test_validate_form(app):
class Pet(Struct):
name: str
alter_ego: list[str]
@app.post("/function")
@validate(form=Pet)
async def handler(_, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
}
)
class MethodView(HTTPMethodView, attach=app, uri="/method"):
decorators = [validate(form=Pet)]
async def post(self, _, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
}
)
_, response = app.test_client.post("/function", data=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
_, response = app.test_client.post("/method", data=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
def test_validate_query(app):
class Search(Struct):
q: str
@app.get("/function")
@validate(query=Search)
async def handler(_, query: Search):
return json({"q": query.q, "is_search": isinstance(query, Search)})
class MethodView(HTTPMethodView, attach=app, uri="/method"):
decorators = [validate(query=Search)]
async def get(self, _, query: Search):
return json({"q": query.q, "is_search": isinstance(query, Search)})
_, response = app.test_client.get("/function", params={"q": "Snoopy"})
assert response.status == 200
assert response.json["is_search"]
assert response.json["q"] == "Snoopy"
_, response = app.test_client.get("/method", params={"q": "Snoopy"})
assert response.status == 200
assert response.json["is_search"]
assert response.json["q"] == "Snoopy"
@@ -0,0 +1,42 @@
from dataclasses import asdict, dataclass
from sanic import Sanic, json
from sanic_ext.extras.validation.decorator import validate
@dataclass
class ModelA:
a: str
@dataclass
class ModelB:
b: str
def test_both_body_and_query(app: Sanic):
@app.post("")
@validate(json=ModelA, query=ModelB)
async def test(_, body: ModelA, query: ModelB):
return json(
{
"body": asdict(body),
"query": asdict(query),
}
)
_, response = app.test_client.post("")
assert response.status == 400
_, response = app.test_client.post("", params={"b": "bbb"})
assert response.status == 400
_, response = app.test_client.post(
"", params={"b": "bbb"}, json={"a": "aaa"}
)
assert response.status == 200
assert response.json == {
"body": {"a": "aaa"},
"query": {"b": "bbb"},
}
@@ -0,0 +1,148 @@
import pydantic
from pydantic.dataclasses import dataclass
from sanic import json
from sanic.views import HTTPMethodView
from sanic_ext import validate
from sanic_ext.exceptions import ValidationError
SNOOPY_DATA = {"name": "Snoopy", "alter_ego": ["Flying Ace", "Joe Cool"]}
def test_validate_json(app):
@dataclass
class Pet:
name: str
alter_ego: list[str]
@app.post("/function")
@validate(json=Pet)
async def handler(_, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
}
)
class MethodView(HTTPMethodView, attach=app, uri="/method"):
decorators = [validate(json=Pet)]
async def post(self, _, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
}
)
_, response = app.test_client.post("/function", json=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
_, response = app.test_client.post("/method", json=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
def test_validate_form(app):
@dataclass
class Pet:
name: str
alter_ego: list[str]
@app.post("/function")
@validate(form=Pet)
async def handler(_, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
}
)
class MethodView(HTTPMethodView, attach=app, uri="/method"):
decorators = [validate(form=Pet)]
async def post(self, _, body: Pet):
return json(
{
"is_pet": isinstance(body, Pet),
"pet": {"name": body.name, "alter_ego": body.alter_ego},
}
)
_, response = app.test_client.post("/function", data=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
_, response = app.test_client.post("/method", data=SNOOPY_DATA)
assert response.status == 200
assert response.json["is_pet"]
assert response.json["pet"] == SNOOPY_DATA
def test_validate_query(app):
@dataclass
class Search:
q: str
@app.get("/function")
@validate(query=Search)
async def handler(_, query: Search):
return json({"q": query.q, "is_search": isinstance(query, Search)})
class MethodView(HTTPMethodView, attach=app, uri="/method"):
decorators = [validate(query=Search)]
async def get(self, _, query: Search):
return json({"q": query.q, "is_search": isinstance(query, Search)})
_, response = app.test_client.get("/function", params={"q": "Snoopy"})
assert response.status == 200
assert response.json["is_search"]
assert response.json["q"] == "Snoopy"
_, response = app.test_client.get("/method", params={"q": "Snoopy"})
assert response.status == 200
assert response.json["is_search"]
assert response.json["q"] == "Snoopy"
class User(pydantic.BaseModel):
name: str
age: int
email: str
def test_success_validate_form_custom_message(app):
@app.post("/user")
@validate(form=User)
async def create_user(request, body: User):
return json(body.dict())
user = {"name": "Alison", "age": 25, "email": "alison@almeida.com"}
_, response = app.test_client.post("/user", data=user)
assert response.status == 200
def test_error_validate_form_custom_message(app):
async def server_error_validate_form(request, exception: ValidationError):
error = exception.extra["exception"]
return json(error, status=400)
@app.post("/user")
@validate(form=User)
async def create_user(request, body: User):
return json(body.dict())
user = {"name": "Alison", "age": 25}
app.error_handler.add(ValidationError, server_error_validate_form)
_, response = app.test_client.post("/user", data=user)
assert response.status == 400