takibiapi 0.1.0__cp313-cp313-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- takibiapi/__init__.py +41 -0
- takibiapi/_core.cp313-win_amd64.pyd +0 -0
- takibiapi/_core.pyi +22 -0
- takibiapi/app.py +451 -0
- takibiapi/context.py +317 -0
- takibiapi/cookie.py +282 -0
- takibiapi/cors.py +112 -0
- takibiapi/openapi.py +98 -0
- takibiapi/problems.py +34 -0
- takibiapi/py.typed +0 -0
- takibiapi/route.py +408 -0
- takibiapi/router.py +115 -0
- takibiapi/schema.py +582 -0
- takibiapi/schemas.py +109 -0
- takibiapi-0.1.0.dist-info/METADATA +152 -0
- takibiapi-0.1.0.dist-info/RECORD +19 -0
- takibiapi-0.1.0.dist-info/WHEEL +4 -0
- takibiapi-0.1.0.dist-info/licenses/LICENSE +21 -0
- takibiapi-0.1.0.dist-info/sboms/takibiapi.cyclonedx.json +543 -0
takibiapi/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""takibi: REST APIs whose OpenAPI document, request type checks and handler types come from
|
|
2
|
+
Python's own types.
|
|
3
|
+
|
|
4
|
+
from takibiapi import Context, TakibiAPI, createRoute
|
|
5
|
+
|
|
6
|
+
Routes are declared with ``createRoute`` (dataclasses, ``TypedDict``, ``Literal``, ``Enum``,
|
|
7
|
+
``Annotated``, ...) and registered with ``app.openapi(route, handler)``; ``app.doc("/doc", {...})``
|
|
8
|
+
serves the OpenAPI document. Request parts are checked against their types only and read with
|
|
9
|
+
``c.req.valid(...)``; ``Annotated`` keywords are documentation and responses are not checked.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from takibiapi.app import Fallback, FallbackHandler, FallbackMiddleware, FallbackNext, Middleware, Next, TakibiAPI
|
|
13
|
+
from takibiapi.context import Context, Request, Response, ResponseValidationError, problem
|
|
14
|
+
from takibiapi.openapi import OpenAPIConfig
|
|
15
|
+
from takibiapi.problems import ValidationProblem
|
|
16
|
+
from takibiapi.route import RequestConfig, ResponseConfig, Route, RouteConfig, createRoute
|
|
17
|
+
from takibiapi.schema import ValidationError
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Context",
|
|
21
|
+
"Fallback",
|
|
22
|
+
"FallbackHandler",
|
|
23
|
+
"FallbackMiddleware",
|
|
24
|
+
"FallbackNext",
|
|
25
|
+
"Middleware",
|
|
26
|
+
"Next",
|
|
27
|
+
"OpenAPIConfig",
|
|
28
|
+
"Request",
|
|
29
|
+
"RequestConfig",
|
|
30
|
+
"Response",
|
|
31
|
+
"ResponseConfig",
|
|
32
|
+
"ResponseValidationError",
|
|
33
|
+
"Route",
|
|
34
|
+
"RouteConfig",
|
|
35
|
+
"TakibiAPI",
|
|
36
|
+
"ValidationError",
|
|
37
|
+
"ValidationProblem",
|
|
38
|
+
"createRoute",
|
|
39
|
+
"problem",
|
|
40
|
+
]
|
|
41
|
+
__version__ = "0.1.0"
|
|
Binary file
|
takibiapi/_core.pyi
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Types of the Rust extension (core/src/lib.rs). Functions return None for input whose result
|
|
2
|
+
# they cannot reproduce exactly; the caller then uses Python's json / urllib.parse.
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
class Validator:
|
|
7
|
+
"""A type compiled from ``takibiapi.schema.plan(tp)``."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, plan: dict[str, Any]) -> None: ...
|
|
10
|
+
def decode(self, value: Any, coerce: bool) -> tuple[bool, Any]: ...
|
|
11
|
+
def decode_json(self, data: bytes) -> tuple[bool, Any] | None: ...
|
|
12
|
+
|
|
13
|
+
class Router:
|
|
14
|
+
"""takibiapi.router.Router in Rust."""
|
|
15
|
+
|
|
16
|
+
def add(self, method: str, path: str, entry: Any) -> None: ...
|
|
17
|
+
def match(self, method: str, path: str) -> tuple[Any, dict[str, str]] | None: ...
|
|
18
|
+
def allowed(self, path: str) -> list[str]: ...
|
|
19
|
+
|
|
20
|
+
def dumps(value: Any, default: Callable[[Any], Any] | None = None) -> bytes | None: ...
|
|
21
|
+
def loads(data: bytes) -> tuple[Any] | None: ...
|
|
22
|
+
def parse_query(qs: bytes) -> dict[str, list[str]] | None: ...
|
takibiapi/app.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""``TakibiAPI``: a REST app on ASGI whose routes are OpenAPI operations described with Python types.
|
|
2
|
+
|
|
3
|
+
Run it with any ASGI server (``uvicorn module:app``), with Granian's faster RSGI interface
|
|
4
|
+
(``granian --interface rsgi module:app``), or call ``await app.request("/path")`` in tests. Request
|
|
5
|
+
parts are checked against their types before the handler runs (``c.req.valid``); ``Annotated``
|
|
6
|
+
keywords and responses are not checked.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
import logging
|
|
13
|
+
import typing
|
|
14
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
15
|
+
from typing import Any, Self, TypeVar, get_args, get_origin
|
|
16
|
+
|
|
17
|
+
from .context import Context, MalformedJSONError, RawHeaders, Request, Response, problem
|
|
18
|
+
from .openapi import OpenAPIConfig, build_document, check_config
|
|
19
|
+
from .route import AnyRoute, route_class
|
|
20
|
+
from .router import new_router
|
|
21
|
+
from .schema import ValidationError, dumps
|
|
22
|
+
from .schemas import Checker, Encoder, Schemas, TypeSchemas
|
|
23
|
+
|
|
24
|
+
Handler = Callable[[Context[Any]], Response | Awaitable[Response]]
|
|
25
|
+
Next = Callable[[], Awaitable[Response]]
|
|
26
|
+
# ``async def mw(c, next): ...; res = await next(); ...; return res`` (return early to stop).
|
|
27
|
+
Middleware = Callable[[Context[Any], Next], Awaitable[Response]]
|
|
28
|
+
# ``onError`` / ``notFound`` take handlers ``def h(c)`` (the error is ``c.error``) and middleware
|
|
29
|
+
# ``async def mw(c, next)``; ``next()`` runs the rest of the chain and is ``None`` when it ends
|
|
30
|
+
# without an answer, which the built-in response then gives.
|
|
31
|
+
FallbackNext = Callable[[], Awaitable[Response | None]]
|
|
32
|
+
FallbackMiddleware = Callable[[Context[Any], FallbackNext], Awaitable[Response | None]]
|
|
33
|
+
FallbackHandler = Callable[[Context[Any]], Response | Awaitable[Response | None] | None]
|
|
34
|
+
Fallback = FallbackHandler | FallbackMiddleware
|
|
35
|
+
|
|
36
|
+
# The registered route; its handler takes ``c: Context[that route]``.
|
|
37
|
+
_TR = TypeVar("_TR", bound=AnyRoute)
|
|
38
|
+
|
|
39
|
+
# Where each checked part lives, as an OpenAPI parameter location ("in") for error reports.
|
|
40
|
+
_LOCATIONS = {"param": "path", "query": "query", "header": "header", "cookie": "cookie", "json": "body"}
|
|
41
|
+
VALIDATION_PROBLEM_TYPE = "/problems/validation-error"
|
|
42
|
+
|
|
43
|
+
log = logging.getLogger("takibiapi")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Entry:
|
|
47
|
+
__slots__ = ("checks", "handler", "is_async", "responses")
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
handler: Callable[..., Any],
|
|
52
|
+
checks: list[tuple[str, Checker, bool]] | None = None,
|
|
53
|
+
responses: dict[int, dict[str, Encoder]] | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.handler = handler
|
|
56
|
+
self.is_async = inspect.iscoroutinefunction(handler)
|
|
57
|
+
self.checks = checks or [] # (target, checker, required) for c.req.valid
|
|
58
|
+
self.responses = responses # {status: {media type: encoder}} for c.json, or None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _Fallback:
|
|
62
|
+
"""One ``onError`` / ``notFound`` registration: runs for paths under ``prefix``; handlers of
|
|
63
|
+
more deeply mounted apps (``depth``) come first, then registration order."""
|
|
64
|
+
|
|
65
|
+
__slots__ = ("depth", "fn", "is_middleware", "prefix")
|
|
66
|
+
|
|
67
|
+
def __init__(self, fn: Fallback, prefix: str, depth: int = 0) -> None:
|
|
68
|
+
self.fn: Callable[..., Any] = fn # called as ``fn(c)`` or ``fn(c, next)``, by is_middleware
|
|
69
|
+
self.prefix = prefix
|
|
70
|
+
self.depth = depth
|
|
71
|
+
self.is_middleware = _arity(fn) >= 2
|
|
72
|
+
|
|
73
|
+
def mounted(self, prefix: str) -> _Fallback:
|
|
74
|
+
return _Fallback(self.fn, prefix + self.prefix, self.depth + 1)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TakibiAPI:
|
|
78
|
+
"""``app = TakibiAPI()``; ``app.openapi(route, handler)``; ``app.doc("/doc", {...})``."""
|
|
79
|
+
|
|
80
|
+
# How the types in routes become checks and OpenAPI schemas (see takibiapi.schemas).
|
|
81
|
+
schemas: Schemas = TypeSchemas()
|
|
82
|
+
|
|
83
|
+
def __init__(self) -> None:
|
|
84
|
+
self._router = new_router()
|
|
85
|
+
self._registrations: list[tuple[str, str, _Entry]] = []
|
|
86
|
+
self._routes: list[type[AnyRoute]] = []
|
|
87
|
+
self._middleware: list[tuple[str, Middleware]] = []
|
|
88
|
+
self._on_error_fallbacks: list[_Fallback] = []
|
|
89
|
+
self._not_found_fallbacks: list[_Fallback] = []
|
|
90
|
+
self._documents: dict[int, bytes] = {}
|
|
91
|
+
|
|
92
|
+
# -- routes ------------------------------------------------------------------------------- #
|
|
93
|
+
def openapi(self, route: type[_TR], handler: Callable[[Context[_TR]], Response | Awaitable[Response]]) -> Self:
|
|
94
|
+
"""Register ``route`` with its handler.
|
|
95
|
+
|
|
96
|
+
The route must be declared as a class (``class getTodoRoute(createRoute({...}))``) and
|
|
97
|
+
the handler must take it as ``c: Context[getTodoRoute]``; anything else is a ``TypeError``.
|
|
98
|
+
The request parts the route declares are checked against their types before the handler
|
|
99
|
+
runs (422 when they do not match) and read with ``c.req.valid(...)``.
|
|
100
|
+
"""
|
|
101
|
+
_check_handler(route, handler)
|
|
102
|
+
request = route.config.get("request") or {}
|
|
103
|
+
checks: list[tuple[str, Checker, bool]] = []
|
|
104
|
+
for key, target in (("params", "param"), ("query", "query"), ("headers", "header"), ("cookies", "cookie")):
|
|
105
|
+
if request.get(key) is not None:
|
|
106
|
+
checks.append((target, self.schemas.checker(request[key], target), True))
|
|
107
|
+
body = request.get("body") or {}
|
|
108
|
+
for media_type, media in (body.get("content") or {}).items():
|
|
109
|
+
if "json" in media_type and media.get("schema") is not None:
|
|
110
|
+
checks.append(
|
|
111
|
+
("json", self.schemas.checker(media["schema"], "json"), bool(body.get("required", False)))
|
|
112
|
+
)
|
|
113
|
+
responses: dict[int, dict[str, Encoder]] = {}
|
|
114
|
+
for status, response in (route.config.get("responses") or {}).items():
|
|
115
|
+
for media_type, media in (response.get("content") or {}).items():
|
|
116
|
+
if media.get("schema") is None or not str(status).isdigit():
|
|
117
|
+
continue
|
|
118
|
+
encoder = self.schemas.response_encoder(media["schema"])
|
|
119
|
+
if encoder is not None:
|
|
120
|
+
responses.setdefault(int(status), {})[media_type.lower()] = encoder
|
|
121
|
+
self._add(route.method, route.path, _Entry(handler, checks, responses or None))
|
|
122
|
+
self._routes.append(route)
|
|
123
|
+
self._documents.clear()
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def route(self, prefix: str, app: TakibiAPI) -> Self:
|
|
127
|
+
"""Mount ``app``'s routes and middleware under ``prefix`` (``app.route("/v1", v1)``)."""
|
|
128
|
+
if type(app.schemas) is not type(self.schemas):
|
|
129
|
+
raise TypeError(
|
|
130
|
+
f"cannot mount a {type(app).__name__} app with other schemas into a {type(self).__name__} app"
|
|
131
|
+
)
|
|
132
|
+
prefix = prefix.rstrip("/")
|
|
133
|
+
for method, path, entry in app._registrations:
|
|
134
|
+
self._add(method, prefix + path, entry)
|
|
135
|
+
for pattern, middleware in app._middleware:
|
|
136
|
+
self._middleware.append((prefix + pattern, middleware))
|
|
137
|
+
self._on_error_fallbacks.extend(f.mounted(prefix) for f in app._on_error_fallbacks)
|
|
138
|
+
self._not_found_fallbacks.extend(f.mounted(prefix) for f in app._not_found_fallbacks)
|
|
139
|
+
for route in app._routes:
|
|
140
|
+
self._routes.append(route_class({**route.config, "path": prefix + route.path}))
|
|
141
|
+
self._documents.clear()
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def use(self, middleware: Middleware, *, prefix: str = "") -> Self:
|
|
145
|
+
"""Run ``middleware`` around the handlers (only under ``prefix`` if given)::
|
|
146
|
+
|
|
147
|
+
async def cors(c, next):
|
|
148
|
+
res = await next()
|
|
149
|
+
res.headers["access-control-allow-origin"] = "*"
|
|
150
|
+
return res
|
|
151
|
+
"""
|
|
152
|
+
self._middleware.append((prefix.rstrip("/"), middleware))
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
def onError(self, *handlers: Fallback, prefix: str = "") -> Self: # noqa: N802
|
|
156
|
+
"""Answer for exceptions raised by handlers and middleware, as Hono's ``app.onError``::
|
|
157
|
+
|
|
158
|
+
app.onError(lambda c: c.json({"error": str(c.error)}, 500))
|
|
159
|
+
|
|
160
|
+
The exception is ``c.error``. Registrations compose rather than replace each other:
|
|
161
|
+
``async def mw(c, next)`` runs around the ones registered after it (``await next()`` is
|
|
162
|
+
their answer, ``None`` when there is none), a ``def h(c)`` handler ends the chain by
|
|
163
|
+
returning a response, and when the chain ends without one the built-in 500 Problem
|
|
164
|
+
Details answers. ``prefix`` limits the handlers to paths under it. The handlers of a
|
|
165
|
+
mounted app (``app.route``) come first for its paths; at the same depth, registration
|
|
166
|
+
order. An exception from a handler here is logged and answered by the built-in response.
|
|
167
|
+
"""
|
|
168
|
+
self._on_error_fallbacks.extend(_Fallback(fn, prefix.rstrip("/")) for fn in handlers)
|
|
169
|
+
return self
|
|
170
|
+
|
|
171
|
+
def notFound(self, *handlers: Fallback, prefix: str = "") -> Self: # noqa: N802
|
|
172
|
+
"""Answer for paths with no route (default: 404 Problem Details; a known path with another
|
|
173
|
+
method answers 405 before this). Composes like :meth:`onError`::
|
|
174
|
+
|
|
175
|
+
app.notFound(lambda c: c.text("not here", 404))
|
|
176
|
+
app.notFound(mw, lambda c: c.text("no such API", 404), prefix="/api")
|
|
177
|
+
"""
|
|
178
|
+
self._not_found_fallbacks.extend(_Fallback(fn, prefix.rstrip("/")) for fn in handlers)
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
# -- OpenAPI documents ---------------------------------------------------------------------- #
|
|
182
|
+
def getOpenAPIDocument(self, config: OpenAPIConfig) -> dict[str, Any]: # noqa: N802
|
|
183
|
+
"""The OpenAPI document for the ``openapi`` version given in ``config`` (3.0.x or 3.1.x)."""
|
|
184
|
+
return build_document(self._routes, config, self.schemas)
|
|
185
|
+
|
|
186
|
+
def doc(self, path: str, config: OpenAPIConfig) -> Self:
|
|
187
|
+
"""Serve the OpenAPI document at ``GET path`` (generated once, after the routes are known)."""
|
|
188
|
+
check_config(config)
|
|
189
|
+
key = id(config)
|
|
190
|
+
|
|
191
|
+
def serve(c: Context[Any]) -> Response:
|
|
192
|
+
body = self._documents.get(key)
|
|
193
|
+
if body is None:
|
|
194
|
+
body = self._documents[key] = dumps(build_document(self._routes, config, self.schemas))
|
|
195
|
+
return Response(body, 200, {"content-type": "application/json"})
|
|
196
|
+
|
|
197
|
+
self._add("GET", path, _Entry(serve))
|
|
198
|
+
return self
|
|
199
|
+
|
|
200
|
+
# -- dispatch ------------------------------------------------------------------------------- #
|
|
201
|
+
def _add(self, method: str, path: str, entry: _Entry) -> None:
|
|
202
|
+
self._router.add(method, path, entry)
|
|
203
|
+
self._registrations.append((method.upper(), path, entry))
|
|
204
|
+
|
|
205
|
+
async def handle(
|
|
206
|
+
self,
|
|
207
|
+
method: str,
|
|
208
|
+
path: str,
|
|
209
|
+
query_string: bytes = b"",
|
|
210
|
+
raw_headers: RawHeaders | None = None,
|
|
211
|
+
receive: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
|
212
|
+
) -> Response:
|
|
213
|
+
matched = self._router.match(method, path)
|
|
214
|
+
if matched is None and method == "HEAD":
|
|
215
|
+
matched = self._router.match("GET", path)
|
|
216
|
+
entry, params = matched if matched is not None else (None, {})
|
|
217
|
+
c: Context[Any] = Context(
|
|
218
|
+
Request(method, path, params, query_string, raw_headers or [], receive),
|
|
219
|
+
entry.responses if entry is not None else None,
|
|
220
|
+
)
|
|
221
|
+
if self._middleware:
|
|
222
|
+
chain = [m for prefix, m in self._middleware if _under(prefix, path)]
|
|
223
|
+
if chain:
|
|
224
|
+
return await self._through(chain, 0, entry, c)
|
|
225
|
+
return await self._dispatch(entry, c)
|
|
226
|
+
|
|
227
|
+
async def _through(self, chain: list[Middleware], i: int, entry: _Entry | None, c: Context[Any]) -> Response:
|
|
228
|
+
if i == len(chain):
|
|
229
|
+
return await self._dispatch(entry, c)
|
|
230
|
+
try:
|
|
231
|
+
return await chain[i](c, lambda: self._through(chain, i + 1, entry, c))
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
return await self._answer_error(exc, c)
|
|
234
|
+
|
|
235
|
+
async def _dispatch(self, entry: _Entry | None, c: Context[Any]) -> Response:
|
|
236
|
+
try:
|
|
237
|
+
if entry is None:
|
|
238
|
+
return await self._not_found(c)
|
|
239
|
+
if entry.checks:
|
|
240
|
+
failure = await _check_request(entry, c)
|
|
241
|
+
if failure is not None:
|
|
242
|
+
return failure
|
|
243
|
+
out = entry.handler(c)
|
|
244
|
+
if entry.is_async:
|
|
245
|
+
out = await out
|
|
246
|
+
if not isinstance(out, Response):
|
|
247
|
+
raise TypeError(
|
|
248
|
+
f"{entry.handler.__qualname__} returned {type(out).__name__}, not a Response (use c.json(...))"
|
|
249
|
+
)
|
|
250
|
+
return out
|
|
251
|
+
except MalformedJSONError: # RFC 9110 15.5.1
|
|
252
|
+
return problem(400, "Bad Request", "The request body is not valid JSON.", c.req.path)
|
|
253
|
+
except Exception as exc:
|
|
254
|
+
return await self._answer_error(exc, c)
|
|
255
|
+
|
|
256
|
+
async def _not_found(self, c: Context[Any]) -> Response:
|
|
257
|
+
allowed = self._router.allowed(c.req.path)
|
|
258
|
+
if allowed: # RFC 9110 15.5.6
|
|
259
|
+
res = problem(405, "Method Not Allowed", f"{c.req.method} is not allowed here.", c.req.path)
|
|
260
|
+
res.headers["allow"] = ", ".join(allowed)
|
|
261
|
+
return res
|
|
262
|
+
out = await self._fallback(_chain(self._not_found_fallbacks, c.req.path), 0, c)
|
|
263
|
+
if out is None:
|
|
264
|
+
return problem(404, "Not Found", f"No route for {c.req.method} {c.req.path}.", c.req.path)
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
async def _answer_error(self, exc: Exception, c: Context[Any]) -> Response:
|
|
268
|
+
log.exception("%s %s: unhandled error", c.req.method, c.req.path, exc_info=exc)
|
|
269
|
+
c.error = exc
|
|
270
|
+
chain = _chain(self._on_error_fallbacks, c.req.path)
|
|
271
|
+
if chain:
|
|
272
|
+
try:
|
|
273
|
+
out = await self._fallback(chain, 0, c)
|
|
274
|
+
if out is not None:
|
|
275
|
+
return out
|
|
276
|
+
except Exception as failure:
|
|
277
|
+
log.exception("%s %s: the error handler failed", c.req.method, c.req.path, exc_info=failure)
|
|
278
|
+
return problem(500, "Internal Server Error", "An unexpected error occurred.", c.req.path)
|
|
279
|
+
|
|
280
|
+
async def _fallback(self, chain: list[_Fallback], i: int, c: Context[Any]) -> Response | None:
|
|
281
|
+
"""Runs the ``onError`` / ``notFound`` handlers in ``chain`` from ``i``; ``None`` past the end."""
|
|
282
|
+
if i == len(chain):
|
|
283
|
+
return None
|
|
284
|
+
fallback = chain[i]
|
|
285
|
+
out = fallback.fn(c, lambda: self._fallback(chain, i + 1, c)) if fallback.is_middleware else fallback.fn(c)
|
|
286
|
+
if inspect.isawaitable(out):
|
|
287
|
+
out = await out
|
|
288
|
+
if out is not None and not isinstance(out, Response):
|
|
289
|
+
raise TypeError(f"{fallback.fn.__qualname__} returned {type(out).__name__}, not a Response")
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
# -- ASGI ------------------------------------------------------------------------------------ #
|
|
293
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
294
|
+
kind = scope["type"]
|
|
295
|
+
if kind == "http":
|
|
296
|
+
method = scope["method"]
|
|
297
|
+
res = await self.handle(
|
|
298
|
+
method, scope["path"], scope.get("query_string", b""), scope.get("headers"), receive
|
|
299
|
+
)
|
|
300
|
+
# Python's str.encode here is faster than a Rust helper (measured).
|
|
301
|
+
headers = [(k.encode("latin-1"), v.encode("latin-1")) for k, v in _header_items(res.headers)]
|
|
302
|
+
headers.append((b"content-length", str(len(res.body)).encode()))
|
|
303
|
+
await send({"type": "http.response.start", "status": res.status, "headers": headers})
|
|
304
|
+
await send({"type": "http.response.body", "body": b"" if method == "HEAD" else res.body})
|
|
305
|
+
elif kind == "lifespan":
|
|
306
|
+
while True:
|
|
307
|
+
message = await receive()
|
|
308
|
+
if message["type"] == "lifespan.startup":
|
|
309
|
+
await send({"type": "lifespan.startup.complete"})
|
|
310
|
+
elif message["type"] == "lifespan.shutdown":
|
|
311
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
async def __rsgi__(self, scope: Any, protocol: Any) -> None:
|
|
315
|
+
"""Granian's RSGI interface (``granian --interface rsgi module:app``): no per-request ASGI
|
|
316
|
+
message dicts, and one call sends the whole response."""
|
|
317
|
+
if scope.proto != "http":
|
|
318
|
+
return
|
|
319
|
+
method = scope.method
|
|
320
|
+
|
|
321
|
+
async def receive() -> dict[str, Any]:
|
|
322
|
+
return {"type": "http.request", "body": await protocol(), "more_body": False}
|
|
323
|
+
|
|
324
|
+
res = await self.handle(method, scope.path, scope.query_string.encode("latin-1"), scope.headers, receive)
|
|
325
|
+
protocol.response_bytes(res.status, _header_items(res.headers), b"" if method == "HEAD" else res.body)
|
|
326
|
+
|
|
327
|
+
async def request(
|
|
328
|
+
self,
|
|
329
|
+
path: str,
|
|
330
|
+
*,
|
|
331
|
+
method: str = "GET",
|
|
332
|
+
json: Any = None,
|
|
333
|
+
body: bytes | str | None = None,
|
|
334
|
+
headers: Mapping[str, str] | None = None,
|
|
335
|
+
) -> Response:
|
|
336
|
+
"""Call the app without a server, e.g. in tests: ``await app.request("/todos/1")``."""
|
|
337
|
+
path, _, query = path.partition("?")
|
|
338
|
+
merged = {k.lower(): v for k, v in (headers or {}).items()}
|
|
339
|
+
if json is not None:
|
|
340
|
+
payload = dumps(json)
|
|
341
|
+
merged.setdefault("content-type", "application/json")
|
|
342
|
+
else:
|
|
343
|
+
payload = body.encode() if isinstance(body, str) else (body or b"")
|
|
344
|
+
raw_headers = [(k.encode("latin-1"), v.encode("latin-1")) for k, v in merged.items()]
|
|
345
|
+
|
|
346
|
+
async def receive() -> dict[str, Any]:
|
|
347
|
+
return {"type": "http.request", "body": payload, "more_body": False}
|
|
348
|
+
|
|
349
|
+
return await self.handle(method.upper(), path, query.encode(), raw_headers, receive)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _header_items(headers: Mapping[str, str | list[str]]) -> list[tuple[str, str]]:
|
|
353
|
+
"""The headers as (name, value) pairs: a list value (several ``Set-Cookie``) is one pair per item."""
|
|
354
|
+
items: list[tuple[str, str]] = []
|
|
355
|
+
for name, value in headers.items():
|
|
356
|
+
if isinstance(value, list):
|
|
357
|
+
items.extend((name, v) for v in value)
|
|
358
|
+
else:
|
|
359
|
+
items.append((name, value))
|
|
360
|
+
return items
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
async def _check_request(entry: _Entry, c: Context[Any]) -> Response | None:
|
|
364
|
+
"""Fill ``c.req.valid(...)``; the error response when a part does not match its type."""
|
|
365
|
+
req = c.req
|
|
366
|
+
for target, checker, required in entry.checks:
|
|
367
|
+
try:
|
|
368
|
+
if target == "param":
|
|
369
|
+
data = checker.decode(req._params, coerce=True)
|
|
370
|
+
elif target == "query":
|
|
371
|
+
queries = req._queries()
|
|
372
|
+
raw = {name: v if repeats else v[0] for name, repeats in checker.names if (v := queries.get(name))}
|
|
373
|
+
data = checker.decode(raw, coerce=True)
|
|
374
|
+
elif target == "header":
|
|
375
|
+
headers = req.header()
|
|
376
|
+
raw = {name: headers[name.lower()] for name, _ in checker.names if name.lower() in headers}
|
|
377
|
+
data = checker.decode(raw, coerce=True)
|
|
378
|
+
elif target == "cookie":
|
|
379
|
+
data = checker.decode(req.cookie(), coerce=True)
|
|
380
|
+
else:
|
|
381
|
+
content_type = req.header("content-type") or ""
|
|
382
|
+
if "json" not in content_type:
|
|
383
|
+
if required: # RFC 9110 15.5.16
|
|
384
|
+
detail = f"Content-Type {content_type or '(none)'} is not supported here"
|
|
385
|
+
return problem(415, "Unsupported Media Type", detail, req.path)
|
|
386
|
+
continue
|
|
387
|
+
try: # parsed and checked in one pass
|
|
388
|
+
data = checker.decode_json(await req.body())
|
|
389
|
+
except ValueError: # RFC 9110 15.5.1 (ValidationError is not a ValueError)
|
|
390
|
+
return problem(400, "Bad Request", "The request body is not valid JSON.", req.path)
|
|
391
|
+
except ValidationError as exc: # well-formed but of the wrong type: RFC 9110 15.5.21
|
|
392
|
+
return _validation_problem(target, exc.issues, req.path)
|
|
393
|
+
req._valid[target] = data
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _validation_problem(target: str, issues: list[dict[str, Any]], instance: str) -> Response:
|
|
398
|
+
"""422 Problem Details with one entry per issue (``ValidationProblem`` in the document)."""
|
|
399
|
+
location = _LOCATIONS[target]
|
|
400
|
+
errors = [
|
|
401
|
+
{
|
|
402
|
+
"in": location,
|
|
403
|
+
"path": ".".join(str(p) for p in issue["path"]),
|
|
404
|
+
"code": issue["code"],
|
|
405
|
+
"message": issue["message"],
|
|
406
|
+
}
|
|
407
|
+
for issue in issues
|
|
408
|
+
]
|
|
409
|
+
detail = f"The request {location} is invalid." if location == "body" else f"Invalid {location} parameters."
|
|
410
|
+
return problem(422, "Unprocessable Content", detail, instance, VALIDATION_PROBLEM_TYPE, errors=errors)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _check_handler(route: type[AnyRoute], handler: Callable[..., Any]) -> None:
|
|
414
|
+
"""Routes are named classes and handlers say which one they serve: ``c: Context[route]``."""
|
|
415
|
+
if route._generated:
|
|
416
|
+
raise TypeError(
|
|
417
|
+
f"declare the route as a class to name it in the handler: "
|
|
418
|
+
f"class getTodoRoute(createRoute({{...}})) (got {route.__name__})"
|
|
419
|
+
)
|
|
420
|
+
name = getattr(handler, "__qualname__", repr(handler))
|
|
421
|
+
expected = f"def {route.__name__}Handler(c: Context[{route.__name__}])"
|
|
422
|
+
params = list(inspect.signature(handler).parameters.values())
|
|
423
|
+
if not params:
|
|
424
|
+
raise TypeError(f"{name} takes no context; write it as {expected}")
|
|
425
|
+
try:
|
|
426
|
+
annotation = typing.get_type_hints(handler).get(params[0].name)
|
|
427
|
+
except Exception as exc:
|
|
428
|
+
raise TypeError(
|
|
429
|
+
f"cannot resolve the annotations of {name} ({exc}); define {route.__name__} at module level"
|
|
430
|
+
) from exc
|
|
431
|
+
if get_origin(annotation) is not Context or get_args(annotation) != (route,):
|
|
432
|
+
raise TypeError(f"{name} must take the route's context: {expected}")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _under(prefix: str, path: str) -> bool:
|
|
436
|
+
return not prefix or path == prefix or path.startswith(prefix + "/")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _chain(fallbacks: list[_Fallback], path: str) -> list[_Fallback]:
|
|
440
|
+
"""The handlers for ``path``: the more deeply mounted first, then in registration order."""
|
|
441
|
+
return sorted((f for f in fallbacks if _under(f.prefix, path)), key=lambda f: -f.depth)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _arity(fn: Callable[..., Any]) -> int:
|
|
445
|
+
try:
|
|
446
|
+
return len(inspect.signature(fn).parameters)
|
|
447
|
+
except (TypeError, ValueError): # a builtin or a callable without a signature: ``h(c)``
|
|
448
|
+
return 1
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
__all__ = ["Fallback", "FallbackHandler", "FallbackMiddleware", "FallbackNext", "Middleware", "Next", "TakibiAPI"]
|