xtr-http-kernel 3.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.
Files changed (101) hide show
  1. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/LICENSE +1 -0
  2. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/PKG-INFO +300 -33
  3. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/README.md +287 -23
  4. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/pyproject.toml +17 -14
  5. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/pyproject.toml.orig +24 -14
  6. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/SKILL.md +165 -15
  7. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/configure.md +10 -1
  8. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/listeners.md +4 -3
  9. xtr_http_kernel-4.0.2/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/routing.md +73 -0
  10. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/__init__.py +23 -1
  11. xtr_http_kernel-4.0.2/src/xtr_http_kernel/_route_mount.py +125 -0
  12. xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/__init__.py +38 -0
  13. xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/_headers.py +85 -0
  14. xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/_scope_uri.py +115 -0
  15. xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/http_message_factory.py +344 -0
  16. xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/http_message_factory_interface.py +48 -0
  17. xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/response.py +437 -0
  18. xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/response_factory.py +48 -0
  19. xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/starlette_http_factory.py +314 -0
  20. xtr_http_kernel-4.0.2/src/xtr_http_kernel/bridge/starlette_http_factory_interface.py +58 -0
  21. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/bundle/__init__.py +2 -1
  22. xtr_http_kernel-4.0.2/src/xtr_http_kernel/bundle/_messages.py +154 -0
  23. xtr_http_kernel-4.0.2/src/xtr_http_kernel/bundle/_routing.py +22 -0
  24. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/bundle/http_kernel_bundle.py +49 -1
  25. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/bundle/http_kernel_config.py +7 -3
  26. xtr_http_kernel-4.0.2/src/xtr_http_kernel/bundle/routing_resolver_pass.py +102 -0
  27. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/command/debug_router_command.py +2 -1
  28. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/command/route_description.py +1 -1
  29. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/command/router_command.py +9 -8
  30. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/command/router_match_command.py +2 -1
  31. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event/exception_event.py +0 -4
  32. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event/request_event.py +0 -4
  33. xtr_http_kernel-4.0.2/src/xtr_http_kernel/event_listener/error_logging_listener.py +62 -0
  34. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event_listener/request_id_listener.py +9 -6
  35. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/exception/__init__.py +10 -0
  36. xtr_http_kernel-4.0.2/src/xtr_http_kernel/exception/invalid_cookie_error.py +37 -0
  37. xtr_http_kernel-4.0.2/src/xtr_http_kernel/exception/ranged_response_body_error.py +40 -0
  38. xtr_http_kernel-4.0.2/src/xtr_http_kernel/exception/response_already_a_message_error.py +34 -0
  39. xtr_http_kernel-4.0.2/src/xtr_http_kernel/exception/streamed_response_body_error.py +34 -0
  40. xtr_http_kernel-4.0.2/src/xtr_http_kernel/exception/unsupported_connection_error.py +33 -0
  41. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/middleware_stack.py +1 -9
  42. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/recipe/manifest.toml +3 -1
  43. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/__init__.py +36 -0
  44. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/alias.py +94 -0
  45. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/alias_deprecation.py +21 -0
  46. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/decorator/__init__.py +20 -0
  47. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/decorator/as_route_loader.py +65 -0
  48. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/decorator/as_routes.py +44 -0
  49. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/decorator/route_loader_marker.py +23 -0
  50. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/exception/__init__.py +21 -0
  51. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/exception/invalid_argument_error.py +22 -0
  52. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/exception/route_circular_reference_error.py +32 -0
  53. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/exception/route_conflict_error.py +37 -0
  54. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/exception/routing_error.py +16 -0
  55. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/__init__.py +7 -0
  56. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/configurator/__init__.py +17 -0
  57. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/configurator/_step_interface.py +19 -0
  58. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/configurator/alias_configurator.py +46 -0
  59. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/configurator/collection_configurator.py +69 -0
  60. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/configurator/import_configurator.py +84 -0
  61. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/configurator/route_configurator.py +111 -0
  62. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/configurator/routing_configurator.py +123 -0
  63. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/loader/routes_file_loader.py +103 -0
  64. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/route.py +66 -0
  65. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/route_collection.py +169 -0
  66. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/route_loader_interface.py +25 -0
  67. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/route_table_parameter.py +12 -0
  68. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/router.py +38 -0
  69. xtr_http_kernel-4.0.2/src/xtr_http_kernel/routing/router_interface.py +23 -0
  70. xtr_http_kernel-4.0.2/src/xtr_http_kernel/setup.py +141 -0
  71. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/testing.py +5 -4
  72. xtr_http_kernel-3.0.0/src/xtr_http_kernel/event_listener/error_logging_listener.py +0 -52
  73. xtr_http_kernel-3.0.0/src/xtr_http_kernel/setup.py +0 -96
  74. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/rate-limits.md +0 -0
  75. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/_kernel_middleware.py +0 -0
  76. {xtr_http_kernel-3.0.0/src/xtr_http_kernel/command → xtr_http_kernel-4.0.2/src/xtr_http_kernel}/_route_contexts.py +0 -0
  77. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/_state.py +0 -0
  78. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/bundle/request_lifecycle_middleware_factory.py +0 -0
  79. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/command/__init__.py +0 -0
  80. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event/__init__.py +0 -0
  81. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event/finish_request_event.py +0 -0
  82. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event/response_event.py +0 -0
  83. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event/terminate_event.py +0 -0
  84. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event_listener/__init__.py +0 -0
  85. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event_listener/disallow_robots_indexing_listener.py +0 -0
  86. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event_listener/log_unit_listener.py +0 -0
  87. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/event_listener/rate_limit_headers_listener.py +0 -0
  88. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/exception/http_kernel_error.py +0 -0
  89. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/exception/invalid_argument_error.py +0 -0
  90. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/exception/invalid_middleware_priority_error.py +0 -0
  91. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/exception/invalid_rate_limit_error.py +0 -0
  92. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/exception/too_many_requests_error.py +0 -0
  93. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/exception/unknown_rate_limiter_error.py +0 -0
  94. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/kernel_events.py +0 -0
  95. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/middleware_tag.py +0 -0
  96. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/py.typed +0 -0
  97. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/rate_limiter/__init__.py +0 -0
  98. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/rate_limiter/_applied_rate_limit.py +0 -0
  99. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/rate_limiter/rate_limited.py +0 -0
  100. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/recipe/__init__.py +0 -0
  101. {xtr_http_kernel-3.0.0 → xtr_http_kernel-4.0.2}/src/xtr_http_kernel/request_lifecycle_middleware.py +0 -0
@@ -1,6 +1,7 @@
1
1
  MIT License
2
2
 
3
3
  Copyright (c) 2026 xterr
4
+ Copyright (c) 2004-present Fabien Potencier
4
5
 
5
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
7
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xtr-http-kernel
3
- Version: 3.0.0
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]>=3.0,<4
20
- Requires-Dist: xtr-event-dispatcher>=3.0,<4
21
- Requires-Dist: xtr-event-dispatcher-contracts>=3.0,<4
22
- Requires-Dist: xtr-logging-contracts>=3.0,<4
23
- Requires-Dist: xtr-service-contracts>=3.0,<4
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: xtr-console>=3.0,<4 ; extra == 'console'
26
- Requires-Dist: xtr-logging>=3.0,<4 ; extra == 'logging'
27
- Requires-Dist: xtr-clock>=3.0,<4 ; extra == 'rate-limiter'
28
- Requires-Dist: xtr-rate-limiter[di]>=3.0,<4 ; extra == 'rate-limiter'
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 routes
138
- are declared; it wraps two seams around them:
139
-
140
- - **The lifespan.** Every start of the application's lifespan builds the kernel afresh, boots
141
- it — a built container boots once, and a test starts the application many times — and attaches
142
- the container so the injection markers resolve in routes. The application's own lifespan runs
143
- inside, its state passing through untouched; on the way out the container is detached and shut
144
- down.
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 trusted incoming id or mints a `uuid4().hex`, puts it on `request.state.request_id`, binds it to the log context when logging is around, and echoes it on the response |
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,7 +456,13 @@ 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.
322
466
  - **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* step below: it lists
323
467
  `HttpKernelBundle`. There is no config file, environment or ignore line to write; it prints the
324
468
  `setup(app, kernel)` step, which a recipe cannot make for you.
@@ -328,9 +472,12 @@ removing it undoes.
328
472
  lifecycle dispatches through it; the [logging](../xtr-logging), [console](../xtr-console) and
329
473
  [rate limiter](../xtr-rate-limiter) bundles whenever those packages are *installed* — they are required peers, pulled in and made
330
474
  active without being listed, and left out silently when the package is not installed. Listing
331
- is not what activates them; installing the extra is.
332
- - **Configure** — nothing is required: the zero-config path gives a `uuid4` request id under
333
- `X-Request-Id`, no robots header, and the `request` log channel. A
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
334
481
  `<app>/config/http_kernel.py` `@configure` function returning an `HttpKernelConfig` changes
335
482
  that — see [Configure](#configure) and [Kernel / bundle](#kernel--bundle).
336
483
  - **Environment** — nothing.
@@ -349,7 +496,7 @@ default:
349
496
  | Field | Default | What it sets |
350
497
  |---|---|---|
351
498
  | `request_id_header` | `"X-Request-Id"` | the header the id is read from and echoed on; must be a non-empty HTTP token |
352
- | `trust_request_id` | `True` | keep a well-formed incoming id; `False` mints a fresh one every request |
499
+ | `trust_request_id` | `False` | mint a fresh id every request; `True` keeps a well-formed incoming one |
353
500
  | `disallow_search_indexing` | `False` | mark every response `X-Robots-Tag: noindex` |
354
501
  | `log_channel` | `"request"` | the channel the error listener writes to |
355
502
  | `middleware_priority` | `0` | where the lifecycle middleware sits among the contributed factories — highest outermost |
@@ -359,6 +506,15 @@ The constructor refuses values the lifecycle would silently misread: a `request_
359
506
  is not an HTTP token, an empty `log_channel`, or an `app` that is not exactly one module and one
360
507
  attribute around a single `:`, each raise `InvalidArgumentError` — also a `ValueError`.
361
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
+
362
518
  The bundle declares the default `log_channel` on the logging config for you. An application
363
519
  renaming it must declare the new channel in its own logging configuration — this bundle's
364
520
  config resolves after logging's, so it cannot declare a name it does not yet know.
@@ -388,7 +544,9 @@ def http_kernel() -> HttpKernelConfig:
388
544
  ```
389
545
 
390
546
  The bundle registers the lifecycle middleware factory, the listeners, and — when a console
391
- bundle is active — the router commands. It requires the event dispatcher bundle outright, and
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
392
550
  the logging and console bundles when installed. Its zero-config path builds and boots with no
393
551
  application configuration and touches no I/O until a request arrives.
394
552
 
@@ -412,9 +570,103 @@ application life and composes it over the application, inside the request scope.
412
570
  that is not an integer fails the build with `InvalidMiddlewarePriorityError`, naming the
413
571
  offending definition.
414
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
+
415
666
  ## Router commands
416
667
 
417
- 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:
418
670
 
419
671
  - **`debug:router`** lists every route in the order routing tries them, prefixes and mounts
420
672
  applied.
@@ -430,7 +682,7 @@ routers.
430
682
 
431
683
  FastAPI's `TestClient` is unusable here: it leans on a deprecated framework path, and this
432
684
  package's test suite turns warnings into errors. Drive the application through an ASGI transport
433
- instead, inside its own lifespan so `setup`'s wrapper builds and boots the kernel:
685
+ instead, inside its own lifespan so `setup`'s wrapper boots the kernel:
434
686
 
435
687
  ```python
436
688
  import httpx
@@ -451,7 +703,7 @@ async def test_a_book_carries_a_request_id() -> None:
451
703
  ```
452
704
 
453
705
  To swap a service for the span of a test, park the replacements on the application with
454
- `override_services` **before** entering the lifespan, so every kernel built while the block is
706
+ `override_services` **before** entering the lifespan, so every kernel booted while the block is
455
707
  open — every boot hook, every route — sees them:
456
708
 
457
709
  ```python
@@ -472,15 +724,30 @@ run at the same time: the block that exits last would restore what the other rep
472
724
  ## Errors
473
725
 
474
726
  Everything this library raises derives from `HttpKernelError`, and carries what went wrong as
475
- 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`.
476
729
 
477
730
  | Error | Raised when |
478
731
  |---|---|
479
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` |
480
734
  | `InvalidMiddlewarePriorityError` | a `http_kernel.middleware` tag's `priority` is not an integer |
481
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 |
482
739
  | `TooManyRequestsError` | a limiter refused the request — a 429 the framework answers, with `Retry-After` |
483
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 |
484
751
 
485
752
  ## Layout
486
753
 
@@ -488,6 +755,7 @@ typed attributes rather than only a message.
488
755
  xtr_http_kernel/
489
756
  ├── setup.py setup(app, kernel), the one call an application makes
490
757
  ├── testing.py override_services, for a served application under test
758
+ ├── bridge/ the request and the response as HTTP messages, both ways
491
759
  ├── event/ the five lifecycle events, one class per file
492
760
  ├── kernel_events.py KernelEvents, the name each of them is dispatched under
493
761
  ├── event_listener/ the listeners the bundle registers
@@ -495,6 +763,7 @@ xtr_http_kernel/
495
763
  ├── middleware_stack.py the ordered chain a bundle contributes to
496
764
  ├── middleware_tag.py MIDDLEWARE_TAG, the tag bundles agree on
497
765
  ├── rate_limiter/ RateLimited, with the rate-limiter extra
766
+ ├── routing/ Route, RouteCollection, Router, the loaders and configurators
498
767
  ├── command/ debug:router and router:match
499
768
  ├── exception/ HttpKernelError, the root of everything this library raises
500
769
  └── bundle/ HttpKernelBundle and HttpKernelConfig
@@ -518,5 +787,3 @@ uv run pytest
518
787
  ## License
519
788
 
520
789
  MIT — see [LICENSE](LICENSE).
521
- </content>
522
- </invoke>