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,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()]