xtr-http-kernel 2.0.0__tar.gz → 4.0.2__tar.gz
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.
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/LICENSE +1 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/PKG-INFO +303 -33
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/README.md +290 -23
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/pyproject.toml +20 -14
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/pyproject.toml.orig +28 -14
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/SKILL.md +423 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/configure.md +53 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/listeners.md +28 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/rate-limits.md +107 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/routing.md +73 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/__init__.py +23 -1
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/_route_mount.py +125 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/__init__.py +38 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/_headers.py +85 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/_scope_uri.py +115 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/http_message_factory.py +344 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/http_message_factory_interface.py +48 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/response.py +437 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/response_factory.py +48 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/starlette_http_factory.py +314 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/starlette_http_factory_interface.py +58 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/bundle/__init__.py +2 -1
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/bundle/_messages.py +154 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/bundle/_routing.py +22 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/bundle/http_kernel_bundle.py +57 -12
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/bundle/http_kernel_config.py +7 -3
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/bundle/routing_resolver_pass.py +102 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/command/debug_router_command.py +2 -1
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/command/route_description.py +1 -1
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/command/router_command.py +9 -8
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/command/router_match_command.py +2 -1
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event/exception_event.py +0 -4
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event/request_event.py +0 -4
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/event_listener/error_logging_listener.py +62 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event_listener/request_id_listener.py +9 -6
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/exception/__init__.py +10 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/exception/invalid_cookie_error.py +37 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/exception/ranged_response_body_error.py +40 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/exception/response_already_a_message_error.py +34 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/exception/streamed_response_body_error.py +34 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/exception/unsupported_connection_error.py +33 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/middleware_stack.py +1 -9
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/recipe/__init__.py +15 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/recipe/manifest.toml +26 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/__init__.py +36 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/alias.py +94 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/alias_deprecation.py +21 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/decorator/__init__.py +20 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/decorator/as_route_loader.py +65 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/decorator/as_routes.py +44 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/decorator/route_loader_marker.py +23 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/exception/__init__.py +21 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/exception/invalid_argument_error.py +22 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/exception/route_circular_reference_error.py +32 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/exception/route_conflict_error.py +37 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/exception/routing_error.py +16 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/__init__.py +7 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/configurator/__init__.py +17 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/configurator/_step_interface.py +19 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/configurator/alias_configurator.py +46 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/configurator/collection_configurator.py +69 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/configurator/import_configurator.py +84 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/configurator/route_configurator.py +111 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/configurator/routing_configurator.py +123 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/routes_file_loader.py +103 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/route.py +66 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/route_collection.py +169 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/route_loader_interface.py +25 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/route_table_parameter.py +12 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/router.py +38 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/router_interface.py +23 -0
- xtr_http_kernel-4.0.2/src/xtr_http_kernel/setup.py +141 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/testing.py +5 -4
- xtr_http_kernel-2.0.0/src/xtr_http_kernel/event_listener/error_logging_listener.py +0 -52
- xtr_http_kernel-2.0.0/src/xtr_http_kernel/setup.py +0 -96
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/_kernel_middleware.py +0 -0
- {xtr_http_kernel-2.0.0/src/xtr_http_kernel/command → xtr_http_kernel-4.0.2/src/xtr_http_kernel}/_route_contexts.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/_state.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/bundle/request_lifecycle_middleware_factory.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/command/__init__.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event/__init__.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event/finish_request_event.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event/response_event.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event/terminate_event.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event_listener/__init__.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event_listener/disallow_robots_indexing_listener.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event_listener/log_unit_listener.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event_listener/rate_limit_headers_listener.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/exception/http_kernel_error.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/exception/invalid_argument_error.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/exception/invalid_middleware_priority_error.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/exception/invalid_rate_limit_error.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/exception/too_many_requests_error.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/exception/unknown_rate_limiter_error.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/kernel_events.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/middleware_tag.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/py.typed +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/rate_limiter/__init__.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/rate_limiter/_applied_rate_limit.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/rate_limiter/rate_limited.py +0 -0
- {xtr_http_kernel-2.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/request_lifecycle_middleware.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xtr-http-kernel
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.2
|
|
4
4
|
Summary: Requests turned into responses through events: a request lifecycle for FastAPI applications on the xtr kernel.
|
|
5
5
|
Keywords: http,kernel,request,lifecycle,events,middleware,fastapi
|
|
6
6
|
Author: Xterr
|
|
@@ -16,16 +16,19 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
16
16
|
Classifier: Typing :: Typed
|
|
17
17
|
Requires-Dist: fastapi>=0.121.3
|
|
18
18
|
Requires-Dist: starlette>=0.40
|
|
19
|
-
Requires-Dist: xtr-dependency-injection[fastapi]>=
|
|
20
|
-
Requires-Dist: xtr-event-dispatcher>=
|
|
21
|
-
Requires-Dist: xtr-event-dispatcher-contracts>=
|
|
22
|
-
Requires-Dist: xtr-
|
|
23
|
-
Requires-Dist: xtr-
|
|
19
|
+
Requires-Dist: xtr-dependency-injection[fastapi]>=4.0,<5
|
|
20
|
+
Requires-Dist: xtr-event-dispatcher>=4.0,<5
|
|
21
|
+
Requires-Dist: xtr-event-dispatcher-contracts>=4.0,<5
|
|
22
|
+
Requires-Dist: xtr-http-message>=4.0,<5
|
|
23
|
+
Requires-Dist: xtr-http-message-contracts>=4.0,<5
|
|
24
|
+
Requires-Dist: xtr-logging-contracts>=4.0,<5
|
|
25
|
+
Requires-Dist: xtr-service-contracts>=4.0,<5
|
|
24
26
|
Requires-Dist: typing-extensions>=4.4
|
|
25
|
-
Requires-Dist:
|
|
26
|
-
Requires-Dist: xtr-
|
|
27
|
-
Requires-Dist: xtr-
|
|
28
|
-
Requires-Dist: xtr-
|
|
27
|
+
Requires-Dist: python-multipart>=0.0.18
|
|
28
|
+
Requires-Dist: xtr-console>=4.0,<5 ; extra == 'console'
|
|
29
|
+
Requires-Dist: xtr-logging>=4.0,<5 ; extra == 'logging'
|
|
30
|
+
Requires-Dist: xtr-clock>=4.0,<5 ; extra == 'rate-limiter'
|
|
31
|
+
Requires-Dist: xtr-rate-limiter[di]>=4.0,<5 ; extra == 'rate-limiter'
|
|
29
32
|
Requires-Python: >=3.11
|
|
30
33
|
Provides-Extra: console
|
|
31
34
|
Provides-Extra: logging
|
|
@@ -65,6 +68,9 @@ Listeners are services, so they come from the container with everything else the
|
|
|
65
68
|
exception, and once everything has been sent.
|
|
66
69
|
- 🪪 **A request id for free.** Every response carries one; every log record made while handling
|
|
67
70
|
carries the same.
|
|
71
|
+
- ✉️ **The request and the response as messages.** Ask for them by the
|
|
72
|
+
[HTTP message contract](../xtr-http-message-contracts) and a library that never heard of this
|
|
73
|
+
web framework can read the one and fill in the other.
|
|
68
74
|
|
|
69
75
|
## Install
|
|
70
76
|
|
|
@@ -76,7 +82,9 @@ uv add "xtr-http-kernel[rate-limiter]" # + rate limits on routes, routers a
|
|
|
76
82
|
```
|
|
77
83
|
|
|
78
84
|
Requires Python 3.11+. The web framework and the container layer come with the package
|
|
79
|
-
itself — this *is* the integration — so neither is an extra
|
|
85
|
+
itself — this *is* the integration — so neither is an extra, and neither is the
|
|
86
|
+
[HTTP message contract](../xtr-http-message-contracts) or
|
|
87
|
+
[its implementation](../xtr-http-message): the bridge below is made of them.
|
|
80
88
|
|
|
81
89
|
## Quick start
|
|
82
90
|
|
|
@@ -134,14 +142,22 @@ bundle, one written above.
|
|
|
134
142
|
|
|
135
143
|
## What `setup` does
|
|
136
144
|
|
|
137
|
-
`setup(app, kernel)` is the one call an application makes. It changes nothing about how
|
|
138
|
-
are declared; it wraps two seams
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
`setup(app, kernel)` is the one call an application makes. It changes nothing about how the
|
|
146
|
+
application's own routes are declared; it adds the routes bundles contribute and wraps two seams
|
|
147
|
+
around them:
|
|
148
|
+
|
|
149
|
+
- **The routes.** `setup` compiles the kernel once — nothing is booted and no service is built
|
|
150
|
+
— and adds its [route table](#routes) to the application with the framework's own
|
|
151
|
+
`include_router`. From then on the application holds every route it serves, so whatever
|
|
152
|
+
reads its routes — the [router commands](#router-commands), the API description,
|
|
153
|
+
`url_path_for` — sees them all without starting it. Configuration and the environment are
|
|
154
|
+
therefore read when `setup` runs, and a contributed route colliding with one the application
|
|
155
|
+
declared itself fails right there.
|
|
156
|
+
- **The lifespan.** The first start of the application's lifespan boots the kernel `setup`
|
|
157
|
+
compiled; every later start builds it afresh — a built container boots once, and a test starts
|
|
158
|
+
the application many times. Each attaches the container so the injection markers resolve in
|
|
159
|
+
routes. The application's own lifespan runs inside, its state passing through untouched; on
|
|
160
|
+
the way out the container is detached and shut down.
|
|
145
161
|
- **One middleware.** Added when `setup` is called, so **call it before the first request** —
|
|
146
162
|
after the routes and the application's own middleware is fine. Each life fetches the
|
|
147
163
|
[middleware stack](#middleware-from-a-bundle) the kernel's bundles contributed and runs every
|
|
@@ -209,6 +225,122 @@ and the generated OpenAPI schema stays the framework's too.
|
|
|
209
225
|
What is left for the lifecycle is everything *around* the endpoint — the id, the log line, the
|
|
210
226
|
header, the unit of work — and that is all it does.
|
|
211
227
|
|
|
228
|
+
## The request and the response as messages
|
|
229
|
+
|
|
230
|
+
A library that reads a request and fills in a response should not have to know which web
|
|
231
|
+
framework the application runs on. It takes the
|
|
232
|
+
[HTTP message contract](../xtr-http-message-contracts) — `ServerRequestInterface`,
|
|
233
|
+
`ResponseInterface` — and this package hands it the request being served and a response to
|
|
234
|
+
answer with. An endpoint asks for the same two, naming the bridge's own `Response` where the
|
|
235
|
+
contract would do, so that what it returns is the type it declares:
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
from xtr_dependency_injection import Injected
|
|
239
|
+
from xtr_http_message_contracts import ServerRequestInterface
|
|
240
|
+
|
|
241
|
+
from xtr_http_kernel.bridge import Response
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@app.post("/tokens")
|
|
245
|
+
async def issue(
|
|
246
|
+
request: Injected[ServerRequestInterface],
|
|
247
|
+
response: Injected[Response],
|
|
248
|
+
) -> Response:
|
|
249
|
+
_ = await response.get_body().write(await issuer.issue(request))
|
|
250
|
+
return response.with_status(201).with_header("content-type", "application/json")
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
- **Built only when asked.** The request is built the first time something asks for it, from
|
|
254
|
+
FastAPI's own request object — the body it already read, the form it already parsed. A route
|
|
255
|
+
that does not ask pays nothing, and nothing is buffered anywhere.
|
|
256
|
+
- **Once per request.** Both are `lifetime="scoped"`, so every endpoint, dependency and service
|
|
257
|
+
of one request sees the same request message and the same response. `RequestInterface` and
|
|
258
|
+
`MessageInterface` lead to the same build, so a parameter typed by any of the three gets it.
|
|
259
|
+
- **One response, two names.** `xtr_http_kernel.bridge.Response` and `ResponseInterface` lead to
|
|
260
|
+
that same build: an endpoint asks for the class and declares it returns it — parameter and
|
|
261
|
+
return annotation the same type, nothing to narrow and nothing to configure — while a library
|
|
262
|
+
that knows only the contract asks for `ResponseInterface` and is handed that very response.
|
|
263
|
+
- **Returned as it is.** The injected response is `xtr_http_kernel.bridge.Response`: an
|
|
264
|
+
HTTP message *and* a FastAPI response. Return it and it goes out — status, headers and
|
|
265
|
+
body read at the moment of sending, so a body written through
|
|
266
|
+
`await response.get_body().write(...)` after the response was handed over is the body on the
|
|
267
|
+
wire. Assigning `response.body = ...` sets the body too; the body is read back through the
|
|
268
|
+
message, with `await response.get_body().get_contents()`. The request id header and every
|
|
269
|
+
`ResponseEvent` listener still apply to it, like any other response.
|
|
270
|
+
- **Every response in an application comes from one of those two.** Either it is the injected
|
|
271
|
+
response, or the injected response factory made it — and every response that factory makes,
|
|
272
|
+
and every response a `with_*` on either of them returns, is that same class. So there is
|
|
273
|
+
nothing to configure: no default response class, no encoder registration, no route class of
|
|
274
|
+
its own. Returning one is the whole of it. A route that makes responses of its own asks for
|
|
275
|
+
`ResponseFactory`, the class, and stays typed through every `with_*`;
|
|
276
|
+
`ResponseFactoryInterface` is the same service, for a library that knows only the contract.
|
|
277
|
+
**Never build an `xtr_http_message` response or an `xtr_http_message.factory.HttpFactory` in
|
|
278
|
+
code that serves a request:** those concrete classes know nothing of this web framework, so
|
|
279
|
+
one of them returned from a route is not a response the framework can send. They are for code
|
|
280
|
+
*outside* a served application — a test, a worker, a console command — and a message that
|
|
281
|
+
arrived from there is sent by [converting it](#building-and-converting-messages).
|
|
282
|
+
- **Annotate the return `-> Response`.** FastAPI reads the return annotation to build the
|
|
283
|
+
schema, and the class above is a response of its own, so the framework sends it rather than
|
|
284
|
+
making a schema of it. A bare `ResponseInterface` annotation fails the route at definition,
|
|
285
|
+
which is the framework telling you it cannot make a schema of a contract — so a route
|
|
286
|
+
annotates the class at both ends and a strict type checker is satisfied with no narrowing.
|
|
287
|
+
- **Values are the framework's own.** `get_query_params()` and the parsed body of a submitted
|
|
288
|
+
form are FastAPI's own `QueryParams` and `FormData`, handed over rather than copied: `["a"]`
|
|
289
|
+
is the last value and `getlist("a")` is every value, and a bracketed name such as `items[]`
|
|
290
|
+
is that name, literally. Uploaded files wrap what the framework parsed where it lies, so
|
|
291
|
+
nothing is copied; a field submitted with no file reports `UploadError.NO_FILE`.
|
|
292
|
+
- **Per-request services only.** A service that takes a `ServerRequestInterface`, a `Response`
|
|
293
|
+
or a `ResponseInterface` must be `lifetime="scoped"` or transient. A singleton would hold the
|
|
294
|
+
request or the response of whichever request happened to build it, so the container refuses
|
|
295
|
+
one at build time, naming the parameter.
|
|
296
|
+
- **A socket has no request message.** On a WebSocket route, asking for one raises
|
|
297
|
+
`UnsupportedConnectionError`: take the framework's own `websocket: WebSocket` instead.
|
|
298
|
+
|
|
299
|
+
Nothing about FastAPI's own `Form`, `File`, `Body`, query and JSON parameters changes, with or
|
|
300
|
+
without these on the route — including its `422` bodies.
|
|
301
|
+
|
|
302
|
+
### Building and converting messages
|
|
303
|
+
|
|
304
|
+
The bundle also registers the six factories of the message contract, so a library can build a
|
|
305
|
+
message of its own: `RequestFactoryInterface`, `ServerRequestFactoryInterface`,
|
|
306
|
+
`StreamFactoryInterface`, `UploadedFileFactoryInterface` and `UriFactoryInterface` are answered
|
|
307
|
+
by [xtr-http-message](../xtr-http-message)'s `HttpFactory`, and `ResponseFactoryInterface` by
|
|
308
|
+
this package's `ResponseFactory` — so a response made through it is returnable as it is, like
|
|
309
|
+
the injected one.
|
|
310
|
+
|
|
311
|
+
Take those factories by their interfaces and the application decides what is behind them.
|
|
312
|
+
Reaching past them — `xtr_http_message.Response(...)`, `HttpFactory()` — in code that serves a
|
|
313
|
+
request gets you a message the framework cannot send, and is the one thing to avoid here. Those
|
|
314
|
+
classes are what a test, a worker or a console command builds, where there is no request being
|
|
315
|
+
served and no response to return.
|
|
316
|
+
|
|
317
|
+
Two more services cross between a message and the framework, for the times you hold the wrong
|
|
318
|
+
one:
|
|
319
|
+
|
|
320
|
+
| Service | Does |
|
|
321
|
+
|---|---|
|
|
322
|
+
| `HttpMessageFactoryInterface` | `await create_request(request)` and `create_response(response)`: the framework's own, as messages |
|
|
323
|
+
| `StarletteHttpFactoryInterface` | `await create_request(message, streamed=False)` and `await create_response(message, streamed=False)`: a message, as the framework's own |
|
|
324
|
+
|
|
325
|
+
A response that arrived from outside the application — one a worker built with
|
|
326
|
+
`xtr_http_message.Response(...)`, say — is sent by converting it with
|
|
327
|
+
`await StarletteHttpFactoryInterface.create_response(...)`; `streamed=True` sends the body in
|
|
328
|
+
chunks instead of whole. That conversion is for a message that *came from elsewhere*; a response
|
|
329
|
+
the application itself is answering with comes from the injected `ResponseFactoryInterface` or is
|
|
330
|
+
the injected `ResponseInterface`, and needs no conversion at all.
|
|
331
|
+
|
|
332
|
+
Neither direction alters what it is handed: where it cannot carry something across, it says so.
|
|
333
|
+
`create_response(response)` refuses a response that streams its body with
|
|
334
|
+
`StreamedResponseBodyError`, because reading that body would consume it and leave the response
|
|
335
|
+
with nothing to send — build the message from the content being streamed instead. It refuses a
|
|
336
|
+
response declaring a `Content-Range` with `RangedResponseBodyError` for the mirror reason: which
|
|
337
|
+
bytes of the file that header names is settled where the response is sent, so the message would
|
|
338
|
+
carry the whole file under a header saying it carries one part of it.
|
|
339
|
+
`StarletteHttpFactoryInterface.create_request(message)` refuses a cookie a `Cookie` header
|
|
340
|
+
cannot carry unchanged with `InvalidCookieError`, naming it: the cookies of a message are a
|
|
341
|
+
plain mapping nothing checked on the way in, and a value holding `;` would be read back as
|
|
342
|
+
further cookies of its own.
|
|
343
|
+
|
|
212
344
|
## Scoped services
|
|
213
345
|
|
|
214
346
|
A service registered `lifetime="scoped"` is built once per request and **released after the
|
|
@@ -219,16 +351,22 @@ a `finally`: the engine throws a scope's error into the generator, so a commit t
|
|
|
219
351
|
raises there, and — with the logging listeners active — that failure is written to the log
|
|
220
352
|
against the request that caused it, like any other uncaught exception.
|
|
221
353
|
|
|
354
|
+
Nothing else is reset between requests. A service built for the application's life is shared
|
|
355
|
+
by every request, including the ones in flight at the same time, so the lifecycle clears no
|
|
356
|
+
state when a request begins or ends: a reset would wipe what a concurrent request is halfway
|
|
357
|
+
through. A service that must not outlive one request says so with `lifetime="scoped"`, and one
|
|
358
|
+
that remembers something per caller keys it by the caller instead of holding only the last.
|
|
359
|
+
|
|
222
360
|
## Listeners shipped
|
|
223
361
|
|
|
224
362
|
The bundle registers these; which ones depend on what is installed and active:
|
|
225
363
|
|
|
226
364
|
| Listener | Events | Active when | Does |
|
|
227
365
|
|---|---|---|---|
|
|
228
|
-
| `RequestIdListener` | `RequestEvent`, `ResponseEvent` | always | keeps a
|
|
366
|
+
| `RequestIdListener` | `RequestEvent`, `ResponseEvent` | always | mints a `uuid4().hex` — or keeps a well-formed incoming id, where `trust_request_id` says to — puts it on `request.state.request_id`, binds it to the log context when logging is around, and echoes it on the response |
|
|
229
367
|
| `DisallowRobotsIndexingListener` | `ResponseEvent` | always | stamps `X-Robots-Tag: noindex` on every response, when the config turns it on |
|
|
230
368
|
| `LogUnitListener` | `RequestEvent`, `TerminateEvent` | logging bundle active | opens a [logging unit of work](../xtr-logging#units-of-work) per request and closes it once all was sent |
|
|
231
|
-
| `ErrorLoggingListener` | `ExceptionEvent` | logging bundle active | writes every uncaught exception to the request channel — `error` below a 500 status, `critical` otherwise — leaving the response to whoever answers it |
|
|
369
|
+
| `ErrorLoggingListener` | `ExceptionEvent` | logging bundle active | writes every uncaught exception to the request channel — `info` for a cancellation, an interrupt or an exit, `error` below a 500 status, `critical` otherwise — leaving the response to whoever answers it |
|
|
232
370
|
| `RateLimitHeadersListener` | `ResponseEvent` | rate limiter bundle active | writes the `X-RateLimit-*` headers of the [rate limit](#rate-limits) that speaks for the response, and makes it private |
|
|
233
371
|
|
|
234
372
|
The two logging listeners join only when the logging bundle is active, and open and close the
|
|
@@ -318,16 +456,28 @@ removing it undoes.
|
|
|
318
456
|
|
|
319
457
|
- **Install** — `uv add "xtr-http-kernel[logging,console]"`; `logging` brings the listeners
|
|
320
458
|
that write to a log, `console` the commands that report on the router, `rate-limiter` the
|
|
321
|
-
[rate limits](#rate-limits). None is needed to serve requests.
|
|
459
|
+
[rate limits](#rate-limits). None is needed to serve requests. The
|
|
460
|
+
[HTTP message contract](../xtr-http-message-contracts) and
|
|
461
|
+
[its implementation](../xtr-http-message) come with the package, not behind an extra: the
|
|
462
|
+
[messages](#the-request-and-the-response-as-messages) are part of what it does. So does the
|
|
463
|
+
parser a submitted form with files in it is read with: building the request as a message
|
|
464
|
+
asks the web framework for the form behind every multipart body, and the framework refuses
|
|
465
|
+
outright where that parser is missing.
|
|
466
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* step below: it lists
|
|
467
|
+
`HttpKernelBundle`. There is no config file, environment or ignore line to write; it prints the
|
|
468
|
+
`setup(app, kernel)` step, which a recipe cannot make for you.
|
|
322
469
|
- **Activate** — `HttpKernelBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
|
|
323
470
|
from `xtr_http_kernel.bundle`. Then call `setup(app, kernel)` where the application is built.
|
|
324
471
|
- **Brings along** — the [event dispatcher](../xtr-event-dispatcher) bundle always, because the
|
|
325
472
|
lifecycle dispatches through it; the [logging](../xtr-logging), [console](../xtr-console) and
|
|
326
473
|
[rate limiter](../xtr-rate-limiter) bundles whenever those packages are *installed* — they are required peers, pulled in and made
|
|
327
474
|
active without being listed, and left out silently when the package is not installed. Listing
|
|
328
|
-
is not what activates them; installing the extra is.
|
|
329
|
-
|
|
330
|
-
|
|
475
|
+
is not what activates them; installing the extra is. No bundle comes along for the messages:
|
|
476
|
+
neither message package ships one, and this bundle registers their services itself.
|
|
477
|
+
- **Configure** — nothing is required: the zero-config path gives every request a fresh `uuid4`
|
|
478
|
+
id under `X-Request-Id` — an inbound one is not trusted — no robots header, and the `request`
|
|
479
|
+
log channel, and makes the request and the response of a request askable as
|
|
480
|
+
[messages](#the-request-and-the-response-as-messages). A
|
|
331
481
|
`<app>/config/http_kernel.py` `@configure` function returning an `HttpKernelConfig` changes
|
|
332
482
|
that — see [Configure](#configure) and [Kernel / bundle](#kernel--bundle).
|
|
333
483
|
- **Environment** — nothing.
|
|
@@ -346,7 +496,7 @@ default:
|
|
|
346
496
|
| Field | Default | What it sets |
|
|
347
497
|
|---|---|---|
|
|
348
498
|
| `request_id_header` | `"X-Request-Id"` | the header the id is read from and echoed on; must be a non-empty HTTP token |
|
|
349
|
-
| `trust_request_id` | `
|
|
499
|
+
| `trust_request_id` | `False` | mint a fresh id every request; `True` keeps a well-formed incoming one |
|
|
350
500
|
| `disallow_search_indexing` | `False` | mark every response `X-Robots-Tag: noindex` |
|
|
351
501
|
| `log_channel` | `"request"` | the channel the error listener writes to |
|
|
352
502
|
| `middleware_priority` | `0` | where the lifecycle middleware sits among the contributed factories — highest outermost |
|
|
@@ -356,6 +506,15 @@ The constructor refuses values the lifecycle would silently misread: a `request_
|
|
|
356
506
|
is not an HTTP token, an empty `log_channel`, or an `app` that is not exactly one module and one
|
|
357
507
|
attribute around a single `:`, each raise `InvalidArgumentError` — also a `ValueError`.
|
|
358
508
|
|
|
509
|
+
`trust_request_id` has one prerequisite: a proxy you control in front of the application, which
|
|
510
|
+
sets `request_id_header` itself and strips whatever the caller sent. Reached directly, the
|
|
511
|
+
caller chooses the id, so it can give two requests the same one — mixing their log records on
|
|
512
|
+
purpose, and making the one id an auditor follows useless. Without that proxy leave it off: the
|
|
513
|
+
minted id still travels on `request.state.request_id`, on every log record made while handling,
|
|
514
|
+
and out on the response, so a caller quoting the response's id names exactly one request. A
|
|
515
|
+
caller with its own trace id of a wider system sends it under a header of its own, which an
|
|
516
|
+
application reads like any other.
|
|
517
|
+
|
|
359
518
|
The bundle declares the default `log_channel` on the logging config for you. An application
|
|
360
519
|
renaming it must declare the new channel in its own logging configuration — this bundle's
|
|
361
520
|
config resolves after logging's, so it cannot declare a name it does not yet know.
|
|
@@ -385,7 +544,9 @@ def http_kernel() -> HttpKernelConfig:
|
|
|
385
544
|
```
|
|
386
545
|
|
|
387
546
|
The bundle registers the lifecycle middleware factory, the listeners, and — when a console
|
|
388
|
-
bundle is active — the router commands
|
|
547
|
+
bundle is active — the router commands, plus the
|
|
548
|
+
[messages](#the-request-and-the-response-as-messages) and the factories behind them. It
|
|
549
|
+
requires the event dispatcher bundle outright, and
|
|
389
550
|
the logging and console bundles when installed. Its zero-config path builds and boots with no
|
|
390
551
|
application configuration and touches no I/O until a request arrives.
|
|
391
552
|
|
|
@@ -409,9 +570,103 @@ application life and composes it over the application, inside the request scope.
|
|
|
409
570
|
that is not an integer fails the build with `InvalidMiddlewarePriorityError`, naming the
|
|
410
571
|
offending definition.
|
|
411
572
|
|
|
573
|
+
## Routes
|
|
574
|
+
|
|
575
|
+
A bundle ships its routes, and an application gets them served with no code in its entry point.
|
|
576
|
+
The application arranges them in its routes files, and `setup` adds the result.
|
|
577
|
+
|
|
578
|
+
### A bundle's routes
|
|
579
|
+
|
|
580
|
+
A route loader is a class with `load_routes()`, declared with `@as_route_loader`. It names the
|
|
581
|
+
*resource* an application imports its routes by:
|
|
582
|
+
|
|
583
|
+
```python
|
|
584
|
+
from xtr_http_kernel.routing import Route, RouteCollection
|
|
585
|
+
from xtr_http_kernel.routing.decorator import as_route_loader
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
async def list_books() -> list[str]:
|
|
589
|
+
return ["Dune"]
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@as_route_loader("books")
|
|
593
|
+
class BooksRouteLoader:
|
|
594
|
+
def load_routes(self) -> RouteCollection:
|
|
595
|
+
routes = RouteCollection()
|
|
596
|
+
routes.add("books_list", Route("/books", list_books, methods=("GET",)))
|
|
597
|
+
return routes
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
The loader is built with no arguments while the kernel compiles, before any service exists, so
|
|
601
|
+
what it contributes is fixed by the code that ships it. A route a deployment turns off keeps
|
|
602
|
+
answering — its endpoint refuses — and an application that wants it gone leaves it out in a
|
|
603
|
+
routes file. Pass `prefixable=False` for routes a standard puts at a fixed place, such as a
|
|
604
|
+
`/.well-known/` document: no routes file may move them under a prefix. A loader is found like any
|
|
605
|
+
other declaration: in the application's scan, or in a module a bundle scans with
|
|
606
|
+
`services.load(...)`.
|
|
607
|
+
|
|
608
|
+
A `Route` carries a path, the endpoint the framework calls — its `Injected[...]` parameters
|
|
609
|
+
untouched — and the options handed to the framework as they are: `methods`,
|
|
610
|
+
`include_in_schema`, `tags`, `summary`, `status_code`, `response_model`. Its name is the key it
|
|
611
|
+
has in its `RouteCollection`. A mounted route validates, documents and is reached by
|
|
612
|
+
`url_path_for` exactly like the same function declared on a router.
|
|
613
|
+
|
|
614
|
+
### The application's routes files
|
|
615
|
+
|
|
616
|
+
A routes file is a function declared `@as_routes` in `<app>/config/routes/`, receiving a
|
|
617
|
+
`RoutingConfigurator`:
|
|
618
|
+
|
|
619
|
+
```python
|
|
620
|
+
from xtr_http_kernel.routing.decorator import as_routes
|
|
621
|
+
from xtr_http_kernel.routing.loader.configurator import RoutingConfigurator
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
@as_routes
|
|
625
|
+
def shop(routes: RoutingConfigurator) -> None:
|
|
626
|
+
routes.import_("books").prefix("/shop").name_prefix("shop_")
|
|
627
|
+
routes.add("shop_books_list", "/shop/catalogue") # move one route by its name
|
|
628
|
+
routes.add("home", "/").endpoint(home).methods("GET")
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
async def home() -> dict[str, str]:
|
|
632
|
+
return {"page": "home"}
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
- `import_(resource)` brings in a resource's routes, arranged with `prefix`, `name_prefix` and
|
|
636
|
+
`exclude(*names)`; `skip(resource)` serves none of them. A resource no routes file imports or
|
|
637
|
+
skips is served at its own paths and names.
|
|
638
|
+
- `add(name, path)` defines a route. Under a name already in the table it replaces that route:
|
|
639
|
+
without `.endpoint(...)` it moves the route and keeps the rest, with one it redefines it. Its
|
|
640
|
+
options — `methods`, `priority`, `tags`, `summary`, `status_code`, `include_in_schema`,
|
|
641
|
+
`response_model` — read like the `Route` fields.
|
|
642
|
+
- `collection(name_prefix)` groups routes under one path prefix and name prefix;
|
|
643
|
+
`alias(name, target)` adds another name for a route, optionally `.deprecate(...)`d.
|
|
644
|
+
- Nothing reaches the table while the file runs: every call is applied when it returns, in the
|
|
645
|
+
order written, so a route defined after an import replaces the imported one of that name.
|
|
646
|
+
Each file starts from the resources as their loaders returned them.
|
|
647
|
+
|
|
648
|
+
A mistake is refused while the kernel compiles — so at `setup` — with an `InvalidArgumentError`
|
|
649
|
+
naming it: an unknown resource or route, a prefix on fixed routes, a new route with no endpoint,
|
|
650
|
+
two loaders claiming one resource. The kernel scans only packages, so `config/routes/` needs an
|
|
651
|
+
`__init__.py`; xtr-recipes creates it with the first routes file a recipe writes there.
|
|
652
|
+
|
|
653
|
+
### Collisions with the application's own routes
|
|
654
|
+
|
|
655
|
+
The application's own routes are never changed, and they come first. A contributed route sharing
|
|
656
|
+
a name with one the application declared, or one the application's routes would answer first —
|
|
657
|
+
the same method and path under any parameter names, a catch-all, a mount, or a router the
|
|
658
|
+
application included — fails `setup` with a `RouteConflictError` naming both; so do two
|
|
659
|
+
contributed routes answering the same method and path. To change a contributed route on purpose,
|
|
660
|
+
redefine it by its name in a routes file.
|
|
661
|
+
|
|
662
|
+
An alias is mounted under its own name, out of the API description, so `url_path_for` reaches it.
|
|
663
|
+
The table is also a service: `RouterInterface.get_route_collection()` returns a copy of what was
|
|
664
|
+
mounted, the same on every start of the application, for code that reads a route by name.
|
|
665
|
+
|
|
412
666
|
## Router commands
|
|
413
667
|
|
|
414
|
-
With a console bundle active, two commands read the application without serving it
|
|
668
|
+
With a console bundle active, two commands read the application without serving it — every
|
|
669
|
+
route included, since `setup` added the contributed ones when the application was imported:
|
|
415
670
|
|
|
416
671
|
- **`debug:router`** lists every route in the order routing tries them, prefixes and mounts
|
|
417
672
|
applied.
|
|
@@ -427,7 +682,7 @@ routers.
|
|
|
427
682
|
|
|
428
683
|
FastAPI's `TestClient` is unusable here: it leans on a deprecated framework path, and this
|
|
429
684
|
package's test suite turns warnings into errors. Drive the application through an ASGI transport
|
|
430
|
-
instead, inside its own lifespan so `setup`'s wrapper
|
|
685
|
+
instead, inside its own lifespan so `setup`'s wrapper boots the kernel:
|
|
431
686
|
|
|
432
687
|
```python
|
|
433
688
|
import httpx
|
|
@@ -448,7 +703,7 @@ async def test_a_book_carries_a_request_id() -> None:
|
|
|
448
703
|
```
|
|
449
704
|
|
|
450
705
|
To swap a service for the span of a test, park the replacements on the application with
|
|
451
|
-
`override_services` **before** entering the lifespan, so every kernel
|
|
706
|
+
`override_services` **before** entering the lifespan, so every kernel booted while the block is
|
|
452
707
|
open — every boot hook, every route — sees them:
|
|
453
708
|
|
|
454
709
|
```python
|
|
@@ -469,15 +724,30 @@ run at the same time: the block that exits last would restore what the other rep
|
|
|
469
724
|
## Errors
|
|
470
725
|
|
|
471
726
|
Everything this library raises derives from `HttpKernelError`, and carries what went wrong as
|
|
472
|
-
typed attributes rather than only a message.
|
|
727
|
+
typed attributes rather than only a message. All of them are importable from `xtr_http_kernel`
|
|
728
|
+
itself, as well as from `xtr_http_kernel.exception`.
|
|
473
729
|
|
|
474
730
|
| Error | Raised when |
|
|
475
731
|
|---|---|
|
|
476
732
|
| `InvalidArgumentError` | an `HttpKernelConfig` field holds a value the lifecycle would misread; also a `ValueError` |
|
|
733
|
+
| `InvalidCookieError` | a request message carries a cookie a `Cookie` header cannot carry unchanged — a name that is not a token, or a value holding `;`, a control character or anything beyond visible ASCII; also a `ValueError` |
|
|
477
734
|
| `InvalidMiddlewarePriorityError` | a `http_kernel.middleware` tag's `priority` is not an integer |
|
|
478
735
|
| `InvalidRateLimitError` | a `RateLimited` takes fewer than one token, limits a router that already has routes, or its key function returns no string; also a `ValueError` |
|
|
736
|
+
| `RangedResponseBodyError` | a response being built into a message declares one range of the file it reads its body from, so the message would carry the whole file under a header saying otherwise |
|
|
737
|
+
| `ResponseAlreadyAMessageError` | the response being built into a message is the bridge's own, which already is a message: there is nothing to convert, and its body is awaited rather than read |
|
|
738
|
+
| `StreamedResponseBodyError` | a response being built into a message streams its body, which cannot be read without consuming it |
|
|
479
739
|
| `TooManyRequestsError` | a limiter refused the request — a 429 the framework answers, with `Retry-After` |
|
|
480
740
|
| `UnknownRateLimiterError` | a `RateLimited` names a limiter the application did not configure; also a `LookupError` |
|
|
741
|
+
| `UnsupportedConnectionError` | something asked for the request as a message while a WebSocket was being served |
|
|
742
|
+
|
|
743
|
+
The route table's errors live in `xtr_http_kernel.routing`, under `RoutingError` — itself an
|
|
744
|
+
`HttpKernelError`:
|
|
745
|
+
|
|
746
|
+
| Error | Raised when |
|
|
747
|
+
|---|---|
|
|
748
|
+
| `InvalidArgumentError` | a routes file names an unknown resource or route, prefixes fixed routes, defines a route with no endpoint, or is `async`; one file skips a resource another imports; two loaders claim one resource, or two resources no file arranges contribute one name; a loader needs arguments or has an `async` `load_routes`; an alias is refused. Also the library's own `InvalidArgumentError`, so a `ValueError` |
|
|
749
|
+
| `RouteConflictError` | `setup` finds a contributed route sharing a name with one the application declared, or answered first by one of the application's routes, or answering the same method and path as another contributed route |
|
|
750
|
+
| `RouteCircularReferenceError` | aliases lead back to a name they already passed |
|
|
481
751
|
|
|
482
752
|
## Layout
|
|
483
753
|
|
|
@@ -485,6 +755,7 @@ typed attributes rather than only a message.
|
|
|
485
755
|
xtr_http_kernel/
|
|
486
756
|
├── setup.py setup(app, kernel), the one call an application makes
|
|
487
757
|
├── testing.py override_services, for a served application under test
|
|
758
|
+
├── bridge/ the request and the response as HTTP messages, both ways
|
|
488
759
|
├── event/ the five lifecycle events, one class per file
|
|
489
760
|
├── kernel_events.py KernelEvents, the name each of them is dispatched under
|
|
490
761
|
├── event_listener/ the listeners the bundle registers
|
|
@@ -492,6 +763,7 @@ xtr_http_kernel/
|
|
|
492
763
|
├── middleware_stack.py the ordered chain a bundle contributes to
|
|
493
764
|
├── middleware_tag.py MIDDLEWARE_TAG, the tag bundles agree on
|
|
494
765
|
├── rate_limiter/ RateLimited, with the rate-limiter extra
|
|
766
|
+
├── routing/ Route, RouteCollection, Router, the loaders and configurators
|
|
495
767
|
├── command/ debug:router and router:match
|
|
496
768
|
├── exception/ HttpKernelError, the root of everything this library raises
|
|
497
769
|
└── bundle/ HttpKernelBundle and HttpKernelConfig
|
|
@@ -515,5 +787,3 @@ uv run pytest
|
|
|
515
787
|
## License
|
|
516
788
|
|
|
517
789
|
MIT — see [LICENSE](LICENSE).
|
|
518
|
-
</content>
|
|
519
|
-
</invoke>
|