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:
@@ -0,0 +1,93 @@
|
||||
import inspect
|
||||
import warnings
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class OpenAPIDocstringParser:
|
||||
def __init__(self, docstring: str):
|
||||
"""
|
||||
Args:
|
||||
docstring (str): docstring of function to be parsed
|
||||
"""
|
||||
if docstring is None:
|
||||
docstring = ""
|
||||
self.docstring = inspect.cleandoc(docstring)
|
||||
|
||||
def to_openAPI_2(self) -> dict:
|
||||
"""
|
||||
Returns:
|
||||
json style dict: dict to be read for the path by swagger 2.0 UI
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def to_openAPI_3(self) -> dict:
|
||||
"""
|
||||
Returns:
|
||||
json style dict: dict to be read for the path by swagger 3.0.0 UI
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class YamlStyleParametersParser(OpenAPIDocstringParser):
|
||||
def _parse_no_yaml(self, doc: str) -> dict:
|
||||
"""
|
||||
Args:
|
||||
doc (str): section of doc before yaml, or full section of doc
|
||||
Returns:
|
||||
json style dict: dict to be read for the path by swagger UI
|
||||
"""
|
||||
# clean again in case further indentation can be removed,
|
||||
# usually this do nothing...
|
||||
doc = inspect.cleandoc(doc)
|
||||
|
||||
if len(doc) == 0:
|
||||
return {}
|
||||
|
||||
lines = doc.split("\n")
|
||||
|
||||
if len(lines) == 1:
|
||||
return {"summary": lines[0]}
|
||||
else:
|
||||
summary = lines.pop(0)
|
||||
|
||||
# remove empty lines at the beginning of the description
|
||||
while len(lines) and lines[0].strip() == "":
|
||||
lines.pop(0)
|
||||
|
||||
if len(lines) == 0:
|
||||
return {"summary": summary}
|
||||
else:
|
||||
# use html tag to preserve linebreaks
|
||||
return {"summary": summary, "description": "<br>".join(lines)}
|
||||
|
||||
def _parse_yaml(self, doc: str) -> dict:
|
||||
"""
|
||||
Args:
|
||||
doc (str): section of doc detected as openapi yaml
|
||||
Returns:
|
||||
json style dict: dict to be read for the path by swagger UI
|
||||
Warns:
|
||||
UserWarning if the yaml couldn't be parsed
|
||||
"""
|
||||
try:
|
||||
return yaml.safe_load(doc)
|
||||
except Exception as e:
|
||||
warnings.warn(f"error parsing openAPI yaml, ignoring it. ({e})")
|
||||
return {}
|
||||
|
||||
def _parse_all(self) -> dict:
|
||||
if "openapi:\n" not in self.docstring:
|
||||
return self._parse_no_yaml(self.docstring)
|
||||
|
||||
predoc, yamldoc = self.docstring.split("openapi:\n", 1)
|
||||
|
||||
conf = self._parse_no_yaml(predoc)
|
||||
conf.update(self._parse_yaml(yamldoc))
|
||||
return conf
|
||||
|
||||
def to_openAPI_2(self) -> dict:
|
||||
return self._parse_all()
|
||||
|
||||
def to_openAPI_3(self) -> dict:
|
||||
return self._parse_all()
|
||||
@@ -0,0 +1,261 @@
|
||||
import inspect
|
||||
|
||||
from functools import lru_cache, partial
|
||||
from os.path import abspath, dirname, realpath
|
||||
|
||||
from sanic import Request
|
||||
from sanic.blueprints import Blueprint
|
||||
from sanic.config import Config
|
||||
from sanic.log import logger
|
||||
from sanic.response import file, html, json
|
||||
|
||||
from sanic_ext.config import PRIORITY
|
||||
from sanic_ext.extensions.openapi.builders import (
|
||||
OperationStore,
|
||||
SpecificationBuilder,
|
||||
)
|
||||
|
||||
from ...utils.route import (
|
||||
clean_route_name,
|
||||
get_all_routes,
|
||||
get_blueprinted_routes,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_oauth2_redirect_html(version: str):
|
||||
import urllib
|
||||
|
||||
response = urllib.request.urlopen(
|
||||
f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{version}/"
|
||||
"oauth2-redirect.html"
|
||||
).read()
|
||||
|
||||
return response.decode("utf-8")
|
||||
|
||||
|
||||
def oauth2_handler(request: Request, version: str):
|
||||
return html(get_oauth2_redirect_html(version))
|
||||
|
||||
|
||||
def blueprint_factory(config: Config):
|
||||
bp = Blueprint("openapi", url_prefix=config.OAS_URL_PREFIX)
|
||||
|
||||
dir_path = dirname(realpath(__file__))
|
||||
dir_path = abspath(dir_path + "/ui")
|
||||
|
||||
for ui in ("redoc", "swagger"):
|
||||
if getattr(config, f"OAS_UI_{ui}".upper()):
|
||||
path = getattr(config, f"OAS_PATH_TO_{ui}_HTML".upper())
|
||||
uri = getattr(config, f"OAS_URI_TO_{ui}".upper())
|
||||
version = getattr(config, f"OAS_UI_{ui}_VERSION".upper(), "")
|
||||
html_title = getattr(config, f"OAS_UI_{ui}_HTML_TITLE".upper())
|
||||
custom_css = getattr(config, f"OAS_UI_{ui}_CUSTOM_CSS".upper())
|
||||
html_path = path if path else f"{dir_path}/{ui}.html"
|
||||
|
||||
with open(html_path) as f:
|
||||
page = f.read()
|
||||
|
||||
def index(
|
||||
request: Request, page: str, html_title: str, custom_css: str
|
||||
):
|
||||
prefix = (
|
||||
request.app.url_for("openapi.index", _external=True)
|
||||
if getattr(request.app.config, "SERVER_NAME", None)
|
||||
else getattr(request.app.config, "OAS_URL_PREFIX")
|
||||
).rstrip("/")
|
||||
return html(
|
||||
page.replace("__VERSION__", version)
|
||||
.replace("__URL_PREFIX__", prefix)
|
||||
.replace("__HTML_TITLE__", html_title)
|
||||
.replace("__HTML_CUSTOM_CSS__", custom_css)
|
||||
)
|
||||
|
||||
bp.add_route(
|
||||
partial(
|
||||
index,
|
||||
page=page,
|
||||
html_title=html_title,
|
||||
custom_css=custom_css,
|
||||
),
|
||||
uri,
|
||||
name=ui,
|
||||
)
|
||||
if config.OAS_UI_DEFAULT and config.OAS_UI_DEFAULT == ui:
|
||||
bp.add_route(
|
||||
partial(
|
||||
index,
|
||||
page=page,
|
||||
html_title=html_title,
|
||||
custom_css=custom_css,
|
||||
),
|
||||
"",
|
||||
name="index",
|
||||
)
|
||||
|
||||
if ui == "swagger":
|
||||
oauth2_redirect_uri = getattr(
|
||||
config, "OAS_UI_SWAGGER_OAUTH2_REDIRECT"
|
||||
)
|
||||
|
||||
bp.add_route(
|
||||
partial(oauth2_handler, version=version),
|
||||
oauth2_redirect_uri,
|
||||
name="oauth2-redirect",
|
||||
)
|
||||
|
||||
@bp.get(config.OAS_URI_TO_JSON)
|
||||
async def spec(request: Request):
|
||||
if config.OAS_CUSTOM_FILE:
|
||||
return await file(config.OAS_CUSTOM_FILE)
|
||||
return json(SpecificationBuilder().build(request.app).serialize())
|
||||
|
||||
if config.OAS_UI_SWAGGER:
|
||||
|
||||
@bp.get(config.OAS_URI_TO_CONFIG)
|
||||
def openapi_config(request: Request):
|
||||
return json(request.app.config.SWAGGER_UI_CONFIGURATION)
|
||||
|
||||
@bp.before_server_start(priority=PRIORITY)
|
||||
def build_spec(app, loop):
|
||||
specification = SpecificationBuilder()
|
||||
# --------------------------------------------------------------- #
|
||||
# Blueprint Tags
|
||||
# --------------------------------------------------------------- #
|
||||
|
||||
for blueprint_name, handler in get_blueprinted_routes(app):
|
||||
operation = OperationStore()[handler]
|
||||
if not operation.tags:
|
||||
operation.tag(blueprint_name)
|
||||
|
||||
# --------------------------------------------------------------- #
|
||||
# Operations
|
||||
# --------------------------------------------------------------- #
|
||||
for (
|
||||
uri,
|
||||
route_name,
|
||||
route_parameters,
|
||||
method_handlers,
|
||||
host,
|
||||
) in get_all_routes(app, bp.url_prefix):
|
||||
# --------------------------------------------------------------- #
|
||||
# Methods
|
||||
# --------------------------------------------------------------- #
|
||||
|
||||
for method, _handler in method_handlers:
|
||||
if (
|
||||
(method == "OPTIONS" and app.config.OAS_IGNORE_OPTIONS)
|
||||
or (method == "HEAD" and app.config.OAS_IGNORE_HEAD)
|
||||
or method == "TRACE"
|
||||
):
|
||||
continue
|
||||
|
||||
if hasattr(_handler, "view_class"):
|
||||
_handler = getattr(_handler.view_class, method.lower())
|
||||
store = OperationStore()
|
||||
if (
|
||||
_handler not in store
|
||||
and (func := getattr(_handler, "__func__", None))
|
||||
and func in store
|
||||
):
|
||||
_handler = func
|
||||
operation = store[_handler]
|
||||
|
||||
if operation._exclude or "openapi" in operation.tags:
|
||||
continue
|
||||
|
||||
docstring = inspect.getdoc(_handler)
|
||||
|
||||
if (
|
||||
docstring
|
||||
and app.config.OAS_AUTODOC
|
||||
and operation._allow_autodoc
|
||||
):
|
||||
operation.autodoc(docstring)
|
||||
|
||||
operation._default["operationId"] = (
|
||||
f"{method.lower()}~{route_name}"
|
||||
)
|
||||
operation._default["summary"] = clean_route_name(route_name)
|
||||
|
||||
if host:
|
||||
if "servers" not in operation._default:
|
||||
operation._default["servers"] = []
|
||||
operation._default["servers"].append({"url": f"//{host}"})
|
||||
|
||||
for _parameter in route_parameters:
|
||||
if any(
|
||||
param.fields["name"] == _parameter.name
|
||||
for param in operation.parameters
|
||||
):
|
||||
continue
|
||||
|
||||
kwargs = {}
|
||||
if operation._autodoc and (
|
||||
parameters := operation._autodoc.get("parameters")
|
||||
):
|
||||
for param in parameters:
|
||||
if param.pop("name", None) == _parameter.name:
|
||||
kwargs["description"] = param.get(
|
||||
"description"
|
||||
)
|
||||
kwargs["required"] = param.get("required")
|
||||
if schema := param.get("schema"):
|
||||
logger.warning(
|
||||
f"Ignoring the schema {schema} in "
|
||||
f"'{route_name}' for "
|
||||
f"'{_parameter.name}'. "
|
||||
"Instead of using the definition in "
|
||||
"docstring definition, Sanic will use "
|
||||
"the actual schema defined for this "
|
||||
"parameter on the route."
|
||||
)
|
||||
break
|
||||
|
||||
operation.parameter(
|
||||
_parameter.name, _parameter.cast, "path", **kwargs
|
||||
)
|
||||
|
||||
operation._app = app
|
||||
specification.operation(uri, method, operation)
|
||||
|
||||
add_static_info_to_spec_from_config(app, specification)
|
||||
|
||||
return bp
|
||||
|
||||
|
||||
def add_static_info_to_spec_from_config(app, specification):
|
||||
"""
|
||||
Reads app.config and sets attributes to specification according to the
|
||||
desired values.
|
||||
|
||||
Modifies specification in-place and returns None
|
||||
"""
|
||||
specification._do_describe(
|
||||
getattr(app.config, "API_TITLE", "API"),
|
||||
getattr(app.config, "API_VERSION", "1.0.0"),
|
||||
getattr(app.config, "API_DESCRIPTION", None),
|
||||
getattr(app.config, "API_TERMS_OF_SERVICE", None),
|
||||
)
|
||||
|
||||
specification._do_license(
|
||||
getattr(app.config, "API_LICENSE_NAME", None),
|
||||
getattr(app.config, "API_LICENSE_URL", None),
|
||||
)
|
||||
|
||||
specification._do_contact(
|
||||
getattr(app.config, "API_CONTACT_NAME", None),
|
||||
getattr(app.config, "API_CONTACT_URL", None),
|
||||
getattr(app.config, "API_CONTACT_EMAIL", None),
|
||||
)
|
||||
|
||||
schemes = getattr(app.config, "API_SCHEMES", ["http"]) or ["http"]
|
||||
if isinstance(schemes, str):
|
||||
schemes = [s.strip() for s in schemes.split(",")]
|
||||
for scheme in schemes:
|
||||
host = getattr(app.config, "API_HOST", None)
|
||||
basePath = getattr(app.config, "API_BASEPATH", "")
|
||||
if host is None or basePath is None:
|
||||
continue
|
||||
|
||||
specification.url(f"{scheme}://{host}/{basePath}")
|
||||
@@ -0,0 +1,446 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Optional, Union, cast
|
||||
|
||||
from sanic_ext.extensions.openapi.constants import (
|
||||
SecuritySchemeAuthorization,
|
||||
SecuritySchemeLocation,
|
||||
SecuritySchemeType,
|
||||
)
|
||||
|
||||
from ...utils.route import remove_nulls, remove_nulls_from_kwargs
|
||||
from .autodoc import YamlStyleParametersParser
|
||||
from .definitions import (
|
||||
Any,
|
||||
Components,
|
||||
Contact,
|
||||
ExternalDocumentation,
|
||||
Flows,
|
||||
Info,
|
||||
License,
|
||||
OpenAPI,
|
||||
Operation,
|
||||
Parameter,
|
||||
PathItem,
|
||||
RequestBody,
|
||||
Response,
|
||||
SecurityRequirement,
|
||||
SecurityScheme,
|
||||
Server,
|
||||
Tag,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic import Sanic
|
||||
|
||||
|
||||
class OperationBuilder:
|
||||
summary: str
|
||||
description: str
|
||||
operationId: str
|
||||
requestBody: RequestBody
|
||||
externalDocs: ExternalDocumentation
|
||||
tags: list[str]
|
||||
security: list[Any]
|
||||
parameters: list[Parameter]
|
||||
responses: dict[str, Response]
|
||||
callbacks: list[str] # TODO
|
||||
deprecated: bool = False
|
||||
|
||||
def __init__(self):
|
||||
self.tags = []
|
||||
self.security = []
|
||||
self.parameters = []
|
||||
self.responses = {}
|
||||
self._default = {}
|
||||
self._autodoc = None
|
||||
self._exclude = False
|
||||
self._allow_autodoc = True
|
||||
self._app: Optional[Sanic] = None
|
||||
|
||||
def name(self, value: str):
|
||||
self.operationId = value
|
||||
|
||||
def describe(self, summary: str = None, description: str = None):
|
||||
if summary:
|
||||
self.summary = summary
|
||||
|
||||
if description:
|
||||
self.description = description
|
||||
|
||||
def document(self, url: str, description: str = None):
|
||||
self.externalDocs = ExternalDocumentation.make(url, description)
|
||||
|
||||
def tag(self, *args: str):
|
||||
for arg in args:
|
||||
if isinstance(arg, Tag):
|
||||
arg = arg.fields["name"]
|
||||
self.tags.append(arg)
|
||||
|
||||
def deprecate(self):
|
||||
self.deprecated = True
|
||||
|
||||
def body(self, content: Any, **kwargs):
|
||||
self.requestBody = RequestBody.make(content, **kwargs)
|
||||
|
||||
def parameter(
|
||||
self, name: str, schema: Any, location: str = "query", **kwargs
|
||||
):
|
||||
self.parameters.append(
|
||||
Parameter.make(name, schema, location, **kwargs)
|
||||
)
|
||||
|
||||
def response(
|
||||
self, status, content: Any = None, description: str = None, **kwargs
|
||||
):
|
||||
response = Response.make(content, description, **kwargs)
|
||||
if status in self.responses:
|
||||
self.responses[status]._fields["content"].update(
|
||||
response.fields["content"]
|
||||
)
|
||||
else:
|
||||
self.responses[status] = response
|
||||
|
||||
def secured(self, *args, **kwargs):
|
||||
if not kwargs and len(args) == 1 and isinstance(args[0], dict):
|
||||
items = args[0]
|
||||
else:
|
||||
items = {**{v: [] for v in args}, **kwargs}
|
||||
gates = {}
|
||||
|
||||
for name, params in items.items():
|
||||
gate = name.__name__ if isinstance(name, type) else name
|
||||
gates[gate] = params
|
||||
|
||||
self.security.append(gates)
|
||||
|
||||
def disable_autodoc(self):
|
||||
self._allow_autodoc = False
|
||||
|
||||
def build(self):
|
||||
operation_dict = self._build_merged_dict()
|
||||
if "responses" not in operation_dict:
|
||||
# todo -- look into more consistent default response format
|
||||
operation_dict["responses"] = {"default": {"description": "OK"}}
|
||||
|
||||
return Operation(**operation_dict)
|
||||
|
||||
def _build_merged_dict(self):
|
||||
defined_dict = self.__dict__.copy()
|
||||
autodoc_dict = self._autodoc or {}
|
||||
default_dict = self._default
|
||||
merged_dict = {}
|
||||
|
||||
for d in (default_dict, autodoc_dict, defined_dict):
|
||||
cleaned = {
|
||||
k: v for k, v in d.items() if v and not k.startswith("_")
|
||||
}
|
||||
merged_dict.update(cleaned)
|
||||
|
||||
return merged_dict
|
||||
|
||||
def autodoc(self, docstring: str):
|
||||
y = YamlStyleParametersParser(docstring)
|
||||
self._autodoc = y.to_openAPI_3()
|
||||
|
||||
def exclude(self, flag: bool = True):
|
||||
self._exclude = flag
|
||||
|
||||
|
||||
class OperationStore(defaultdict):
|
||||
_singleton = None
|
||||
|
||||
def __new__(cls) -> Any:
|
||||
if not cls._singleton:
|
||||
cls._singleton = super().__new__(cls)
|
||||
return cls._singleton
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(OperationBuilder)
|
||||
|
||||
@classmethod
|
||||
def reset(cls):
|
||||
cls._singleton = None
|
||||
|
||||
|
||||
class SpecificationBuilder:
|
||||
_urls: list[str]
|
||||
_title: str
|
||||
_version: str
|
||||
_description: Optional[str]
|
||||
_terms: Optional[str]
|
||||
_contact: Contact
|
||||
_license: License
|
||||
_paths: dict[str, dict[str, OperationBuilder]]
|
||||
_tags: dict[str, Tag]
|
||||
_security: list[SecurityRequirement]
|
||||
_components: dict[str, Any]
|
||||
_servers: list[Server]
|
||||
# _components: ComponentsBuilder
|
||||
# deliberately not included
|
||||
_singleton: Optional[SpecificationBuilder] = None
|
||||
|
||||
def __new__(cls) -> SpecificationBuilder:
|
||||
if not cls._singleton:
|
||||
cls._singleton = super().__new__(cls)
|
||||
cls._setup_instance(cls._singleton)
|
||||
return cast(SpecificationBuilder, cls._singleton)
|
||||
|
||||
@classmethod
|
||||
def _setup_instance(cls, instance):
|
||||
instance._components = defaultdict(dict)
|
||||
instance._contact = None
|
||||
instance._description = None
|
||||
instance._external = None
|
||||
instance._license = None
|
||||
instance._paths = defaultdict(dict)
|
||||
instance._servers = []
|
||||
instance._tags = {}
|
||||
instance._security = []
|
||||
instance._terms = None
|
||||
instance._title = None
|
||||
instance._urls = []
|
||||
instance._version = None
|
||||
|
||||
@classmethod
|
||||
def reset(cls):
|
||||
cls._singleton = None
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
return self._tags
|
||||
|
||||
@property
|
||||
def security(self):
|
||||
return self._security
|
||||
|
||||
def url(self, value: str):
|
||||
self._urls.append(value)
|
||||
|
||||
def describe(
|
||||
self,
|
||||
title: str,
|
||||
version: str,
|
||||
description: Optional[str] = None,
|
||||
terms: Optional[str] = None,
|
||||
):
|
||||
self._title = title
|
||||
self._version = version
|
||||
self._description = description
|
||||
self._terms = terms
|
||||
|
||||
def _do_describe(
|
||||
self,
|
||||
title: str,
|
||||
version: str,
|
||||
description: Optional[str] = None,
|
||||
terms: Optional[str] = None,
|
||||
):
|
||||
if any([self._title, self._version, self._description, self._terms]):
|
||||
return
|
||||
self.describe(title, version, description, terms)
|
||||
|
||||
def tag(self, name: str, description: Optional[str] = None, **kwargs):
|
||||
self._tags[name] = Tag(name, description=description, **kwargs)
|
||||
|
||||
def external(self, url: str, description: Optional[str] = None, **kwargs):
|
||||
self._external = ExternalDocumentation(url, description=description)
|
||||
|
||||
def secured(
|
||||
self,
|
||||
name: str = None,
|
||||
value: Optional[Union[str, Sequence[str]]] = None,
|
||||
):
|
||||
if value is None:
|
||||
value = []
|
||||
elif isinstance(value, str):
|
||||
value = [value]
|
||||
else:
|
||||
value = list(value)
|
||||
self._security.append(SecurityRequirement(name=name, value=value))
|
||||
|
||||
def contact(self, name: str = None, url: str = None, email: str = None):
|
||||
kwargs = remove_nulls_from_kwargs(name=name, url=url, email=email)
|
||||
self._contact = Contact(**kwargs)
|
||||
|
||||
def _do_contact(
|
||||
self, name: str = None, url: str = None, email: str = None
|
||||
):
|
||||
if self._contact:
|
||||
return
|
||||
|
||||
self.contact(name, url, email)
|
||||
|
||||
def license(self, name: str = None, url: str = None):
|
||||
if name is not None:
|
||||
self._license = License(name, url=url)
|
||||
|
||||
def _do_license(self, name: str = None, url: str = None):
|
||||
if self._license:
|
||||
return
|
||||
|
||||
self.license(name, url)
|
||||
|
||||
def operation(self, path: str, method: str, operation: OperationBuilder):
|
||||
for _tag in operation.tags:
|
||||
if _tag in self._tags.keys():
|
||||
continue
|
||||
|
||||
self._tags[_tag] = Tag(_tag)
|
||||
|
||||
self._paths[path][method.lower()] = operation
|
||||
|
||||
def add_component(self, location: str, name: str, obj: Any):
|
||||
self._components[location].update({name: obj})
|
||||
|
||||
def has_component(self, location: str, name: str) -> bool:
|
||||
return name in self._components.get(location, {})
|
||||
|
||||
def add_security_scheme(
|
||||
self,
|
||||
ident: str,
|
||||
type: Union[str, SecuritySchemeType],
|
||||
*,
|
||||
bearer_format: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
flows: Optional[Union[Flows, dict[str, Any]]] = None,
|
||||
location: Union[
|
||||
str, SecuritySchemeLocation
|
||||
] = SecuritySchemeLocation.HEADER,
|
||||
name: str = "authorization",
|
||||
openid_connect_url: Optional[str] = None,
|
||||
scheme: Union[
|
||||
str, SecuritySchemeAuthorization
|
||||
] = SecuritySchemeAuthorization.BEARER,
|
||||
):
|
||||
if isinstance(type, str):
|
||||
type = SecuritySchemeType(type)
|
||||
if isinstance(location, str):
|
||||
location = SecuritySchemeLocation(location)
|
||||
|
||||
kwargs: dict[str, Any] = {"type": type, "description": description}
|
||||
|
||||
if type is SecuritySchemeType.API_KEY:
|
||||
kwargs["location"] = location
|
||||
kwargs["name"] = name
|
||||
elif type is SecuritySchemeType.HTTP:
|
||||
kwargs["scheme"] = scheme
|
||||
kwargs["bearerFormat"] = bearer_format
|
||||
elif type is SecuritySchemeType.OAUTH2:
|
||||
kwargs["flows"] = flows
|
||||
elif type is SecuritySchemeType.OPEN_ID_CONNECT:
|
||||
kwargs["openIdConnectUrl"] = openid_connect_url
|
||||
|
||||
self.add_component(
|
||||
"securitySchemes",
|
||||
ident,
|
||||
SecurityScheme(**kwargs),
|
||||
) # type: ignore
|
||||
|
||||
def raw(self, data):
|
||||
if "info" in data:
|
||||
self.describe(
|
||||
data["info"].get("title"),
|
||||
data["info"].get("version"),
|
||||
data["info"].get("description"),
|
||||
data["info"].get("terms"),
|
||||
)
|
||||
|
||||
if "servers" in data:
|
||||
for server in data["servers"]:
|
||||
self._servers.append(Server(**server))
|
||||
|
||||
if "paths" in data:
|
||||
self._paths.update(data["paths"])
|
||||
|
||||
if "components" in data:
|
||||
for location, component in data["components"].items():
|
||||
self._components[location].update(component)
|
||||
|
||||
if "security" in data:
|
||||
for security in data["security"]:
|
||||
if not security:
|
||||
self.secured()
|
||||
else:
|
||||
for key, value in security.items():
|
||||
self.secured(key, value)
|
||||
|
||||
if "tags" in data:
|
||||
for tag in data["tags"]:
|
||||
self.tag(**tag)
|
||||
|
||||
if "externalDocs" in data:
|
||||
self.external(**data["externalDocs"])
|
||||
|
||||
def build(self, app: Sanic) -> OpenAPI:
|
||||
info = self._build_info()
|
||||
paths = self._build_paths(app)
|
||||
tags = self._build_tags()
|
||||
security = self._build_security()
|
||||
|
||||
url_servers = getattr(self, "_urls", None)
|
||||
servers = self._servers
|
||||
existing = [
|
||||
server.fields["url"].strip("/") for server in self._servers
|
||||
]
|
||||
if url_servers is not None:
|
||||
for url_server in url_servers:
|
||||
if url_server.strip("/") not in existing:
|
||||
servers.append(Server(url=url_server))
|
||||
|
||||
components = (
|
||||
Components(**self._components) if self._components else None
|
||||
)
|
||||
|
||||
return OpenAPI(
|
||||
info,
|
||||
paths,
|
||||
tags=tags,
|
||||
servers=servers,
|
||||
security=security,
|
||||
components=components,
|
||||
externalDocs=self._external,
|
||||
)
|
||||
|
||||
def _build_info(self) -> Info:
|
||||
kwargs = remove_nulls(
|
||||
{
|
||||
"description": self._description,
|
||||
"termsOfService": self._terms,
|
||||
"license": self._license,
|
||||
"contact": self._contact,
|
||||
},
|
||||
deep=False,
|
||||
)
|
||||
|
||||
return Info(self._title, self._version, **kwargs)
|
||||
|
||||
def _build_tags(self):
|
||||
return [self._tags[k] for k in self._tags]
|
||||
|
||||
def _build_paths(self, app: Sanic) -> dict:
|
||||
paths = {}
|
||||
|
||||
for path, operations in self._paths.items():
|
||||
paths[path] = PathItem(
|
||||
**{
|
||||
k: v if isinstance(v, dict) else v.build()
|
||||
for k, v in operations.items()
|
||||
if isinstance(v, dict) or v._app is app
|
||||
}
|
||||
)
|
||||
|
||||
return paths
|
||||
|
||||
def _build_security(self):
|
||||
return [
|
||||
(
|
||||
{sec.fields["name"]: sec.fields["value"]}
|
||||
if sec.fields["name"] is not None
|
||||
else {}
|
||||
)
|
||||
for sec in self.security
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class BaseEnum(Enum):
|
||||
def _generate_next_value_(name, start, count, last_values):
|
||||
parts = name.split("_")
|
||||
return parts[0].lower() + "".join(part.title() for part in parts[1:])
|
||||
|
||||
|
||||
class SecuritySchemeType(BaseEnum):
|
||||
API_KEY = auto()
|
||||
HTTP = auto()
|
||||
OAUTH2 = auto()
|
||||
OPEN_ID_CONNECT = auto()
|
||||
|
||||
|
||||
class SecuritySchemeLocation(BaseEnum):
|
||||
QUERY = auto()
|
||||
HEADER = auto()
|
||||
COOKIE = auto()
|
||||
|
||||
|
||||
class SecuritySchemeAuthorization(BaseEnum):
|
||||
def _generate_next_value_(name, start, count, last_values):
|
||||
return name.title()
|
||||
|
||||
BASIC = auto()
|
||||
BEARER = auto()
|
||||
DIGEST = auto()
|
||||
HOBA = "HOBA"
|
||||
MUTUAL = auto()
|
||||
NEGOTIATE = auto()
|
||||
OAUTH = "OAuth"
|
||||
SCRAM_SHA_1 = "SCRAM-SHA-1"
|
||||
SCRAM_SHA_256 = "SCRAM-SHA-256"
|
||||
VAPID = "vapid"
|
||||
@@ -0,0 +1,455 @@
|
||||
"""
|
||||
Classes defined from the OpenAPI 3.0 specifications.
|
||||
|
||||
I.e., the objects described https://swagger.io/docs/specification
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from inspect import isclass
|
||||
from typing import (
|
||||
Any,
|
||||
Literal,
|
||||
Optional,
|
||||
Union,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from sanic.exceptions import SanicException
|
||||
|
||||
from sanic_ext.utils.typing import (
|
||||
contains_annotations,
|
||||
is_msgspec,
|
||||
is_pydantic,
|
||||
)
|
||||
|
||||
from .types import Definition, Schema
|
||||
|
||||
|
||||
class Reference(Schema):
|
||||
def __init__(self, value):
|
||||
super().__init__(**{"$ref": value})
|
||||
|
||||
def guard(self, fields: dict[str, Any]):
|
||||
return fields
|
||||
|
||||
|
||||
class Contact(Definition):
|
||||
name: str
|
||||
url: str
|
||||
email: str
|
||||
|
||||
|
||||
class License(Definition):
|
||||
name: str
|
||||
url: str
|
||||
|
||||
def __init__(self, name: str, **kwargs):
|
||||
super().__init__(name=name, **kwargs)
|
||||
|
||||
|
||||
class Info(Definition):
|
||||
title: str
|
||||
description: str
|
||||
termsOfService: str
|
||||
contact: Contact
|
||||
license: License
|
||||
version: str
|
||||
|
||||
def __init__(self, title: str, version: str, **kwargs):
|
||||
super().__init__(title=title, version=version, **kwargs)
|
||||
|
||||
|
||||
class Example(Definition):
|
||||
summary: str
|
||||
description: str
|
||||
value: Any
|
||||
externalValue: str
|
||||
|
||||
def __init__(self, value: Any = None, **kwargs):
|
||||
super().__init__(value=value, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def make(value: Any, **kwargs):
|
||||
return Example(Schema.make(value), **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def external(value: Any, **kwargs):
|
||||
return Example(externalValue=value, **kwargs)
|
||||
|
||||
|
||||
class MediaType(Definition):
|
||||
schema: Schema
|
||||
example: Any
|
||||
|
||||
def __init__(self, schema: Union[Schema, dict[str, Any]], **kwargs):
|
||||
if isinstance(schema, dict) and contains_annotations(schema):
|
||||
schema = Schema.make(schema)
|
||||
super().__init__(schema=schema, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def make(value: Any):
|
||||
if isinstance(value, dict):
|
||||
kwargs = {}
|
||||
if "schema" in value:
|
||||
kwargs = {**value}
|
||||
value = kwargs.pop("schema")
|
||||
return MediaType(value, **kwargs)
|
||||
# See https://github.com/sanic-org/sanic-ext/issues/152
|
||||
# The following lines will automatically inject pydantic models as
|
||||
# components if that feature is desired. Until that decision is made
|
||||
# this commented out code will remain.
|
||||
# elif isclass(value) and is_pydantic(value):
|
||||
# return MediaType(Component(value))
|
||||
return MediaType(Schema.make(value))
|
||||
|
||||
@staticmethod
|
||||
def all(content: Any):
|
||||
media_types = (
|
||||
content if isinstance(content, dict) else {"*/*": content or {}}
|
||||
)
|
||||
|
||||
return {x: MediaType.make(v) for x, v in media_types.items()}
|
||||
|
||||
|
||||
class Response(Definition):
|
||||
content: Union[Any, dict[str, Union[Any, MediaType]]]
|
||||
description: Optional[str]
|
||||
status: Union[Literal["default"], int]
|
||||
|
||||
__nullable__ = None
|
||||
__ignore__ = ["status"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: Optional[Union[Any, dict[str, Union[Any, MediaType]]]] = None,
|
||||
status: Union[Literal["default"], int] = "default",
|
||||
description: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
content=content,
|
||||
status=status,
|
||||
description=description,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def make(content, description: Optional[str] = None, **kwargs):
|
||||
if not description:
|
||||
description = "Default Response"
|
||||
|
||||
return Response(
|
||||
MediaType.all(content), description=description, **kwargs
|
||||
)
|
||||
|
||||
|
||||
class RequestBody(Definition):
|
||||
description: Optional[str]
|
||||
required: Optional[bool]
|
||||
content: Union[Any, dict[str, Union[Any, MediaType]]]
|
||||
|
||||
__nullable__ = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: Union[Any, dict[str, Union[Any, MediaType]]],
|
||||
required: Optional[bool] = None,
|
||||
description: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Can be initialized with content in one of a few ways:
|
||||
|
||||
RequestBody(SomeModel)
|
||||
RequestBody({"application/json": SomeModel})
|
||||
RequestBody({"application/json": {"name": str}})
|
||||
"""
|
||||
super().__init__(
|
||||
content=content,
|
||||
required=required,
|
||||
description=description,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def make(content: Any, **kwargs):
|
||||
return RequestBody(MediaType.all(content), **kwargs)
|
||||
|
||||
|
||||
class ExternalDocumentation(Definition):
|
||||
url: str
|
||||
description: str
|
||||
|
||||
__nullable__ = None
|
||||
|
||||
def __init__(self, url: str, description=None):
|
||||
super().__init__(url=url, description=description)
|
||||
|
||||
@staticmethod
|
||||
def make(url: str, description: Optional[str] = None):
|
||||
return ExternalDocumentation(url, description)
|
||||
|
||||
|
||||
class Header(Definition):
|
||||
name: str
|
||||
description: str
|
||||
externalDocs: ExternalDocumentation
|
||||
|
||||
def __init__(self, url: str, description=None):
|
||||
super().__init__(url=url, description=description)
|
||||
|
||||
@staticmethod
|
||||
def make(url: str, description: Optional[str] = None):
|
||||
return Header(url, description)
|
||||
|
||||
|
||||
class Parameter(Definition):
|
||||
name: str
|
||||
schema: Union[type, Schema]
|
||||
location: str
|
||||
description: Optional[str]
|
||||
required: Optional[bool]
|
||||
deprecated: Optional[bool]
|
||||
allowEmptyValue: Optional[bool]
|
||||
|
||||
__nullable__ = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
schema: Union[type, Schema] = str,
|
||||
location: str = "query",
|
||||
description: Optional[str] = None,
|
||||
required: Optional[bool] = None,
|
||||
deprecated: Optional[bool] = None,
|
||||
allowEmptyValue: Optional[bool] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
name=name,
|
||||
schema=schema,
|
||||
location=location,
|
||||
description=description,
|
||||
required=required,
|
||||
deprecated=deprecated,
|
||||
allowEmptyValue=allowEmptyValue,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@property
|
||||
def fields(self):
|
||||
values = super().fields
|
||||
|
||||
if "location" in values:
|
||||
values["in"] = values.pop("location")
|
||||
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def make(name: str, schema: type, location: str, **kwargs):
|
||||
if location == "path" and "required" not in kwargs:
|
||||
kwargs["required"] = True
|
||||
|
||||
return Parameter(name, Schema.make(schema), location, **kwargs)
|
||||
|
||||
|
||||
class Operation(Definition):
|
||||
tags: list[str]
|
||||
summary: str
|
||||
description: str
|
||||
operationId: str
|
||||
requestBody: RequestBody
|
||||
externalDocs: ExternalDocumentation
|
||||
parameters: list[Parameter]
|
||||
responses: dict[str, Response]
|
||||
security: dict[str, list[str]]
|
||||
callbacks: list[str] # TODO
|
||||
deprecated: bool
|
||||
servers: list[dict[str, str]]
|
||||
|
||||
|
||||
class PathItem(Definition):
|
||||
summary: str
|
||||
description: str
|
||||
get: Operation
|
||||
put: Operation
|
||||
post: Operation
|
||||
delete: Operation
|
||||
options: Operation
|
||||
head: Operation
|
||||
patch: Operation
|
||||
trace: Operation
|
||||
|
||||
|
||||
class Flow(Definition):
|
||||
authorizationUrl: str
|
||||
tokenUrl: str
|
||||
refreshUrl: str
|
||||
scopes: dict[str, str]
|
||||
|
||||
|
||||
class Flows(Definition):
|
||||
implicit: Flow
|
||||
password: Flow
|
||||
clientCredentials: Flow
|
||||
authorizationCode: Flow
|
||||
|
||||
|
||||
class SecurityRequirement(Definition):
|
||||
name: str
|
||||
value: list[str]
|
||||
|
||||
|
||||
class SecurityScheme(Definition):
|
||||
type: str
|
||||
bearerFormat: str
|
||||
description: str
|
||||
flows: Flows
|
||||
location: str
|
||||
name: str
|
||||
openIdConnectUrl: str
|
||||
scheme: str
|
||||
|
||||
__nullable__ = None
|
||||
|
||||
def __init__(self, type: str, **kwargs):
|
||||
super().__init__(type=type, **kwargs)
|
||||
|
||||
@property
|
||||
def fields(self):
|
||||
values = super().fields
|
||||
|
||||
if "location" in values:
|
||||
values["in"] = values.pop("location")
|
||||
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def make(_type: str, cls: type, **kwargs):
|
||||
params: dict[str, Any] = getattr(cls, "__dict__", {})
|
||||
return SecurityScheme(_type, **params, **kwargs)
|
||||
|
||||
|
||||
class ServerVariable(Definition):
|
||||
default: str
|
||||
description: str
|
||||
enum: list[str]
|
||||
|
||||
def __init__(self, default: str, **kwargs):
|
||||
super().__init__(default=default, **kwargs)
|
||||
|
||||
|
||||
class Server(Definition):
|
||||
url: str
|
||||
description: str
|
||||
variables: dict[str, ServerVariable]
|
||||
|
||||
__nullable__ = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
description: Optional[str] = None,
|
||||
variables: Optional[dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
url=url, description=description, variables=variables or {}
|
||||
)
|
||||
|
||||
|
||||
class Tag(Definition):
|
||||
name: str
|
||||
description: str
|
||||
externalDocs: ExternalDocumentation
|
||||
|
||||
def __init__(self, name: str, **kwargs):
|
||||
super().__init__(name=name, **kwargs)
|
||||
|
||||
|
||||
class Components(Definition):
|
||||
# This class is not being used in sanic-openapi right now, but the
|
||||
# definition is kept here to keep in close accordance with the openapi
|
||||
# spec, in case it is desired to be added later.
|
||||
schemas: dict[str, Schema]
|
||||
responses: dict[str, Response]
|
||||
parameters: dict[str, Parameter]
|
||||
examples: dict[str, Example]
|
||||
requestBodies: dict[str, RequestBody]
|
||||
headers: dict[str, Header]
|
||||
securitySchemes: dict[str, SecurityScheme]
|
||||
links: dict[str, Schema] # TODO
|
||||
callbacks: dict[str, Schema] # TODO
|
||||
|
||||
|
||||
def Component(
|
||||
obj: Any, *, name: str = "", field: str = "schemas"
|
||||
) -> Reference:
|
||||
hints = get_type_hints(Components)
|
||||
|
||||
if field not in hints:
|
||||
raise AttributeError(
|
||||
f"Unknown field '{field}'. Must be a valid field per OAS3 "
|
||||
"requirements. See "
|
||||
"https://swagger.io/specification/#components-object."
|
||||
)
|
||||
|
||||
if not isclass(obj) and not name:
|
||||
raise SanicException(
|
||||
f"Components {obj} must be created with a declared name"
|
||||
)
|
||||
|
||||
if not name:
|
||||
name = obj.__name__
|
||||
|
||||
from sanic_ext.extensions.openapi.builders import SpecificationBuilder
|
||||
|
||||
spec = SpecificationBuilder()
|
||||
refval = f"#/components/{field}/{name}"
|
||||
ref = Reference(refval)
|
||||
|
||||
if not spec.has_component(field, name):
|
||||
prop_info = hints[field]
|
||||
type_ = prop_info.__args__[1]
|
||||
if is_msgspec(obj):
|
||||
import msgspec
|
||||
|
||||
_, definitions = msgspec.json.schema_components(
|
||||
[obj], ref_template="#/components/schemas/{name}"
|
||||
)
|
||||
if definitions:
|
||||
for key, value in definitions.items():
|
||||
spec.add_component(field, key, value)
|
||||
elif is_pydantic(obj):
|
||||
try:
|
||||
schema = obj.schema
|
||||
except AttributeError:
|
||||
schema = obj.__pydantic_model__.schema
|
||||
component = schema(ref_template="#/components/schemas/{model}")
|
||||
definitions = component.pop("definitions", None)
|
||||
if definitions:
|
||||
for key, value in definitions.items():
|
||||
spec.add_component(field, key, value)
|
||||
spec.add_component(field, name, component)
|
||||
else:
|
||||
component = (
|
||||
type_.make(obj) if hasattr(type_, "make") else type_(obj)
|
||||
)
|
||||
spec.add_component(field, name, component)
|
||||
|
||||
return ref
|
||||
|
||||
|
||||
class OpenAPI(Definition):
|
||||
openapi: str
|
||||
info: Info
|
||||
servers: list[Server]
|
||||
paths: dict[str, PathItem]
|
||||
components: Components
|
||||
security: dict[str, SecurityScheme]
|
||||
tags: list[Tag]
|
||||
externalDocs: ExternalDocumentation
|
||||
|
||||
def __init__(self, info: Info, paths: dict[str, PathItem], **kwargs):
|
||||
use = {k: v for k, v in kwargs.items() if v is not None}
|
||||
super().__init__(openapi="3.0.3", info=info, paths=paths, **use)
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sanic_ext.extensions.openapi.builders import SpecificationBuilder
|
||||
|
||||
from ..base import Extension
|
||||
from .blueprint import blueprint_factory
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic_ext import Extend
|
||||
|
||||
|
||||
class OpenAPIExtension(Extension):
|
||||
name = "openapi"
|
||||
|
||||
def startup(self, bootstrap: Extend) -> None:
|
||||
if self.app.config.OAS:
|
||||
self.bp = blueprint_factory(self.app.config)
|
||||
self.app.blueprint(self.bp)
|
||||
bootstrap._openapi = SpecificationBuilder()
|
||||
|
||||
def label(self):
|
||||
if self.app.config.OAS:
|
||||
return self._make_url()
|
||||
|
||||
return ""
|
||||
|
||||
def included(self):
|
||||
return self.config.OAS
|
||||
|
||||
def _make_url(self):
|
||||
name = f"{self.bp.name}.index"
|
||||
_server = (
|
||||
None
|
||||
if "SERVER_NAME" in self.app.config
|
||||
else self.app.serve_location
|
||||
) or None
|
||||
_external = bool(_server) or "SERVER_NAME" in self.app.config
|
||||
return (
|
||||
f"{self.app.url_for(name, _external=_external, _server=_server)}"
|
||||
)
|
||||
@@ -0,0 +1,534 @@
|
||||
"""
|
||||
This module provides decorators which append
|
||||
documentation to OperationStore() and components created in the blueprints.
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from functools import wraps
|
||||
from inspect import isawaitable, isclass
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Literal,
|
||||
Optional,
|
||||
TypeVar,
|
||||
Union,
|
||||
overload,
|
||||
)
|
||||
|
||||
from sanic import Blueprint
|
||||
from sanic.exceptions import InvalidUsage, SanicException
|
||||
|
||||
from sanic_ext.extensions.openapi import definitions
|
||||
from sanic_ext.extensions.openapi.builders import (
|
||||
OperationStore,
|
||||
SpecificationBuilder,
|
||||
)
|
||||
from sanic_ext.extensions.openapi.definitions import Component
|
||||
from sanic_ext.extensions.openapi.types import (
|
||||
Array,
|
||||
Binary,
|
||||
Boolean,
|
||||
Byte,
|
||||
Date,
|
||||
DateTime,
|
||||
Double,
|
||||
Email,
|
||||
Float,
|
||||
Integer,
|
||||
Long,
|
||||
Object,
|
||||
Password,
|
||||
Schema,
|
||||
String,
|
||||
Time,
|
||||
)
|
||||
from sanic_ext.extras.validation.setup import do_validation, generate_schema
|
||||
from sanic_ext.utils.extraction import extract_request
|
||||
|
||||
|
||||
__all__ = (
|
||||
"definitions",
|
||||
"body",
|
||||
"component",
|
||||
"definition",
|
||||
"deprecated",
|
||||
"description",
|
||||
"document",
|
||||
"exclude",
|
||||
"no_autodoc",
|
||||
"operation",
|
||||
"parameter",
|
||||
"response",
|
||||
"secured",
|
||||
"summary",
|
||||
"tag",
|
||||
"Array",
|
||||
"Binary",
|
||||
"Boolean",
|
||||
"Byte",
|
||||
"Component",
|
||||
"Date",
|
||||
"DateTime",
|
||||
"Double",
|
||||
"Email",
|
||||
"Float",
|
||||
"Integer",
|
||||
"Long",
|
||||
"Object",
|
||||
"Password",
|
||||
"String",
|
||||
"Time",
|
||||
)
|
||||
|
||||
|
||||
def _content_or_component(content):
|
||||
if isclass(content):
|
||||
spec = SpecificationBuilder()
|
||||
if spec._components["schemas"].get(content.__name__):
|
||||
content = definitions.Component(content)
|
||||
return content
|
||||
|
||||
|
||||
@overload
|
||||
def exclude(flag: bool = True, *, bp: Blueprint) -> None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def exclude(flag: bool = True) -> Callable: ...
|
||||
|
||||
|
||||
def exclude(flag: bool = True, *, bp: Optional[Blueprint] = None):
|
||||
if bp:
|
||||
for route in bp.routes:
|
||||
exclude(flag)(route.handler)
|
||||
return
|
||||
|
||||
def inner(func):
|
||||
OperationStore()[func].exclude(flag)
|
||||
return func
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def operation(name: str) -> Callable[[T], T]:
|
||||
def inner(func):
|
||||
OperationStore()[func].name(name)
|
||||
return func
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
def summary(text: str) -> Callable[[T], T]:
|
||||
def inner(func):
|
||||
OperationStore()[func].describe(summary=text)
|
||||
return func
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
def description(text: str) -> Callable[[T], T]:
|
||||
def inner(func):
|
||||
OperationStore()[func].describe(description=text)
|
||||
return func
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
def document(
|
||||
url: Union[str, definitions.ExternalDocumentation],
|
||||
description: Optional[str] = None,
|
||||
) -> Callable[[T], T]:
|
||||
if isinstance(url, definitions.ExternalDocumentation):
|
||||
description = url.fields["description"]
|
||||
url = url.fields["url"]
|
||||
|
||||
def inner(func):
|
||||
OperationStore()[func].document(url, description)
|
||||
return func
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
def tag(*args: Union[str, definitions.Tag]) -> Callable[[T], T]:
|
||||
def inner(func):
|
||||
OperationStore()[func].tag(*args)
|
||||
return func
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
def deprecated(maybe_func=None) -> Callable[[T], T]:
|
||||
def inner(func):
|
||||
OperationStore()[func].deprecate()
|
||||
return func
|
||||
|
||||
return inner(maybe_func) if maybe_func else inner
|
||||
|
||||
|
||||
def no_autodoc(maybe_func=None) -> Callable[[T], T]:
|
||||
def inner(func):
|
||||
OperationStore()[func].disable_autodoc()
|
||||
return func
|
||||
|
||||
return inner(maybe_func) if maybe_func else inner
|
||||
|
||||
|
||||
def body(
|
||||
content: Any,
|
||||
*,
|
||||
validate: bool = False,
|
||||
body_argument: str = "body",
|
||||
**kwargs,
|
||||
) -> Callable[[T], T]:
|
||||
body_content = _content_or_component(content)
|
||||
params = {**kwargs}
|
||||
validation_schema = None
|
||||
if isinstance(body_content, definitions.RequestBody):
|
||||
params = {**body_content.fields, **params}
|
||||
body_content = params.pop("content")
|
||||
|
||||
if validate:
|
||||
if callable(validate):
|
||||
model = validate
|
||||
else:
|
||||
model = body_content
|
||||
validation_schema = generate_schema(body_content)
|
||||
|
||||
def inner(func):
|
||||
@wraps(func)
|
||||
async def handler(*handler_args, **handler_kwargs):
|
||||
request = extract_request(*handler_args)
|
||||
|
||||
if validate:
|
||||
try:
|
||||
data = request.json
|
||||
allow_multiple = False
|
||||
allow_coerce = False
|
||||
except InvalidUsage:
|
||||
data = request.form
|
||||
allow_multiple = True
|
||||
allow_coerce = True
|
||||
|
||||
await do_validation(
|
||||
model=model,
|
||||
data=data,
|
||||
schema=validation_schema,
|
||||
request=request,
|
||||
kwargs=handler_kwargs,
|
||||
body_argument=body_argument,
|
||||
allow_multiple=allow_multiple,
|
||||
allow_coerce=allow_coerce,
|
||||
)
|
||||
|
||||
retval = func(*handler_args, **handler_kwargs)
|
||||
if isawaitable(retval):
|
||||
retval = await retval
|
||||
return retval
|
||||
|
||||
if func in OperationStore():
|
||||
OperationStore()[handler] = OperationStore().pop(func)
|
||||
OperationStore()[handler].body(body_content, **params)
|
||||
return handler
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
@overload
|
||||
def parameter(
|
||||
*,
|
||||
parameter: definitions.Parameter,
|
||||
**kwargs,
|
||||
) -> Callable[[T], T]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def parameter(
|
||||
name: None,
|
||||
schema: None,
|
||||
location: None,
|
||||
parameter: definitions.Parameter,
|
||||
**kwargs,
|
||||
) -> Callable[[T], T]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def parameter(
|
||||
name: str,
|
||||
schema: Optional[Union[type, Schema]] = None,
|
||||
location: Optional[str] = None,
|
||||
parameter: None = None,
|
||||
**kwargs,
|
||||
) -> Callable[[T], T]: ...
|
||||
|
||||
|
||||
def parameter(
|
||||
name: Optional[str] = None,
|
||||
schema: Optional[Union[type, Schema]] = None,
|
||||
location: Optional[str] = None,
|
||||
parameter: Optional[definitions.Parameter] = None,
|
||||
**kwargs,
|
||||
) -> Callable[[T], T]:
|
||||
if parameter:
|
||||
if name or schema or location:
|
||||
raise SanicException(
|
||||
"When using a parameter object, you cannot pass "
|
||||
"other arguments."
|
||||
)
|
||||
if not schema:
|
||||
schema = str
|
||||
if not location:
|
||||
location = "query"
|
||||
|
||||
def inner(func: Callable):
|
||||
if parameter:
|
||||
# Temporary solution convert in to location,
|
||||
# need to be changed later.
|
||||
fields = dict(parameter.fields)
|
||||
if "in" in fields:
|
||||
fields["location"] = fields.pop("in")
|
||||
OperationStore()[func].parameter(**fields)
|
||||
else:
|
||||
OperationStore()[func].parameter(name, schema, location, **kwargs)
|
||||
return func
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
def response(
|
||||
status: Union[Literal["default"], int] = "default",
|
||||
content: Any = str,
|
||||
description: Optional[str] = None,
|
||||
*,
|
||||
response: Optional[definitions.Response] = None,
|
||||
**kwargs,
|
||||
) -> Callable[[T], T]:
|
||||
if response:
|
||||
if (
|
||||
status != "default"
|
||||
or content is not str
|
||||
or description is not None
|
||||
):
|
||||
raise SanicException(
|
||||
"When using a response object, you cannot pass "
|
||||
"other arguments."
|
||||
)
|
||||
|
||||
status = response.fields["status"]
|
||||
content = response.fields["content"]
|
||||
description = response.fields["description"]
|
||||
|
||||
def inner(func):
|
||||
OperationStore()[func].response(status, content, description, **kwargs)
|
||||
return func
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
def secured(*args, **kwargs) -> Callable[[T], T]:
|
||||
def inner(func):
|
||||
OperationStore()[func].secured(*args, **kwargs)
|
||||
return func
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
Model = TypeVar("Model")
|
||||
|
||||
|
||||
def component(
|
||||
model: Optional[Model] = None,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
field: str = "schemas",
|
||||
) -> Callable[[T], T]:
|
||||
def wrap(m):
|
||||
return component(m, name=name, field=field)
|
||||
|
||||
if not model:
|
||||
return wrap
|
||||
|
||||
params = {}
|
||||
if name:
|
||||
params["name"] = name
|
||||
if field:
|
||||
params["field"] = field
|
||||
definitions.Component(model, **params)
|
||||
return model
|
||||
|
||||
|
||||
def definition(
|
||||
*,
|
||||
exclude: Optional[bool] = None,
|
||||
operation: Optional[str] = None,
|
||||
summary: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
document: Optional[Union[str, definitions.ExternalDocumentation]] = None,
|
||||
tag: Optional[
|
||||
Union[
|
||||
Union[str, definitions.Tag], Sequence[Union[str, definitions.Tag]]
|
||||
]
|
||||
] = None,
|
||||
deprecated: bool = False,
|
||||
body: Optional[Union[dict[str, Any], definitions.RequestBody, Any]] = None,
|
||||
parameter: Optional[
|
||||
Union[
|
||||
Union[dict[str, Any], definitions.Parameter, str],
|
||||
list[Union[dict[str, Any], definitions.Parameter, str]],
|
||||
]
|
||||
] = None,
|
||||
response: Optional[
|
||||
Union[
|
||||
Union[dict[str, Any], definitions.Response, Any],
|
||||
list[Union[dict[str, Any], definitions.Response]],
|
||||
]
|
||||
] = None,
|
||||
secured: Optional[dict[str, Any]] = None,
|
||||
validate: bool = False,
|
||||
body_argument: str = "body",
|
||||
) -> Callable[[T], T]:
|
||||
validation_schema = None
|
||||
body_content = None
|
||||
|
||||
def inner(func):
|
||||
nonlocal validation_schema
|
||||
nonlocal body_content
|
||||
|
||||
glbl = globals()
|
||||
|
||||
if body:
|
||||
kwargs = {}
|
||||
content = body
|
||||
if isinstance(content, definitions.RequestBody):
|
||||
kwargs = content.fields
|
||||
elif isinstance(content, dict):
|
||||
if "content" in content:
|
||||
kwargs = content
|
||||
else:
|
||||
kwargs["content"] = content
|
||||
else:
|
||||
content = _content_or_component(content)
|
||||
kwargs["content"] = content
|
||||
|
||||
if validate:
|
||||
kwargs["validate"] = validate
|
||||
kwargs["body_argument"] = body_argument
|
||||
|
||||
func = glbl["body"](**kwargs)(func)
|
||||
|
||||
if exclude is not None:
|
||||
func = glbl["exclude"](exclude)(func)
|
||||
|
||||
if operation:
|
||||
func = glbl["operation"](operation)(func)
|
||||
|
||||
if summary:
|
||||
func = glbl["summary"](summary)(func)
|
||||
|
||||
if description:
|
||||
func = glbl["description"](description)(func)
|
||||
|
||||
if document:
|
||||
kwargs = {}
|
||||
if isinstance(document, str):
|
||||
kwargs["url"] = document
|
||||
else:
|
||||
kwargs["url"] = document.fields["url"]
|
||||
kwargs["description"] = document.fields["description"]
|
||||
|
||||
func = glbl["document"](**kwargs)(func)
|
||||
|
||||
if tag:
|
||||
taglist = []
|
||||
op = (
|
||||
"extend"
|
||||
if isinstance(tag, (list, tuple, set, frozenset))
|
||||
else "append"
|
||||
)
|
||||
|
||||
getattr(taglist, op)(tag)
|
||||
func = glbl["tag"](*taglist)(func)
|
||||
|
||||
if deprecated:
|
||||
func = glbl["deprecated"]()(func)
|
||||
|
||||
if parameter:
|
||||
paramlist = []
|
||||
op = (
|
||||
"extend"
|
||||
if isinstance(parameter, (list, tuple, set, frozenset))
|
||||
else "append"
|
||||
)
|
||||
getattr(paramlist, op)(parameter)
|
||||
|
||||
for param in paramlist:
|
||||
kwargs = {}
|
||||
if isinstance(param, definitions.Parameter):
|
||||
kwargs = param.fields
|
||||
if "in" in kwargs:
|
||||
kwargs["location"] = kwargs.pop("in")
|
||||
elif isinstance(param, dict) and "name" in param:
|
||||
kwargs = param
|
||||
elif isinstance(param, str):
|
||||
kwargs["name"] = param
|
||||
else:
|
||||
raise SanicException(
|
||||
"parameter must be a Parameter instance, a string, or "
|
||||
"a dictionary containing at least 'name'."
|
||||
)
|
||||
|
||||
if "schema" not in kwargs:
|
||||
kwargs["schema"] = str
|
||||
|
||||
func = glbl["parameter"](**kwargs)(func)
|
||||
|
||||
if response:
|
||||
resplist = []
|
||||
op = (
|
||||
"extend"
|
||||
if isinstance(response, (list, tuple, set, frozenset))
|
||||
else "append"
|
||||
)
|
||||
getattr(resplist, op)(response)
|
||||
|
||||
if len(resplist) > 1 and any(
|
||||
not isinstance(item, definitions.Response)
|
||||
and not isinstance(item, dict)
|
||||
for item in resplist
|
||||
):
|
||||
raise SanicException(
|
||||
"Cannot use multiple bare custom models to define "
|
||||
"multiple responses like openapi.definition(response=["
|
||||
"MyModel1, MyModel2]). Instead, you should wrap them in a "
|
||||
"dict or a Response object. See "
|
||||
"https://sanic.dev/en/plugins/sanic-ext/openapi/decorators"
|
||||
".html#response for more details."
|
||||
)
|
||||
|
||||
for resp in resplist:
|
||||
kwargs = {}
|
||||
if isinstance(resp, definitions.Response):
|
||||
kwargs = resp.fields
|
||||
elif isinstance(resp, dict):
|
||||
if "content" in resp:
|
||||
kwargs = resp
|
||||
else:
|
||||
kwargs["content"] = resp
|
||||
else:
|
||||
kwargs["content"] = resp
|
||||
|
||||
if "status" not in kwargs:
|
||||
kwargs["status"] = "default"
|
||||
|
||||
func = glbl["response"](**kwargs)(func)
|
||||
|
||||
if secured:
|
||||
func = glbl["secured"](secured)(func)
|
||||
|
||||
return func
|
||||
|
||||
return inner
|
||||
@@ -0,0 +1,452 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from dataclasses import MISSING, is_dataclass
|
||||
from datetime import date, datetime, time
|
||||
from enum import Enum
|
||||
from inspect import getmembers, isclass, isfunction, ismethod
|
||||
from typing import (
|
||||
Any,
|
||||
Optional,
|
||||
Union,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from sanic_routing.patterns import alpha, ext, nonemptystr, parse_date, slug
|
||||
|
||||
from sanic_ext.utils.typing import (
|
||||
UnionType,
|
||||
is_attrs,
|
||||
is_generic,
|
||||
is_msgspec,
|
||||
is_pydantic,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
import attrs
|
||||
|
||||
NOTHING: Any = attrs.NOTHING
|
||||
except ImportError:
|
||||
NOTHING = object()
|
||||
|
||||
try:
|
||||
import msgspec
|
||||
|
||||
from msgspec.inspect import Metadata as MsgspecMetadata
|
||||
from msgspec.inspect import type_info as msgspec_type_info
|
||||
|
||||
MsgspecMetadata: Any = MsgspecMetadata
|
||||
NODEFAULT: Any = msgspec.NODEFAULT
|
||||
UNSET: Any = msgspec.UNSET
|
||||
|
||||
class MsgspecAdapter(msgspec.Struct):
|
||||
name: str
|
||||
default: Any
|
||||
metadata: dict
|
||||
|
||||
except ImportError:
|
||||
|
||||
def msgspec_type_info(struct):
|
||||
pass
|
||||
|
||||
class MsgspecAdapter:
|
||||
pass
|
||||
|
||||
MsgspecMetadata = object()
|
||||
NODEFAULT = object()
|
||||
UNSET = object()
|
||||
|
||||
|
||||
class Definition:
|
||||
__nullable__: Optional[list[str]] = []
|
||||
__ignore__: Optional[list[str]] = []
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self._fields: dict[str, Any] = self.guard(kwargs)
|
||||
|
||||
@property
|
||||
def fields(self):
|
||||
return self._fields
|
||||
|
||||
def guard(self, fields):
|
||||
return {
|
||||
k: v
|
||||
for k, v in fields.items()
|
||||
if k in _properties(self).keys() or k.startswith("x-")
|
||||
}
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
k: self._value(v)
|
||||
for k, v in _serialize(self.fields).items()
|
||||
if (
|
||||
k not in self.__ignore__
|
||||
and (
|
||||
v is not None
|
||||
or (
|
||||
isinstance(self.__nullable__, list)
|
||||
and (not self.__nullable__ or k in self.__nullable__)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def __str__(self):
|
||||
return json.dumps(self.serialize())
|
||||
|
||||
@staticmethod
|
||||
def _value(value):
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
return value
|
||||
|
||||
|
||||
class Schema(Definition):
|
||||
title: str
|
||||
description: str
|
||||
type: str
|
||||
format: str
|
||||
nullable: bool
|
||||
required: bool
|
||||
default: None
|
||||
example: None
|
||||
oneOf: list[Definition]
|
||||
anyOf: list[Definition]
|
||||
allOf: list[Definition]
|
||||
|
||||
additionalProperties: dict[str, str]
|
||||
multipleOf: int
|
||||
maximum: int
|
||||
exclusiveMaximum: bool
|
||||
minimum: int
|
||||
exclusiveMinimum: bool
|
||||
maxLength: int
|
||||
minLength: int
|
||||
pattern: str
|
||||
enum: Union[list[Any], Enum]
|
||||
|
||||
@staticmethod
|
||||
def make(value, **kwargs):
|
||||
_type = type(value)
|
||||
origin = get_origin(value)
|
||||
args = get_args(value)
|
||||
if origin in (Union, UnionType):
|
||||
if type(None) in args:
|
||||
kwargs["nullable"] = True
|
||||
|
||||
filtered = [arg for arg in args if arg is not type(None)] # noqa
|
||||
|
||||
if len(filtered) == 1:
|
||||
return Schema.make(filtered[0], **kwargs)
|
||||
return Schema(
|
||||
oneOf=[Schema.make(arg) for arg in filtered], **kwargs
|
||||
)
|
||||
|
||||
for field in ("type", "format"):
|
||||
kwargs.pop(field, None)
|
||||
|
||||
if isinstance(value, Schema):
|
||||
return value
|
||||
if value is bool:
|
||||
return Boolean(**kwargs)
|
||||
elif value is int:
|
||||
return Integer(**kwargs)
|
||||
elif value is float:
|
||||
return Float(**kwargs)
|
||||
elif value is str or value in (nonemptystr, ext, slug, alpha):
|
||||
return String(**kwargs)
|
||||
elif value is bytes:
|
||||
return Byte(**kwargs)
|
||||
elif value is bytearray:
|
||||
return Binary(**kwargs)
|
||||
elif value is date:
|
||||
return Date(**kwargs)
|
||||
elif value is time:
|
||||
return Time(**kwargs)
|
||||
elif value is datetime or value is parse_date:
|
||||
return DateTime(**kwargs)
|
||||
elif value is uuid.UUID:
|
||||
return UUID(**kwargs)
|
||||
elif value is Any:
|
||||
return AnyValue(**kwargs)
|
||||
|
||||
if _type is bool:
|
||||
return Boolean(default=value, **kwargs)
|
||||
elif _type is int:
|
||||
return Integer(default=value, **kwargs)
|
||||
elif _type is float:
|
||||
return Float(default=value, **kwargs)
|
||||
elif _type is str:
|
||||
return String(default=value, **kwargs)
|
||||
elif _type is bytes:
|
||||
return Byte(default=value, **kwargs)
|
||||
elif _type is bytearray:
|
||||
return Binary(default=value, **kwargs)
|
||||
elif _type is date:
|
||||
return Date(**kwargs)
|
||||
elif _type is time:
|
||||
return Time(**kwargs)
|
||||
elif _type is datetime:
|
||||
return DateTime(**kwargs)
|
||||
elif _type is uuid.UUID:
|
||||
return UUID(**kwargs)
|
||||
elif _type is list:
|
||||
if len(value) == 0:
|
||||
schema = Schema(nullable=True)
|
||||
elif len(value) == 1:
|
||||
schema = Schema.make(value[0])
|
||||
else:
|
||||
schema = Schema(oneOf=[Schema.make(x) for x in value])
|
||||
|
||||
return Array(schema, **kwargs)
|
||||
elif _type is dict:
|
||||
return Object.make(value, **kwargs)
|
||||
elif (
|
||||
(is_generic(value) or is_generic(_type))
|
||||
and origin is dict
|
||||
and len(args) == 2
|
||||
):
|
||||
kwargs["additionalProperties"] = Schema.make(args[1])
|
||||
return Object(**kwargs)
|
||||
elif (is_generic(value) or is_generic(_type)) and origin is list:
|
||||
kwargs.pop("items", None)
|
||||
return Array(Schema.make(args[0]), **kwargs)
|
||||
elif _type is type(Enum):
|
||||
available = [item.value for item in value.__members__.values()]
|
||||
available_types = list({type(item) for item in available})
|
||||
schema_type = (
|
||||
available_types[0] if len(available_types) == 1 else "string"
|
||||
)
|
||||
return Schema.make(
|
||||
schema_type,
|
||||
enum=[item.value for item in value.__members__.values()],
|
||||
)
|
||||
else:
|
||||
return Object.make(value, **kwargs)
|
||||
|
||||
|
||||
class Boolean(Schema):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(type="boolean", **kwargs)
|
||||
|
||||
|
||||
class Integer(Schema):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(type="integer", format="int32", **kwargs)
|
||||
|
||||
|
||||
class Long(Schema):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(type="integer", format="int64", **kwargs)
|
||||
|
||||
|
||||
class Float(Schema):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(type="number", format="float", **kwargs)
|
||||
|
||||
|
||||
class Double(Schema):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(type="number", format="double", **kwargs)
|
||||
|
||||
|
||||
class String(Schema):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(type="string", **kwargs)
|
||||
|
||||
|
||||
class Byte(Schema):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(type="string", format="byte", **kwargs)
|
||||
|
||||
|
||||
class Binary(Schema):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(type="string", format="binary", **kwargs)
|
||||
|
||||
|
||||
class Date(Schema):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(type="string", format="date", **kwargs)
|
||||
|
||||
|
||||
class Time(Schema):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(type="string", format="time", **kwargs)
|
||||
|
||||
|
||||
class DateTime(Schema):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(type="string", format="date-time", **kwargs)
|
||||
|
||||
|
||||
class Password(Schema):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(type="string", format="password", **kwargs)
|
||||
|
||||
|
||||
class Email(Schema):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(type="string", format="email", **kwargs)
|
||||
|
||||
|
||||
class UUID(Schema):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(type="string", format="uuid", **kwargs)
|
||||
|
||||
|
||||
class AnyValue(Schema):
|
||||
@classmethod
|
||||
def make(cls, value: Any, **kwargs):
|
||||
return cls(
|
||||
AnyValue={},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class Object(Schema):
|
||||
properties: dict[str, Schema]
|
||||
maxProperties: int
|
||||
minProperties: int
|
||||
|
||||
def __init__(
|
||||
self, properties: Optional[dict[str, Schema]] = None, **kwargs
|
||||
):
|
||||
if properties:
|
||||
kwargs["properties"] = properties
|
||||
super().__init__(type="object", **kwargs)
|
||||
|
||||
@classmethod
|
||||
def make(cls, value: Any, **kwargs):
|
||||
extra: dict[str, Any] = {}
|
||||
|
||||
# Extract from field metadata if msgspec, pydantic, attrs, or dataclass
|
||||
if isclass(value):
|
||||
fields = ()
|
||||
if is_pydantic(value):
|
||||
try:
|
||||
value = value.__pydantic_model__
|
||||
except AttributeError:
|
||||
...
|
||||
extra = value.schema()["properties"]
|
||||
elif is_attrs(value):
|
||||
fields = value.__attrs_attrs__
|
||||
elif is_dataclass(value):
|
||||
fields = value.__dataclass_fields__.values()
|
||||
elif is_msgspec(value):
|
||||
# adapt to msgspec metadata layout -- annotated type --
|
||||
# to match dataclass "metadata" attribute
|
||||
fields = [
|
||||
MsgspecAdapter(
|
||||
name=f.name,
|
||||
default=(
|
||||
MISSING
|
||||
if f.default in (UNSET, NODEFAULT)
|
||||
else f.default
|
||||
),
|
||||
metadata=getattr(f.type, "extra", {}),
|
||||
)
|
||||
for f in msgspec_type_info(value).fields
|
||||
]
|
||||
|
||||
if fields:
|
||||
extra = {
|
||||
field.name: {
|
||||
"title": field.name.title(),
|
||||
**(
|
||||
{"default": field.default}
|
||||
if field.default not in (MISSING, NOTHING)
|
||||
else {}
|
||||
),
|
||||
**dict(field.metadata).get("openapi", {}),
|
||||
}
|
||||
for field in fields
|
||||
}
|
||||
|
||||
return cls(
|
||||
{
|
||||
k: Schema.make(v, **extra.get(k, {}))
|
||||
for k, v in _properties(value).items()
|
||||
},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class Array(Schema):
|
||||
items: Any
|
||||
maxItems: int
|
||||
minItems: int
|
||||
uniqueItems: bool
|
||||
|
||||
def __init__(self, items: Any, **kwargs):
|
||||
super().__init__(type="array", items=Schema.make(items), **kwargs)
|
||||
|
||||
|
||||
def _serialize(value) -> Any:
|
||||
if isinstance(value, Definition):
|
||||
return value.serialize()
|
||||
|
||||
if isinstance(value, type) and issubclass(value, Enum):
|
||||
return [item.value for item in value.__members__.values()]
|
||||
|
||||
if isinstance(value, dict):
|
||||
return {k: _serialize(v) for k, v in value.items()}
|
||||
|
||||
if isinstance(value, list):
|
||||
return [_serialize(v) for v in value]
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _properties(value: object) -> dict:
|
||||
try:
|
||||
fields = {
|
||||
x: val
|
||||
for x, v in getmembers(value, _is_property)
|
||||
if (val := _extract(v)) and x in value.__dict__
|
||||
}
|
||||
except AttributeError:
|
||||
fields = {}
|
||||
|
||||
cls = value if callable(value) else value.__class__
|
||||
extra = value if isinstance(value, dict) else {}
|
||||
try:
|
||||
annotations = get_type_hints(cls)
|
||||
except (NameError, TypeError):
|
||||
if hasattr(value, "__annotations__"):
|
||||
annotations = value.__annotations__
|
||||
else:
|
||||
annotations = {}
|
||||
annotations.pop("return", None)
|
||||
try:
|
||||
output = {
|
||||
k: v
|
||||
for k, v in {**fields, **annotations, **extra}.items()
|
||||
if not k.startswith("_")
|
||||
and not (
|
||||
isclass(v)
|
||||
and isclass(cls)
|
||||
and v.__qualname__.endswith(
|
||||
f"{getattr(cls, '__name__', '<unknown>')}."
|
||||
f"{getattr(v, '__name__', '<unknown>')}"
|
||||
)
|
||||
)
|
||||
}
|
||||
except TypeError:
|
||||
return {}
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def _extract(item):
|
||||
if isinstance(item, property):
|
||||
hints = get_type_hints(item.fget)
|
||||
return hints.get("return")
|
||||
return item
|
||||
|
||||
|
||||
def _is_property(item):
|
||||
return not isfunction(item) and not ismethod(item)
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
|
||||
<title>__HTML_TITLE__</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
__HTML_CUSTOM_CSS__
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="__URL_PREFIX__/openapi.json"></redoc>
|
||||
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/__VERSION__/swagger-ui.min.css">
|
||||
<title>__HTML_TITLE__</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
__HTML_CUSTOM_CSS__
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="openapi"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/__VERSION__/swagger-ui-bundle.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/__VERSION__/swagger-ui-standalone-preset.min.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "__URL_PREFIX__/openapi.json",
|
||||
dom_id: "#openapi",
|
||||
configUrl: "__URL_PREFIX__/swagger-config",
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
})
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user