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,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Union
|
||||
|
||||
from sanic.app import Sanic
|
||||
from sanic.exceptions import SanicException
|
||||
|
||||
from sanic_ext.config import Config
|
||||
from sanic_ext.exceptions import InitError
|
||||
|
||||
|
||||
class NoDuplicateDict(dict): # type: ignore
|
||||
def __setitem__(self, key: Any, value: Any) -> None:
|
||||
if key in self:
|
||||
raise KeyError(f"Duplicate key: {key}")
|
||||
return super().__setitem__(key, value)
|
||||
|
||||
|
||||
class Extension(ABC):
|
||||
_name_registry: dict[str, type[Extension]] = NoDuplicateDict()
|
||||
_started: bool
|
||||
name: str
|
||||
app: Sanic
|
||||
config: Config
|
||||
|
||||
def __init_subclass__(cls):
|
||||
if not getattr(cls, "name", None) or not cls.name.isalpha():
|
||||
raise InitError(
|
||||
"Extensions must be named, and may only contain "
|
||||
"alphabetic characters"
|
||||
)
|
||||
|
||||
if cls.name in cls._name_registry:
|
||||
raise InitError(f'Extension "{cls.name}" already exists')
|
||||
|
||||
cls._name_registry[cls.name] = cls
|
||||
|
||||
def _startup(self, bootstrap):
|
||||
if self._started:
|
||||
raise SanicException(
|
||||
"Extension already started. Cannot start "
|
||||
f"Extension:{self.name} multiple times."
|
||||
)
|
||||
self.startup(bootstrap)
|
||||
self._started = True
|
||||
|
||||
@abstractmethod
|
||||
def startup(self, bootstrap) -> None: ...
|
||||
|
||||
def label(self):
|
||||
return ""
|
||||
|
||||
def render_label(self):
|
||||
if not self.included:
|
||||
return "~~disabled~~"
|
||||
label = self.label()
|
||||
if not label:
|
||||
return ""
|
||||
return f"[{label}]"
|
||||
|
||||
def included(self):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
extension: Union[type[Extension], Extension],
|
||||
app: Sanic,
|
||||
config: Config,
|
||||
) -> Extension:
|
||||
instance = (
|
||||
extension if isinstance(extension, Extension) else extension()
|
||||
)
|
||||
instance.app = app
|
||||
instance.config = config
|
||||
instance._started = False
|
||||
return instance
|
||||
|
||||
@classmethod
|
||||
def reset(cls) -> None:
|
||||
cls._name_registry.clear()
|
||||
@@ -0,0 +1,13 @@
|
||||
from sanic import Blueprint, Request, Sanic
|
||||
from sanic.response import json
|
||||
from sanic.worker.inspector import Inspector
|
||||
|
||||
|
||||
def setup_health_endpoint(app: Sanic) -> None:
|
||||
bp = Blueprint("SanicHealth", url_prefix=app.config.HEALTH_URL_PREFIX)
|
||||
|
||||
@bp.get(app.config.HEALTH_URI_TO_INFO)
|
||||
async def info(request: Request):
|
||||
return json(Inspector._make_safe(dict(request.app.m.workers)))
|
||||
|
||||
app.blueprint(bp)
|
||||
@@ -0,0 +1,30 @@
|
||||
from sanic.exceptions import SanicException
|
||||
|
||||
from sanic_ext.extensions.health.endpoint import setup_health_endpoint
|
||||
|
||||
from ..base import Extension
|
||||
from .monitor import HealthMonitor
|
||||
|
||||
|
||||
class HealthExtension(Extension):
|
||||
name = "health"
|
||||
MIN_VERSION = (22, 9)
|
||||
|
||||
def startup(self, bootstrap) -> None:
|
||||
if self.config.HEALTH:
|
||||
if self.config.HEALTH_MONITOR:
|
||||
if self.MIN_VERSION > bootstrap.sanic_version:
|
||||
min_version = ".".join(map(str, self.MIN_VERSION))
|
||||
sanic_version = ".".join(map(str, bootstrap.sanic_version))
|
||||
raise SanicException(
|
||||
f"Health monitoring only works with Sanic "
|
||||
f"v{min_version} and above. It looks like you are "
|
||||
f"running {sanic_version}."
|
||||
)
|
||||
HealthMonitor.setup(self.app)
|
||||
|
||||
if self.config.HEALTH_ENDPOINT:
|
||||
setup_health_endpoint(self.app)
|
||||
|
||||
def included(self):
|
||||
return self.config.HEALTH
|
||||
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import sleep
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from multiprocessing import Manager
|
||||
from queue import Empty, Full
|
||||
from signal import SIGINT, SIGTERM
|
||||
from signal import signal as signal_func
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sanic.application.constants import ServerStage
|
||||
from sanic.log import logger
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic import Sanic
|
||||
|
||||
|
||||
class Stale(ValueError): ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthState:
|
||||
name: str
|
||||
last: Optional[datetime] = None
|
||||
misses: int = 0
|
||||
|
||||
def report(self, timestamp: int) -> None:
|
||||
logger.debug(f"Reporting {self.name}")
|
||||
if self.misses:
|
||||
logger.info(f"Recovered {self.name}")
|
||||
self.last = datetime.fromtimestamp(timestamp)
|
||||
self.misses = 0
|
||||
|
||||
def missed(self) -> None:
|
||||
self.misses += 1
|
||||
logger.info(
|
||||
f"Missed health check for {self.name} "
|
||||
f"({self.misses}/{HealthMonitor.MAX_MISSES})"
|
||||
)
|
||||
if self.misses >= HealthMonitor.MAX_MISSES:
|
||||
raise Stale
|
||||
|
||||
def check(self) -> None:
|
||||
if not self.last:
|
||||
return
|
||||
|
||||
threshhold = timedelta(
|
||||
seconds=(HealthMonitor.MISSED_THRESHHOLD * (self.misses + 1))
|
||||
)
|
||||
if self.last < (datetime.now() - threshhold):
|
||||
self.missed()
|
||||
|
||||
def reset(self) -> None:
|
||||
self.misses = 0
|
||||
self.last = datetime.now()
|
||||
|
||||
|
||||
def send_healthy(name, queue):
|
||||
health = (name, datetime.now().timestamp())
|
||||
logger.debug(f"Sending health: {health}", extra={"verbosity": 1})
|
||||
try:
|
||||
queue.put_nowait(health)
|
||||
except Full:
|
||||
...
|
||||
|
||||
|
||||
async def health_check(app: Sanic):
|
||||
sent = datetime.now()
|
||||
|
||||
while app.state.stage is ServerStage.SERVING:
|
||||
now = datetime.now()
|
||||
if sent < now - timedelta(seconds=HealthMonitor.REPORT_INTERVAL):
|
||||
send_healthy(app.m.name, app.shared_ctx.health_queue)
|
||||
sent = now
|
||||
await sleep(0.1)
|
||||
|
||||
|
||||
async def start_health_check(app: Sanic):
|
||||
app.add_task(health_check(app), name="health_check")
|
||||
|
||||
|
||||
async def prepare_health_monitor(app, *_):
|
||||
HealthMonitor.prepare(app)
|
||||
|
||||
|
||||
async def setup_health_monitor(app, *_):
|
||||
health = HealthMonitor(app)
|
||||
process_names = [
|
||||
process.name for process in app.manager.transient_processes
|
||||
]
|
||||
app.manager.manage(
|
||||
"HealthMonitor",
|
||||
health,
|
||||
{
|
||||
"process_names": process_names,
|
||||
"health_queue": app.shared_ctx.health_queue,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class HealthMonitor:
|
||||
MAX_MISSES = 3
|
||||
REPORT_INTERVAL = 5
|
||||
MISSED_THRESHHOLD = 10
|
||||
|
||||
def __init__(self, app: Sanic):
|
||||
self.run = True
|
||||
self.monitor_publisher = app.manager.monitor_publisher
|
||||
|
||||
def __call__(self, process_names, health_queue) -> None:
|
||||
signal_func(SIGINT, self.stop)
|
||||
signal_func(SIGTERM, self.stop)
|
||||
|
||||
now = datetime.now()
|
||||
health_state = {
|
||||
process_name: HealthState(last=now, name=process_name)
|
||||
for process_name in process_names
|
||||
}
|
||||
while self.run:
|
||||
try:
|
||||
name, timestamp = health_queue.get(timeout=0.05)
|
||||
except Empty:
|
||||
...
|
||||
else:
|
||||
health_state[name].report(timestamp)
|
||||
|
||||
for state in health_state.values():
|
||||
try:
|
||||
state.check()
|
||||
except Stale:
|
||||
state.reset()
|
||||
self.monitor_publisher.send(state.name)
|
||||
|
||||
def stop(self, *_):
|
||||
self.run = False
|
||||
|
||||
@classmethod
|
||||
def prepare(cls, app: Sanic):
|
||||
sync_manager = Manager()
|
||||
health_queue = sync_manager.Queue(maxsize=app.state.workers * 2)
|
||||
app.shared_ctx.health_queue = health_queue
|
||||
|
||||
@classmethod
|
||||
def setup(
|
||||
cls,
|
||||
app: Sanic,
|
||||
max_misses: Optional[int] = None,
|
||||
report_interval: Optional[int] = None,
|
||||
missed_threshhold: Optional[int] = None,
|
||||
):
|
||||
HealthMonitor.MAX_MISSES = max_misses or app.config.HEALTH_MAX_MISSES
|
||||
HealthMonitor.REPORT_INTERVAL = (
|
||||
report_interval or app.config.HEALTH_REPORT_INTERVAL
|
||||
)
|
||||
HealthMonitor.MISSED_THRESHHOLD = (
|
||||
missed_threshhold or app.config.HEALTH_MISSED_THRESHHOLD
|
||||
)
|
||||
app.main_process_start(prepare_health_monitor)
|
||||
app.main_process_ready(setup_health_monitor)
|
||||
app.after_server_start(start_health_check)
|
||||
@@ -0,0 +1,396 @@
|
||||
import re
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from sanic import HTTPResponse, Request, Sanic
|
||||
from sanic.exceptions import SanicException
|
||||
from sanic.helpers import Default, _default
|
||||
from sanic.log import logger
|
||||
|
||||
from sanic_ext.config import PRIORITY
|
||||
|
||||
|
||||
WILDCARD_PATTERN = re.compile(r".*")
|
||||
ORIGIN_HEADER = "access-control-allow-origin"
|
||||
ALLOW_HEADERS_HEADER = "access-control-allow-headers"
|
||||
ALLOW_METHODS_HEADER = "access-control-allow-methods"
|
||||
EXPOSE_HEADER = "access-control-expose-headers"
|
||||
CREDENTIALS_HEADER = "access-control-allow-credentials"
|
||||
REQUEST_METHOD_HEADER = "access-control-request-method"
|
||||
REQUEST_HEADERS_HEADER = "access-control-request-headers"
|
||||
MAX_AGE_HEADER = "access-control-max-age"
|
||||
VARY_HEADER = "vary"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CORSSettings:
|
||||
allow_headers: frozenset[str]
|
||||
allow_methods: frozenset[str]
|
||||
allow_origins: tuple[re.Pattern, ...]
|
||||
always_send: bool
|
||||
automatic_options: bool
|
||||
expose_headers: frozenset[str]
|
||||
max_age: str
|
||||
send_wildcard: bool
|
||||
supports_credentials: bool
|
||||
|
||||
|
||||
def add_cors(app: Sanic) -> None:
|
||||
_setup_cors_settings(app)
|
||||
|
||||
@app.on_response
|
||||
async def _add_cors_headers(request, response):
|
||||
preflight = (
|
||||
request.app.ctx.cors.automatic_options
|
||||
and request.method == "OPTIONS"
|
||||
)
|
||||
|
||||
if preflight and not request.headers.get(REQUEST_METHOD_HEADER):
|
||||
logger.info(
|
||||
"No Access-Control-Request-Method header found on request. "
|
||||
"CORS headers will not be applied."
|
||||
)
|
||||
return
|
||||
|
||||
_add_origin_header(request, response)
|
||||
|
||||
if ORIGIN_HEADER not in response.headers:
|
||||
return
|
||||
|
||||
_add_expose_header(request, response)
|
||||
_add_credentials_header(request, response)
|
||||
_add_vary_header(request, response)
|
||||
|
||||
if preflight:
|
||||
_add_max_age_header(request, response)
|
||||
_add_allow_header(request, response)
|
||||
_add_methods_header(request, response)
|
||||
|
||||
@app.before_server_start(priority=PRIORITY)
|
||||
async def _assign_cors_settings(app, _):
|
||||
for group in app.router.groups.values():
|
||||
_cors = SimpleNamespace()
|
||||
for route in group:
|
||||
cors = getattr(route.handler, "__cors__", None)
|
||||
if cors:
|
||||
for key, value in cors.__dict__.items():
|
||||
setattr(_cors, key, value)
|
||||
|
||||
for route in group:
|
||||
route.ctx._cors = _cors
|
||||
|
||||
|
||||
def cors(
|
||||
*,
|
||||
origin: Union[str, Default] = _default,
|
||||
expose_headers: Union[list[str], Default] = _default,
|
||||
allow_headers: Union[list[str], Default] = _default,
|
||||
allow_methods: Union[list[str], Default] = _default,
|
||||
supports_credentials: Union[bool, Default] = _default,
|
||||
max_age: Union[str, int, timedelta, Default] = _default,
|
||||
):
|
||||
def decorator(f):
|
||||
f.__cors__ = SimpleNamespace(
|
||||
_cors_origin=origin,
|
||||
_cors_expose_headers=expose_headers,
|
||||
_cors_supports_credentials=supports_credentials,
|
||||
_cors_allow_origins=(
|
||||
_parse_allow_origins(origin)
|
||||
if origin is not _default
|
||||
else origin
|
||||
),
|
||||
_cors_allow_headers=(
|
||||
_parse_allow_headers(allow_headers)
|
||||
if allow_headers is not _default
|
||||
else allow_headers
|
||||
),
|
||||
_cors_allow_methods=(
|
||||
_parse_allow_methods(allow_methods)
|
||||
if allow_methods is not _default
|
||||
else allow_methods
|
||||
),
|
||||
_cors_max_age=(
|
||||
_parse_max_age(max_age) if max_age is not _default else max_age
|
||||
),
|
||||
)
|
||||
return f
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _setup_cors_settings(app: Sanic) -> None:
|
||||
if app.config.CORS_ORIGINS == "*" and app.config.CORS_SUPPORTS_CREDENTIALS:
|
||||
raise SanicException(
|
||||
"Cannot use supports_credentials in conjunction with "
|
||||
"an origin string of '*'. See: "
|
||||
"http://www.w3.org/TR/cors/#resource-requests"
|
||||
)
|
||||
|
||||
allow_headers = _get_allow_headers(app)
|
||||
allow_methods = _get_allow_methods(app)
|
||||
allow_origins = _get_allow_origins(app)
|
||||
expose_headers = _get_expose_headers(app)
|
||||
max_age = _get_max_age(app)
|
||||
|
||||
app.ctx.cors = CORSSettings(
|
||||
allow_headers=allow_headers,
|
||||
allow_methods=allow_methods,
|
||||
allow_origins=allow_origins,
|
||||
always_send=app.config.CORS_ALWAYS_SEND,
|
||||
automatic_options=app.config.CORS_AUTOMATIC_OPTIONS,
|
||||
expose_headers=expose_headers,
|
||||
max_age=max_age,
|
||||
send_wildcard=(
|
||||
app.config.CORS_SEND_WILDCARD and WILDCARD_PATTERN in allow_origins
|
||||
),
|
||||
supports_credentials=app.config.CORS_SUPPORTS_CREDENTIALS,
|
||||
)
|
||||
|
||||
|
||||
def _get_from_cors_ctx(request: Request, key: str, default: Any = None):
|
||||
if request.route:
|
||||
value = getattr(request.route.ctx._cors, key, default)
|
||||
if value is not _default:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
def _add_origin_header(request: Request, response: HTTPResponse) -> None:
|
||||
request_origin = request.headers.get("origin")
|
||||
origin_value = ""
|
||||
allow_origins = _get_from_cors_ctx(
|
||||
request,
|
||||
"_cors_allow_origins",
|
||||
request.app.ctx.cors.allow_origins,
|
||||
)
|
||||
fallback_origin = _get_from_cors_ctx(
|
||||
request,
|
||||
"_cors_origin",
|
||||
request.app.config.CORS_ORIGINS,
|
||||
)
|
||||
|
||||
if request_origin:
|
||||
if request.app.ctx.cors.send_wildcard:
|
||||
origin_value = "*"
|
||||
else:
|
||||
for pattern in allow_origins:
|
||||
if pattern.match(request_origin):
|
||||
origin_value = request_origin
|
||||
break
|
||||
elif request.app.ctx.cors.always_send:
|
||||
if WILDCARD_PATTERN in allow_origins:
|
||||
origin_value = "*"
|
||||
else:
|
||||
if isinstance(fallback_origin, str) and "," not in fallback_origin:
|
||||
origin_value = fallback_origin
|
||||
else:
|
||||
origin_value = request.app.config.get("SERVER_NAME", "")
|
||||
|
||||
if origin_value:
|
||||
response.headers[ORIGIN_HEADER] = origin_value
|
||||
|
||||
|
||||
def _add_expose_header(request: Request, response: HTTPResponse) -> None:
|
||||
with_credentials = _is_request_with_credentials(request)
|
||||
headers = None
|
||||
expose_headers = _get_from_cors_ctx(
|
||||
request, "_cors_expose_headers", request.app.ctx.cors.expose_headers
|
||||
)
|
||||
# MDN: The value "*" only counts as a special wildcard value for requests
|
||||
# without credentials (requests without HTTP cookies or HTTP
|
||||
# authentication information). In requests with credentials, it is
|
||||
# treated as the literal header name "*" without special semantics.
|
||||
# Note that the Authorization header can't be wildcarded and always
|
||||
# needs to be listed explicitly.
|
||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers
|
||||
if not with_credentials and "*" in expose_headers:
|
||||
headers = ["*"]
|
||||
elif expose_headers:
|
||||
headers = expose_headers
|
||||
|
||||
if headers:
|
||||
response.headers[EXPOSE_HEADER] = ",".join(headers)
|
||||
|
||||
|
||||
def _add_credentials_header(request: Request, response: HTTPResponse) -> None:
|
||||
supports_credentials = _get_from_cors_ctx(
|
||||
request,
|
||||
"_cors_supports_credentials",
|
||||
request.app.ctx.cors.supports_credentials,
|
||||
)
|
||||
if supports_credentials:
|
||||
response.headers[CREDENTIALS_HEADER] = "true"
|
||||
|
||||
|
||||
def _add_allow_header(request: Request, response: HTTPResponse) -> None:
|
||||
with_credentials = _is_request_with_credentials(request)
|
||||
request_headers = {
|
||||
h.strip().lower()
|
||||
for h in request.headers.get(REQUEST_HEADERS_HEADER, "").split(",")
|
||||
}
|
||||
allow_headers = _get_from_cors_ctx(
|
||||
request, "_cors_allow_headers", request.app.ctx.cors.allow_headers
|
||||
)
|
||||
|
||||
# MDN: The value "*" only counts as a special wildcard value for requests
|
||||
# without credentials (requests without HTTP cookies or HTTP
|
||||
# authentication information). In requests with credentials,
|
||||
# it is treated as the literal header name "*" without special semantics.
|
||||
# Note that the Authorization header can't be wildcarded and always needs
|
||||
# to be listed explicitly.
|
||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
|
||||
if not with_credentials and "*" in allow_headers:
|
||||
allow_headers = ["*"]
|
||||
else:
|
||||
allow_headers = request_headers & allow_headers
|
||||
|
||||
if allow_headers:
|
||||
response.headers[ALLOW_HEADERS_HEADER] = ",".join(allow_headers)
|
||||
|
||||
|
||||
def _add_max_age_header(request: Request, response: HTTPResponse) -> None:
|
||||
max_age = _get_from_cors_ctx(
|
||||
request, "_cors_max_age", request.app.ctx.cors.max_age
|
||||
)
|
||||
if max_age:
|
||||
response.headers[MAX_AGE_HEADER] = max_age
|
||||
|
||||
|
||||
def _add_methods_header(request: Request, response: HTTPResponse) -> None:
|
||||
# MDN: The value "*" only counts as a special wildcard value for requests
|
||||
# without credentials (requests without HTTP cookies or HTTP
|
||||
# authentication information). In requests with credentials, it
|
||||
# is treated as the literal method name "*" without
|
||||
# special semantics.
|
||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
|
||||
methods = None
|
||||
with_credentials = _is_request_with_credentials(request)
|
||||
allow_methods = _get_from_cors_ctx(
|
||||
request, "_cors_allow_methods", request.app.ctx.cors.allow_methods
|
||||
)
|
||||
|
||||
if not with_credentials and "*" in allow_methods:
|
||||
methods = {"*"}
|
||||
elif request.route:
|
||||
group = request.app.router.groups.get(request.route.segments)
|
||||
if group:
|
||||
group_methods = {method.lower() for method in group.methods}
|
||||
if allow_methods:
|
||||
methods = group_methods & allow_methods
|
||||
else:
|
||||
methods = group_methods
|
||||
|
||||
if methods:
|
||||
response.headers[ALLOW_METHODS_HEADER] = ",".join(methods).upper()
|
||||
|
||||
|
||||
def _add_vary_header(request: Request, response: HTTPResponse) -> None:
|
||||
allow_origins = _get_from_cors_ctx(
|
||||
request,
|
||||
"_cors_allow_origins",
|
||||
request.app.ctx.cors.allow_origins,
|
||||
)
|
||||
if len(allow_origins) > 1:
|
||||
response.headers[VARY_HEADER] = "origin"
|
||||
|
||||
|
||||
def _get_allow_origins(app: Sanic) -> tuple[re.Pattern, ...]:
|
||||
origins = app.config.CORS_ORIGINS
|
||||
return _parse_allow_origins(origins)
|
||||
|
||||
|
||||
def _parse_allow_origins(
|
||||
value: Union[str, re.Pattern, list[Union[str, re.Pattern]]],
|
||||
) -> tuple[re.Pattern, ...]:
|
||||
origins: Optional[
|
||||
Union[list[str], list[re.Pattern], list[Union[str, re.Pattern]]]
|
||||
] = None
|
||||
if value and isinstance(value, str):
|
||||
if value == "*":
|
||||
origins = [WILDCARD_PATTERN]
|
||||
else:
|
||||
origins = [origin.strip() for origin in value.split(",")]
|
||||
elif isinstance(value, re.Pattern):
|
||||
origins = [value]
|
||||
elif isinstance(value, list):
|
||||
origins = value
|
||||
|
||||
return tuple(
|
||||
pattern
|
||||
if isinstance(pattern, re.Pattern)
|
||||
else re.compile(re.escape(pattern))
|
||||
for pattern in (origins or [])
|
||||
)
|
||||
|
||||
|
||||
def _get_expose_headers(app: Sanic) -> frozenset[str]:
|
||||
expose_headers = (
|
||||
(
|
||||
app.config.CORS_EXPOSE_HEADERS
|
||||
if isinstance(
|
||||
app.config.CORS_EXPOSE_HEADERS, (list, set, frozenset, tuple)
|
||||
)
|
||||
else app.config.CORS_EXPOSE_HEADERS.split(",")
|
||||
)
|
||||
if app.config.CORS_EXPOSE_HEADERS
|
||||
else tuple()
|
||||
)
|
||||
return frozenset(header.lower() for header in expose_headers)
|
||||
|
||||
|
||||
def _get_allow_headers(app: Sanic) -> frozenset[str]:
|
||||
return _parse_allow_headers(app.config.CORS_ALLOW_HEADERS)
|
||||
|
||||
|
||||
def _parse_allow_headers(value: str) -> frozenset[str]:
|
||||
allow_headers = (
|
||||
(
|
||||
value
|
||||
if isinstance(
|
||||
value,
|
||||
(list, set, frozenset, tuple),
|
||||
)
|
||||
else value.split(",")
|
||||
)
|
||||
if value
|
||||
else tuple()
|
||||
)
|
||||
return frozenset(header.lower() for header in allow_headers)
|
||||
|
||||
|
||||
def _get_max_age(app: Sanic) -> str:
|
||||
return _parse_max_age(app.config.CORS_MAX_AGE or "")
|
||||
|
||||
|
||||
def _parse_max_age(value) -> str:
|
||||
max_age = value or ""
|
||||
if isinstance(max_age, timedelta):
|
||||
max_age = str(int(max_age.total_seconds()))
|
||||
return str(max_age)
|
||||
|
||||
|
||||
def _get_allow_methods(app: Sanic) -> frozenset[str]:
|
||||
return _parse_allow_methods(app.config.CORS_METHODS)
|
||||
|
||||
|
||||
def _parse_allow_methods(value) -> frozenset[str]:
|
||||
allow_methods = (
|
||||
(
|
||||
value
|
||||
if isinstance(
|
||||
value,
|
||||
(list, set, frozenset, tuple),
|
||||
)
|
||||
else value.split(",")
|
||||
)
|
||||
if value
|
||||
else tuple()
|
||||
)
|
||||
return frozenset(method.lower() for method in allow_methods)
|
||||
|
||||
|
||||
def _is_request_with_credentials(request: Request) -> bool:
|
||||
return bool(request.headers.get("authorization") or request.cookies)
|
||||
@@ -0,0 +1,34 @@
|
||||
from ...exceptions import InitError
|
||||
from ..base import Extension
|
||||
from .cors import add_cors
|
||||
from .methods import add_auto_handlers, add_http_methods
|
||||
|
||||
|
||||
class HTTPExtension(Extension):
|
||||
name = "http"
|
||||
|
||||
def startup(self, _) -> None:
|
||||
self.all_methods: bool = self.config.HTTP_ALL_METHODS
|
||||
self.auto_head: bool = self.config.HTTP_AUTO_HEAD
|
||||
self.auto_options: bool = self.config.HTTP_AUTO_OPTIONS
|
||||
self.auto_trace: bool = self.config.HTTP_AUTO_TRACE
|
||||
self.cors: bool = self.config.CORS
|
||||
|
||||
if self.all_methods:
|
||||
add_http_methods(self.app, ["CONNECT", "TRACE"])
|
||||
|
||||
if self.auto_head or self.auto_options or self.auto_trace:
|
||||
add_auto_handlers(
|
||||
self.app, self.auto_head, self.auto_options, self.auto_trace
|
||||
)
|
||||
|
||||
if self.cors:
|
||||
add_cors(self.app)
|
||||
else:
|
||||
return
|
||||
|
||||
if self.app.ctx.cors.automatic_options and not self.auto_options:
|
||||
raise InitError(
|
||||
"Configuration mismatch. If CORS_AUTOMATIC_OPTIONS is set to "
|
||||
"True, then you must run SanicExt with auto_options=True"
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
from collections.abc import Sequence
|
||||
from functools import partial
|
||||
from inspect import isawaitable
|
||||
from operator import itemgetter
|
||||
from typing import Union
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.constants import HTTPMethod
|
||||
from sanic.exceptions import SanicException
|
||||
from sanic.response import empty, raw
|
||||
|
||||
from sanic_ext.config import PRIORITY
|
||||
from sanic_ext.extensions.openapi import openapi
|
||||
from sanic_ext.utils.route import clean_route_name
|
||||
|
||||
|
||||
def add_http_methods(
|
||||
app: Sanic, methods: Sequence[Union[str, HTTPMethod]]
|
||||
) -> None:
|
||||
"""
|
||||
Adds HTTP methods to an app
|
||||
|
||||
:param app: Your Sanic app
|
||||
:type app: Sanic
|
||||
:param methods: The http methods being added, eg: CONNECT, TRACE
|
||||
:type methods: Sequence[str]
|
||||
"""
|
||||
|
||||
app.router.ALLOWED_METHODS = tuple(
|
||||
[*app.router.ALLOWED_METHODS, *methods] # type: ignore
|
||||
)
|
||||
|
||||
|
||||
def add_auto_handlers(
|
||||
app: Sanic, auto_head: bool, auto_options: bool, auto_trace: bool
|
||||
) -> None:
|
||||
if auto_trace and "TRACE" not in app.router.ALLOWED_METHODS:
|
||||
raise SanicException(
|
||||
"Cannot use apply(..., auto_trace=True) if TRACE is not an "
|
||||
"allowed HTTP method. Make sure apply(..., all_http_methods=True) "
|
||||
"has been set."
|
||||
)
|
||||
|
||||
async def head_handler(request, get_handler, *args, **kwargs):
|
||||
retval = get_handler(request, *args, **kwargs)
|
||||
if isawaitable(retval):
|
||||
retval = await retval
|
||||
return retval
|
||||
|
||||
async def options_handler(request, methods, *args, **kwargs):
|
||||
resp = empty()
|
||||
resp.headers["allow"] = ",".join([*methods, "OPTIONS"])
|
||||
return resp
|
||||
|
||||
async def trace_handler(request):
|
||||
cleaned_head = b""
|
||||
for line in request.head.split(b"\r\n"):
|
||||
first_word, _ = line.split(b" ", 1)
|
||||
|
||||
if (
|
||||
first_word.lower().replace(b":", b"").decode("utf-8")
|
||||
not in request.app.config.TRACE_EXCLUDED_HEADERS
|
||||
):
|
||||
cleaned_head += line + b"\r\n"
|
||||
|
||||
message = "\r\n\r\n".join(
|
||||
[part.decode("utf-8") for part in [cleaned_head, request.body]]
|
||||
)
|
||||
return raw(message, content_type="message/http")
|
||||
|
||||
@app.before_server_start(priority=PRIORITY)
|
||||
def _add_handlers(app, _):
|
||||
nonlocal auto_head
|
||||
nonlocal auto_options
|
||||
|
||||
if auto_head:
|
||||
app.router.reset()
|
||||
for group in app.router.groups.values():
|
||||
if "GET" in group.methods and "HEAD" not in group.methods:
|
||||
for route in group:
|
||||
if "GET" in route.methods:
|
||||
host = route.requirements.get("host")
|
||||
name = f"{route.name}_head"
|
||||
handler = openapi.definition(
|
||||
summary=clean_route_name(route.name).title(),
|
||||
description="Retrieve HEAD details",
|
||||
)(partial(head_handler, get_handler=route.handler))
|
||||
handler.__auto_handler__ = True
|
||||
handler.__route_handler__ = route.handler
|
||||
app.add_route(
|
||||
handler=handler,
|
||||
uri=group.uri,
|
||||
methods=["HEAD"],
|
||||
strict_slashes=group.strict,
|
||||
name=name,
|
||||
host=host,
|
||||
unquote=group.unquote,
|
||||
)
|
||||
app.finalize()
|
||||
|
||||
if auto_trace:
|
||||
app.router.reset()
|
||||
for group in app.router.groups.values():
|
||||
if "TRACE" not in group.methods:
|
||||
app.add_route(
|
||||
handler=trace_handler,
|
||||
uri=group.uri,
|
||||
methods=["TRACE"],
|
||||
strict_slashes=group.strict,
|
||||
)
|
||||
app.finalize()
|
||||
|
||||
if auto_options:
|
||||
app.router.reset()
|
||||
for group in app.router.groups.values():
|
||||
if "OPTIONS" not in group.methods:
|
||||
if not group.requirements:
|
||||
hosts = [None]
|
||||
else:
|
||||
hosts = set(
|
||||
map(itemgetter("host"), group.requirements)
|
||||
)
|
||||
|
||||
try:
|
||||
base_route = next(
|
||||
r for r in group if not r.name.endswith("_head")
|
||||
)
|
||||
except StopIteration:
|
||||
base_route = group[0]
|
||||
|
||||
name = f"{base_route.name}_options"
|
||||
handler = openapi.definition(
|
||||
summary=clean_route_name(base_route.name).title(),
|
||||
description="Retrieve OPTIONS details",
|
||||
)(partial(options_handler, methods=group.methods))
|
||||
handler.__auto_handler__ = True
|
||||
app.add_route(
|
||||
handler=handler,
|
||||
uri=group.uri,
|
||||
methods=["OPTIONS"],
|
||||
strict_slashes=group.strict,
|
||||
name=name,
|
||||
host=hosts,
|
||||
unquote=group.unquote,
|
||||
)
|
||||
app.finalize()
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import is_dataclass
|
||||
from inspect import isclass, iscoroutine
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Optional,
|
||||
get_args,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from sanic import Request
|
||||
from sanic.app import Sanic
|
||||
from sanic.exceptions import ServerError
|
||||
|
||||
from sanic_ext.exceptions import InitError
|
||||
from sanic_ext.utils.typing import (
|
||||
is_attrs,
|
||||
is_msgspec,
|
||||
is_optional,
|
||||
is_pydantic,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .registry import ConstantRegistry, InjectionRegistry
|
||||
|
||||
|
||||
class Constructor:
|
||||
EXEMPT_ANNOTATIONS = (Request,)
|
||||
|
||||
def __init__(
|
||||
self, func: Callable[..., Any], request_arg: Optional[str] = None
|
||||
):
|
||||
self.func = func
|
||||
self.injections: dict[str, tuple[type, Constructor]] = {}
|
||||
self.constants: dict[str, Any] = {}
|
||||
self.pass_kwargs: bool = False
|
||||
self.request_arg = request_arg
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"<{self.__class__.__name__}:{self.func.__name__}>"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__}(func={self.func.__name__})>"
|
||||
|
||||
async def __call__(self, request, **kwargs):
|
||||
try:
|
||||
args = await gather_args(self.injections, request, **kwargs)
|
||||
args.update(self.constants)
|
||||
if self.pass_kwargs:
|
||||
args.update(kwargs)
|
||||
|
||||
if self.request_arg:
|
||||
args[self.request_arg] = request
|
||||
|
||||
retval = self.func(**args)
|
||||
|
||||
if iscoroutine(retval):
|
||||
retval = await retval
|
||||
return retval
|
||||
except TypeError as e:
|
||||
raise ServerError(
|
||||
"Failure to inject dependencies. Make sure that all "
|
||||
f"dependencies for '{self.func.__name__}' have been "
|
||||
"registered."
|
||||
) from e
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
app: Sanic,
|
||||
injection_registry: InjectionRegistry,
|
||||
constant_registry: ConstantRegistry,
|
||||
allowed_types: set[type[object]],
|
||||
) -> None:
|
||||
hints = self._get_hints()
|
||||
hints.pop("return", None)
|
||||
missing = []
|
||||
for param, annotation in hints.items():
|
||||
if param in constant_registry:
|
||||
self.constants[param] = getattr(app.config, param.upper())
|
||||
if annotation in allowed_types:
|
||||
self.pass_kwargs = True
|
||||
if is_optional(annotation):
|
||||
annotation = get_args(annotation)[0]
|
||||
if not isclass(annotation):
|
||||
missing.append((param, annotation))
|
||||
continue
|
||||
if issubclass(annotation, Request):
|
||||
self.request_arg = param
|
||||
if (
|
||||
annotation not in self.EXEMPT_ANNOTATIONS
|
||||
and not issubclass(annotation, self.EXEMPT_ANNOTATIONS)
|
||||
and annotation not in allowed_types
|
||||
):
|
||||
dependency = injection_registry.get(annotation)
|
||||
if not dependency:
|
||||
missing.append((param, annotation))
|
||||
continue
|
||||
self.injections[param] = (annotation, dependency)
|
||||
|
||||
if missing:
|
||||
dependencies = "\n".join(
|
||||
[f" - {param}: {annotation}" for param, annotation in missing]
|
||||
)
|
||||
raise InitError(
|
||||
"Unable to resolve dependencies for "
|
||||
f"'{self.func.__name__}'. Could not find the following "
|
||||
f"dependencies:\n{dependencies}.\nMake sure the dependencies "
|
||||
"are declared using ext.injection. See "
|
||||
"https://sanicframework.org/en/plugins/sanic-ext/injection."
|
||||
"html#injecting-services for more details."
|
||||
)
|
||||
|
||||
checked: set[type[object]] = set()
|
||||
current: set[type[object]] = set()
|
||||
self.check_circular(checked, current)
|
||||
|
||||
def check_circular(
|
||||
self,
|
||||
checked: set[type[object]],
|
||||
current: set[type[object]],
|
||||
) -> None:
|
||||
dependencies = set(self.injections.values())
|
||||
for dependency, constructor in dependencies:
|
||||
self._visit(dependency, constructor, checked, current)
|
||||
|
||||
def _visit(
|
||||
self,
|
||||
dependency: type[object],
|
||||
constructor: Constructor,
|
||||
checked: set[type[object]],
|
||||
current: set[type[object]],
|
||||
):
|
||||
if dependency in checked:
|
||||
return
|
||||
elif dependency in current:
|
||||
raise InitError(
|
||||
"Circular dependency injection detected on "
|
||||
f"'{self.func.__name__}'. Check dependencies of "
|
||||
f"'{constructor.func.__name__}' which may contain "
|
||||
f"circular dependency chain with {dependency}."
|
||||
)
|
||||
|
||||
current.add(dependency)
|
||||
constructor.check_circular(checked, current)
|
||||
current.remove(dependency)
|
||||
checked.add(dependency)
|
||||
|
||||
def _get_hints(self):
|
||||
if (
|
||||
not isclass(self.func)
|
||||
or is_dataclass(self.func)
|
||||
or is_attrs(self.func)
|
||||
or is_pydantic(self.func)
|
||||
or is_msgspec(self.func)
|
||||
):
|
||||
return get_type_hints(self.func)
|
||||
elif isclass(self.func):
|
||||
return get_type_hints(self.func.__init__)
|
||||
raise InitError(f"Cannot get type hints for {self.func}")
|
||||
|
||||
|
||||
async def gather_args(injections, request, **kwargs) -> dict[str, Any]:
|
||||
return {
|
||||
name: await do_cast(_type, constructor, request, **kwargs)
|
||||
for name, (_type, constructor) in injections.items()
|
||||
}
|
||||
|
||||
|
||||
async def do_cast(_type, constructor, request, **kwargs):
|
||||
cast = constructor if constructor else _type
|
||||
args = [request] if constructor else []
|
||||
|
||||
retval = cast(*args, **kwargs)
|
||||
if iscoroutine(retval):
|
||||
retval = await retval
|
||||
return retval
|
||||
@@ -0,0 +1,22 @@
|
||||
from ..base import Extension
|
||||
from .injector import add_injection
|
||||
from .registry import ConstantRegistry, InjectionRegistry
|
||||
|
||||
|
||||
class InjectionExtension(Extension):
|
||||
name = "injection"
|
||||
|
||||
def startup(self, bootstrap) -> None:
|
||||
self.constant_registry = ConstantRegistry(self.app.config)
|
||||
self.registry = InjectionRegistry()
|
||||
add_injection(self.app, self.registry, self.constant_registry)
|
||||
bootstrap._injection_registry = self.registry
|
||||
bootstrap._constant_registry = self.constant_registry
|
||||
if self.config.INJECTION_LOAD_CUSTOM_CONSTANTS:
|
||||
self.app.ext.load_constants()
|
||||
|
||||
def label(self):
|
||||
return (
|
||||
f"{self.registry.length} dependencies; "
|
||||
f"{self.constant_registry.length} constants"
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from inspect import getmembers, isclass, isfunction
|
||||
from typing import Any, Callable, Optional, get_type_hints
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.constants import HTTP_METHODS
|
||||
|
||||
from sanic_ext.config import PRIORITY
|
||||
from sanic_ext.extensions.injection.constructor import gather_args
|
||||
|
||||
from .registry import ConstantRegistry, InjectionRegistry, SignatureRegistry
|
||||
|
||||
|
||||
def add_injection(
|
||||
app: Sanic,
|
||||
injection_registry: InjectionRegistry,
|
||||
constant_registry: ConstantRegistry,
|
||||
) -> None:
|
||||
signature_registry = _setup_signature_registry(
|
||||
app, injection_registry, constant_registry
|
||||
)
|
||||
|
||||
@app.listener("before_server_start", priority=PRIORITY)
|
||||
async def finalize_injections(app: Sanic, _):
|
||||
router_converters = {
|
||||
allowed[0] for allowed in app.router.regex_types.values()
|
||||
}
|
||||
router_types = set()
|
||||
for converter in router_converters:
|
||||
if isclass(converter):
|
||||
router_types.add(converter)
|
||||
elif isfunction(converter):
|
||||
hints = get_type_hints(converter)
|
||||
if return_type := hints.get("return"):
|
||||
router_types.add(return_type)
|
||||
injection_registry.finalize(app, constant_registry, router_types)
|
||||
|
||||
injection_signal = app.ext.config.INJECTION_SIGNAL
|
||||
injection_priority = app.ext.config.INJECTION_PRIORITY
|
||||
|
||||
@app.signal(injection_signal, priority=injection_priority)
|
||||
async def inject_kwargs(request, **_):
|
||||
nonlocal signature_registry
|
||||
|
||||
for name in (
|
||||
request.route.name,
|
||||
f"{request.route.name}_{request.method.lower()}",
|
||||
):
|
||||
dependencies, constants = signature_registry.get(
|
||||
name, (None, None)
|
||||
)
|
||||
if dependencies or constants:
|
||||
break
|
||||
|
||||
if dependencies:
|
||||
injected_args = await gather_args(
|
||||
dependencies, request, **request.match_info
|
||||
)
|
||||
request.match_info.update(injected_args)
|
||||
if constants:
|
||||
request.match_info.update(constants)
|
||||
|
||||
|
||||
def _http_method_predicate(member):
|
||||
return isfunction(member) and member.__name__ in HTTP_METHODS
|
||||
|
||||
|
||||
def _setup_signature_registry(
|
||||
app: Sanic,
|
||||
injection_registry: InjectionRegistry,
|
||||
constant_registry: ConstantRegistry,
|
||||
) -> SignatureRegistry:
|
||||
registry = SignatureRegistry()
|
||||
|
||||
@app.listener("before_server_start", priority=PRIORITY - 1)
|
||||
async def setup_signatures(app, _):
|
||||
nonlocal registry
|
||||
|
||||
for route in app.router.routes:
|
||||
if ".openapi." in route.name:
|
||||
continue
|
||||
handlers = [(route.name, route.handler)]
|
||||
viewclass = getattr(route.handler, "view_class", None)
|
||||
if viewclass:
|
||||
handlers = [
|
||||
(f"{route.name}_{name}", member)
|
||||
for name, member in getmembers(
|
||||
viewclass, _http_method_predicate
|
||||
)
|
||||
]
|
||||
for name, handler in handlers:
|
||||
if route_handler := getattr(
|
||||
handler, "__route_handler__", None
|
||||
):
|
||||
handler = route_handler
|
||||
if isinstance(handler, partial):
|
||||
if handler.func == app._websocket_handler:
|
||||
handler = handler.args[0]
|
||||
else:
|
||||
handler = handler.func
|
||||
try:
|
||||
hints = get_type_hints(handler)
|
||||
except TypeError:
|
||||
continue
|
||||
|
||||
dependencies: dict[
|
||||
str, tuple[type, Optional[Callable[..., Any]]]
|
||||
] = {}
|
||||
constants: dict[str, Any] = {}
|
||||
for param, annotation in hints.items():
|
||||
if annotation in injection_registry:
|
||||
dependencies[param] = (
|
||||
annotation,
|
||||
injection_registry[annotation],
|
||||
)
|
||||
if param in constant_registry:
|
||||
constants[param] = app.config[param.upper()]
|
||||
|
||||
registry.register(name, dependencies, constants)
|
||||
|
||||
return registry
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from sanic.app import Sanic
|
||||
from sanic.config import Config
|
||||
|
||||
from .constructor import Constructor
|
||||
|
||||
|
||||
class InjectionRegistry:
|
||||
def __init__(self):
|
||||
self._registry: dict[type, Optional[Callable[..., Any]]] = {}
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._registry[key]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self._registry)
|
||||
|
||||
def __contains__(self, other: Any):
|
||||
return other in self._registry
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._registry.get(key, default)
|
||||
|
||||
def register(
|
||||
self,
|
||||
_type: type,
|
||||
constructor: Optional[Callable[..., Any]],
|
||||
request_arg: Optional[str] = None,
|
||||
) -> None:
|
||||
constructor = constructor or _type
|
||||
constructor = Constructor(constructor, request_arg=request_arg)
|
||||
self._registry[_type] = constructor
|
||||
|
||||
def finalize(
|
||||
self, app: Sanic, constant_registry: ConstantRegistry, allowed_types
|
||||
):
|
||||
for constructor in self._registry.values():
|
||||
if isinstance(constructor, Constructor):
|
||||
constructor.prepare(
|
||||
app, self, constant_registry, allowed_types
|
||||
)
|
||||
|
||||
@property
|
||||
def length(self):
|
||||
return len(self._registry)
|
||||
|
||||
|
||||
class SignatureRegistry:
|
||||
def __init__(self):
|
||||
self._registry: dict[
|
||||
str,
|
||||
tuple[
|
||||
dict[str, tuple[type, Optional[Callable[..., Any]]]],
|
||||
dict[str, Any],
|
||||
],
|
||||
] = {}
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._registry[key]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self._registry)
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._registry.get(key, default)
|
||||
|
||||
def register(
|
||||
self,
|
||||
route_name: str,
|
||||
dependencies: dict[str, tuple[type, Optional[Callable[..., Any]]]],
|
||||
constants: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self._registry[route_name] = (dependencies, constants or {})
|
||||
|
||||
|
||||
class ConstantRegistry:
|
||||
def __init__(self, config: Config):
|
||||
self._config = config
|
||||
self._registry: set[str] = set()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self._registry)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._registry)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._registry[key]
|
||||
|
||||
def __contains__(self, other: Any):
|
||||
return other in self._registry
|
||||
|
||||
def register(self, key: str, value: Any, overwrite: bool):
|
||||
attribute = key.upper()
|
||||
if attribute in self._config and not overwrite:
|
||||
raise ValueError(
|
||||
f"A value for {attribute} has already been assigned"
|
||||
)
|
||||
key = key.lower()
|
||||
setattr(self._config, attribute, value)
|
||||
return self._registry.add(key)
|
||||
|
||||
def get(self, key: str):
|
||||
key = key.lower()
|
||||
if key not in self._registry:
|
||||
raise ValueError
|
||||
attribute = key.upper()
|
||||
return getattr(self._config, attribute)
|
||||
|
||||
@property
|
||||
def length(self):
|
||||
return len(self._registry)
|
||||
@@ -0,0 +1,24 @@
|
||||
from sanic.exceptions import SanicException
|
||||
|
||||
from ..base import Extension
|
||||
from .logger import Logger
|
||||
|
||||
|
||||
class LoggingExtension(Extension):
|
||||
name = "logging"
|
||||
MIN_VERSION = (22, 9)
|
||||
|
||||
def startup(self, bootstrap) -> None:
|
||||
if self.included():
|
||||
if self.MIN_VERSION > bootstrap.sanic_version:
|
||||
min_version = ".".join(map(str, self.MIN_VERSION))
|
||||
sanic_version = ".".join(map(str, bootstrap.sanic_version))
|
||||
raise SanicException(
|
||||
f"The logging extension only works with Sanic "
|
||||
f"v{min_version} and above. It looks like you are "
|
||||
f"running {sanic_version}."
|
||||
)
|
||||
Logger.setup(self.app)
|
||||
|
||||
def included(self):
|
||||
return self.config.LOGGING
|
||||
@@ -0,0 +1,116 @@
|
||||
import logging
|
||||
|
||||
from typing import Any, Optional, TypedDict
|
||||
|
||||
|
||||
class LoggerConfig(TypedDict):
|
||||
level: str
|
||||
propagate: bool
|
||||
handlers: list[str]
|
||||
|
||||
|
||||
class HandlerConfig(TypedDict):
|
||||
class_: str
|
||||
level: str
|
||||
stream: Optional[str]
|
||||
formatter: Optional[str]
|
||||
|
||||
|
||||
class FormatterConfig(TypedDict):
|
||||
class_: str
|
||||
format: Optional[str]
|
||||
datefmt: Optional[str]
|
||||
|
||||
|
||||
class LoggingConfig(TypedDict):
|
||||
version: int
|
||||
disable_existing_loggers: bool
|
||||
formatters: dict[str, FormatterConfig]
|
||||
handlers: dict[str, HandlerConfig]
|
||||
loggers: dict[str, LoggerConfig]
|
||||
|
||||
|
||||
class LoggingConfigExtractor:
|
||||
def __init__(self):
|
||||
self.version = 1
|
||||
self.disable_existing_loggers = False
|
||||
self.formatters: dict[str, FormatterConfig] = {}
|
||||
self.handlers: dict[str, HandlerConfig] = {}
|
||||
self.loggers: dict[str, LoggerConfig] = {}
|
||||
|
||||
def add_logger(self, logger: logging.Logger):
|
||||
self._extract_logger_config(logger)
|
||||
self._extract_handlers(logger)
|
||||
|
||||
def compile(self) -> LoggingConfig:
|
||||
output = {
|
||||
"version": self.version,
|
||||
"disable_existing_loggers": self.disable_existing_loggers,
|
||||
"formatters": self.formatters,
|
||||
"handlers": self.handlers,
|
||||
"loggers": self.loggers,
|
||||
}
|
||||
return self._clean(output)
|
||||
|
||||
def _extract_logger_config(self, logger: logging.Logger):
|
||||
config: LoggerConfig = {
|
||||
"level": logging.getLevelName(logger.level),
|
||||
"propagate": logger.propagate,
|
||||
"handlers": [handler.get_name() for handler in logger.handlers],
|
||||
}
|
||||
self.loggers[logger.name] = config
|
||||
|
||||
def _extract_handlers(self, logger: logging.Logger):
|
||||
for handler in logger.handlers:
|
||||
self._extract_handler_config(handler)
|
||||
|
||||
def _extract_handler_config(self, handler: logging.Handler):
|
||||
handler_name = handler.get_name()
|
||||
if handler_name in self.handlers:
|
||||
return
|
||||
config: HandlerConfig = {
|
||||
"class_": self._full_name(handler),
|
||||
"level": logging.getLevelName(handler.level),
|
||||
"formatter": (
|
||||
self._formatter_name(handler.formatter)
|
||||
if handler.formatter
|
||||
else None
|
||||
),
|
||||
"stream": None,
|
||||
}
|
||||
# if (stream := getattr(handler, "stream", None)) and (
|
||||
# stream_name := getattr(stream, "name", None)
|
||||
# ):
|
||||
# config["stream"] = stream_name
|
||||
self.handlers[handler_name] = config
|
||||
if handler.formatter:
|
||||
self._extract_formatter_config(handler.formatter)
|
||||
|
||||
def _extract_formatter_config(self, formatter: logging.Formatter):
|
||||
formatter_name = self._formatter_name(formatter)
|
||||
if formatter_name in self.formatters:
|
||||
return
|
||||
config: FormatterConfig = {
|
||||
"class_": self._full_name(formatter),
|
||||
"format": formatter._fmt,
|
||||
"datefmt": formatter.datefmt,
|
||||
}
|
||||
self.formatters[formatter_name] = config
|
||||
|
||||
def _clean(self, d: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
k.replace("class_", "class"): self._clean(v)
|
||||
if isinstance(v, dict)
|
||||
else v
|
||||
for k, v in d.items()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _formatter_name(
|
||||
formatter: logging.Formatter, prefix: str = "formatter"
|
||||
):
|
||||
return f"{prefix}_{formatter.__class__.__name__}".lower()
|
||||
|
||||
@staticmethod
|
||||
def _full_name(obj):
|
||||
return f"{obj.__module__}.{obj.__class__.__name__}"
|
||||
@@ -0,0 +1,126 @@
|
||||
import logging
|
||||
|
||||
from collections import defaultdict
|
||||
from logging import LogRecord
|
||||
from logging.handlers import QueueHandler
|
||||
from multiprocessing import Manager
|
||||
from queue import Empty, Full
|
||||
from signal import SIGINT, SIGTERM
|
||||
from signal import signal as signal_func
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.log import logger as root_logger
|
||||
from sanic.log import logger as server_logger
|
||||
from sanic.logging.setup import setup_logging
|
||||
|
||||
from sanic_ext.extensions.logging.extractor import LoggingConfigExtractor
|
||||
|
||||
|
||||
async def prepare_logger(app: Sanic, *_):
|
||||
Logger.prepare(app)
|
||||
|
||||
|
||||
async def setup_logger(app: Sanic, *_):
|
||||
logger = Logger()
|
||||
extractor = LoggingConfigExtractor()
|
||||
for logger_name in app.config.LOGGERS:
|
||||
extractor.add_logger(logging.getLogger(logger_name))
|
||||
app.manager.manage(
|
||||
"Logger",
|
||||
logger,
|
||||
{
|
||||
"queue": app.shared_ctx.logger_queue,
|
||||
"config": extractor.compile(),
|
||||
},
|
||||
transient=True,
|
||||
)
|
||||
|
||||
|
||||
class SanicQueueHandler(QueueHandler):
|
||||
def emit(self, record: LogRecord) -> None:
|
||||
try:
|
||||
return super().enqueue(record)
|
||||
except Full:
|
||||
server_logger.warning(
|
||||
"Background logger is full. Emitting log in process."
|
||||
)
|
||||
server_logger.handle(record)
|
||||
|
||||
|
||||
async def setup_server_logging(app: Sanic):
|
||||
qhandler = SanicQueueHandler(app.shared_ctx.logger_queue)
|
||||
app.ctx._logger_handlers = defaultdict(list)
|
||||
app.ctx._qhandler = qhandler
|
||||
|
||||
for logger_name in app.config.LOGGERS:
|
||||
logger_instance = logging.getLogger(logger_name)
|
||||
for handler in logger_instance.handlers:
|
||||
logger_instance.removeHandler(handler)
|
||||
logger_instance.addHandler(qhandler)
|
||||
|
||||
|
||||
async def remove_server_logging(app: Sanic):
|
||||
for logger, handlers in app.ctx._logger_handlers.items():
|
||||
logger.removeHandler(app.ctx._qhandler)
|
||||
for handler in handlers:
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
class Logger:
|
||||
LOGGERS: list[str] = []
|
||||
|
||||
def __init__(self):
|
||||
self.run = True
|
||||
self.loggers = {
|
||||
logger: logging.getLogger(logger) for logger in self.LOGGERS
|
||||
}
|
||||
|
||||
def __call__(self, queue, config) -> None:
|
||||
signal_func(SIGINT, self.stop)
|
||||
signal_func(SIGTERM, self.stop)
|
||||
|
||||
logging.config.dictConfig(config)
|
||||
|
||||
setup_loggers = set(config["loggers"].keys())
|
||||
enabled_loggers = set(self.loggers.keys())
|
||||
missing = enabled_loggers - setup_loggers
|
||||
root_logger.info(
|
||||
f"Setup background logging for: {', '.join(setup_loggers)}"
|
||||
)
|
||||
if missing:
|
||||
root_logger.warning(
|
||||
f"Logger config not found for: {', '.join(missing)}"
|
||||
)
|
||||
setup_logging(True, no_color=False, log_extra=True)
|
||||
|
||||
while self.run:
|
||||
try:
|
||||
record: LogRecord = queue.get(timeout=0.05)
|
||||
except Empty:
|
||||
continue
|
||||
logger = self.loggers.get(record.name)
|
||||
logger.handle(record)
|
||||
|
||||
def stop(self, *_):
|
||||
if self.run:
|
||||
self.run = False
|
||||
|
||||
@classmethod
|
||||
def update_cls_loggers(cls, logger_names: list[str]):
|
||||
cls.LOGGERS = logger_names
|
||||
|
||||
@classmethod
|
||||
def prepare(cls, app: Sanic):
|
||||
sync_manager = Manager()
|
||||
logger_queue = sync_manager.Queue(
|
||||
maxsize=app.config.LOGGING_QUEUE_MAX_SIZE
|
||||
)
|
||||
app.shared_ctx.logger_queue = logger_queue
|
||||
cls.update_cls_loggers(app.config.LOGGERS)
|
||||
|
||||
@classmethod
|
||||
def setup(cls, app: Sanic):
|
||||
app.main_process_start(prepare_logger)
|
||||
app.main_process_ready(setup_logger)
|
||||
app.before_server_start(setup_server_logging)
|
||||
app.before_server_stop(remove_server_logging)
|
||||
@@ -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>
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from inspect import isawaitable
|
||||
from typing import TYPE_CHECKING, Optional, Union
|
||||
|
||||
from jinja2 import Environment
|
||||
from sanic.compat import Header
|
||||
from sanic.request import Request
|
||||
from sanic.response import HTTPResponse
|
||||
|
||||
from sanic_ext.extensions.templating.render import (
|
||||
LazyResponse,
|
||||
TemplateResponse,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic_ext import Config
|
||||
|
||||
|
||||
class Templating:
|
||||
def __init__(self, environment: Environment, config: Config) -> None:
|
||||
self.environment = environment
|
||||
self.config = config
|
||||
|
||||
def template(
|
||||
self,
|
||||
file_name: str,
|
||||
status: int = 200,
|
||||
headers: Optional[Union[Header, dict[str, str]]] = None,
|
||||
content_type: str = "text/html; charset=utf-8",
|
||||
**kwargs,
|
||||
):
|
||||
template = self.environment.get_template(file_name)
|
||||
render = (
|
||||
template.render_async
|
||||
if self.config.TEMPLATING_ENABLE_ASYNC
|
||||
else template.render
|
||||
)
|
||||
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def decorated_function(*args, **kwargs):
|
||||
context = f(*args, **kwargs)
|
||||
if isawaitable(context):
|
||||
context = await context
|
||||
if isinstance(context, HTTPResponse) and not isinstance(
|
||||
context, TemplateResponse
|
||||
):
|
||||
return context
|
||||
|
||||
# TODO
|
||||
# - Allow each of these to be a callable that is executed here
|
||||
params = {
|
||||
"status": status,
|
||||
"content_type": content_type,
|
||||
"headers": headers,
|
||||
}
|
||||
|
||||
if isinstance(context, LazyResponse):
|
||||
for attr in ("status", "headers", "content_type"):
|
||||
value = getattr(context, attr, None)
|
||||
if value:
|
||||
params[attr] = value
|
||||
context = context.context
|
||||
|
||||
context["request"] = Request.get_current()
|
||||
|
||||
content = render(**context)
|
||||
if isawaitable(content):
|
||||
content = await content
|
||||
|
||||
return HTTPResponse(content, **params)
|
||||
|
||||
return decorated_function
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from collections import abc
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Union
|
||||
|
||||
from jinja2 import (
|
||||
Environment,
|
||||
FileSystemLoader,
|
||||
__version__,
|
||||
select_autoescape,
|
||||
)
|
||||
|
||||
from sanic_ext.extensions.templating.engine import Templating
|
||||
|
||||
from ..base import Extension
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic_ext import Extend
|
||||
|
||||
|
||||
class TemplatingExtension(Extension):
|
||||
name = "templating"
|
||||
|
||||
def startup(self, bootstrap: Extend) -> None:
|
||||
self._add_template_paths_to_reloader(
|
||||
self.config.TEMPLATING_PATH_TO_TEMPLATES
|
||||
)
|
||||
loader = FileSystemLoader(self.config.TEMPLATING_PATH_TO_TEMPLATES)
|
||||
|
||||
if not hasattr(bootstrap, "environment"):
|
||||
bootstrap.environment = Environment(
|
||||
loader=loader,
|
||||
autoescape=select_autoescape(),
|
||||
enable_async=self.config.TEMPLATING_ENABLE_ASYNC,
|
||||
)
|
||||
if not hasattr(bootstrap, "templating"):
|
||||
bootstrap.templating = Templating(
|
||||
environment=bootstrap.environment, config=self.config
|
||||
)
|
||||
bootstrap.templating.environment.globals["url_for"] = self.app.url_for
|
||||
|
||||
def label(self):
|
||||
return f"jinja2=={__version__}"
|
||||
|
||||
def _add_template_paths_to_reloader(
|
||||
self, path: Union[str, os.PathLike, Sequence[Union[str, os.PathLike]]]
|
||||
) -> None:
|
||||
if not isinstance(path, abc.Iterable) or isinstance(path, str):
|
||||
path = [path]
|
||||
|
||||
for item in path:
|
||||
self.app.state.reload_dirs.add(Path(item))
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from inspect import isawaitable
|
||||
from typing import TYPE_CHECKING, Any, Optional, Union
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.compat import Header
|
||||
from sanic.exceptions import SanicException
|
||||
from sanic.request import Request
|
||||
from sanic.response import HTTPResponse
|
||||
|
||||
from sanic_ext.exceptions import ExtensionNotFound
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from jinja2 import Environment
|
||||
|
||||
|
||||
class TemplateResponse(HTTPResponse): ...
|
||||
|
||||
|
||||
class LazyResponse(TemplateResponse):
|
||||
__slots__ = (
|
||||
"body",
|
||||
"status",
|
||||
"content_type",
|
||||
"headers",
|
||||
"_cookies",
|
||||
"context",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context: dict[str, Any],
|
||||
status: int = 0,
|
||||
headers: Optional[Union[Header, dict[str, str]]] = None,
|
||||
content_type: Optional[str] = None,
|
||||
):
|
||||
super().__init__(
|
||||
content_type=content_type, status=status, headers=headers
|
||||
)
|
||||
self.context = context
|
||||
|
||||
|
||||
async def render(
|
||||
template_name: str = "",
|
||||
status: int = 200,
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
content_type: str = "text/html; charset=utf-8",
|
||||
app: Optional[Sanic] = None,
|
||||
environment: Optional[Environment] = None,
|
||||
context: Optional[dict[str, Any]] = None,
|
||||
*,
|
||||
template_source: str = "",
|
||||
) -> TemplateResponse:
|
||||
if app is None:
|
||||
try:
|
||||
app = Sanic.get_app()
|
||||
except SanicException as e:
|
||||
raise SanicException(
|
||||
"Cannot render template beause locating the Sanic application "
|
||||
"was ambiguous. Please return render(..., app=some_app)."
|
||||
) from e
|
||||
|
||||
if template_name and template_source:
|
||||
raise SanicException(
|
||||
"You must provide template_name OR template_source, not both."
|
||||
)
|
||||
|
||||
if environment is None:
|
||||
try:
|
||||
environment = app.ext.environment
|
||||
except AttributeError:
|
||||
raise ExtensionNotFound(
|
||||
"The Templating extension does not appear to be enabled. "
|
||||
"Perhaps jinja2 is not installed."
|
||||
)
|
||||
|
||||
kwargs = context if context else {}
|
||||
|
||||
kwargs["request"] = Request.get_current()
|
||||
|
||||
if template_name or template_source:
|
||||
template = (
|
||||
environment.get_template(template_name)
|
||||
if template_name
|
||||
else environment.from_string(template_source)
|
||||
)
|
||||
|
||||
render = (
|
||||
template.render_async
|
||||
if app.config.TEMPLATING_ENABLE_ASYNC
|
||||
else template.render
|
||||
)
|
||||
content = render(**kwargs)
|
||||
if isawaitable(content):
|
||||
content = await content # type: ignore
|
||||
|
||||
return TemplateResponse( # type: ignore
|
||||
content, status=status, headers=headers, content_type=content_type
|
||||
)
|
||||
else:
|
||||
return LazyResponse(
|
||||
kwargs, status=status, headers=headers, content_type=content_type
|
||||
)
|
||||
Reference in New Issue
Block a user