xtr-http-kernel 4.1.0__tar.gz → 5.0.0__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 (111) hide show
  1. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/PKG-INFO +116 -89
  2. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/README.md +108 -81
  3. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/pyproject.toml +12 -12
  4. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/pyproject.toml.orig +12 -12
  5. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/SKILL.md +61 -42
  6. xtr_http_kernel-5.0.0/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/routing.md +59 -0
  7. xtr_http_kernel-5.0.0/src/xtr_http_kernel/_staging.py +175 -0
  8. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/bridge/__init__.py +8 -3
  9. xtr_http_kernel-5.0.0/src/xtr_http_kernel/bridge/_media_types.py +38 -0
  10. xtr_http_kernel-5.0.0/src/xtr_http_kernel/bridge/_server_params.py +39 -0
  11. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/bridge/http_message_factory.py +82 -74
  12. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/bridge/http_message_factory_interface.py +23 -0
  13. xtr_http_kernel-5.0.0/src/xtr_http_kernel/bridge/served_connection.py +279 -0
  14. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/bundle/__init__.py +2 -2
  15. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/bundle/_messages.py +40 -22
  16. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/bundle/http_kernel_bundle.py +19 -28
  17. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/bundle/request_lifecycle_middleware_factory.py +19 -6
  18. xtr_http_kernel-5.0.0/src/xtr_http_kernel/bundle/route_registry_pass.py +59 -0
  19. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/event/exception_event.py +25 -5
  20. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/event/finish_request_event.py +19 -5
  21. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/event/request_event.py +19 -5
  22. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/event/response_event.py +25 -6
  23. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/event/terminate_event.py +26 -5
  24. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/event_listener/error_logging_listener.py +2 -2
  25. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/event_listener/rate_limit_headers_listener.py +9 -9
  26. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/event_listener/request_id_listener.py +9 -10
  27. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/request_lifecycle_middleware.py +93 -24
  28. xtr_http_kernel-5.0.0/src/xtr_http_kernel/routing/__init__.py +22 -0
  29. xtr_http_kernel-5.0.0/src/xtr_http_kernel/routing/decorator/__init__.py +19 -0
  30. xtr_http_kernel-5.0.0/src/xtr_http_kernel/routing/decorator/as_route_resource.py +65 -0
  31. xtr_http_kernel-5.0.0/src/xtr_http_kernel/routing/decorator/as_routes.py +58 -0
  32. xtr_http_kernel-5.0.0/src/xtr_http_kernel/routing/exception/__init__.py +14 -0
  33. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/routing/exception/route_conflict_error.py +6 -5
  34. xtr_http_kernel-5.0.0/src/xtr_http_kernel/routing/route_registry.py +34 -0
  35. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/setup.py +39 -27
  36. xtr_http_kernel-4.1.0/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/routing.md +0 -73
  37. xtr_http_kernel-4.1.0/src/xtr_http_kernel/_route_mount.py +0 -125
  38. xtr_http_kernel-4.1.0/src/xtr_http_kernel/bundle/_routing.py +0 -22
  39. xtr_http_kernel-4.1.0/src/xtr_http_kernel/bundle/routing_resolver_pass.py +0 -102
  40. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/__init__.py +0 -36
  41. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/alias.py +0 -94
  42. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/alias_deprecation.py +0 -21
  43. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/decorator/__init__.py +0 -20
  44. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/decorator/as_route_loader.py +0 -65
  45. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/decorator/as_routes.py +0 -44
  46. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/decorator/route_loader_marker.py +0 -23
  47. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/exception/__init__.py +0 -21
  48. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/exception/route_circular_reference_error.py +0 -32
  49. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/loader/__init__.py +0 -7
  50. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/loader/configurator/__init__.py +0 -17
  51. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/loader/configurator/_step_interface.py +0 -19
  52. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/loader/configurator/alias_configurator.py +0 -46
  53. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/loader/configurator/collection_configurator.py +0 -69
  54. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/loader/configurator/import_configurator.py +0 -84
  55. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/loader/configurator/route_configurator.py +0 -111
  56. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/loader/configurator/routing_configurator.py +0 -123
  57. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/loader/routes_file_loader.py +0 -103
  58. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/route.py +0 -66
  59. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/route_collection.py +0 -169
  60. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/route_loader_interface.py +0 -25
  61. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/route_table_parameter.py +0 -12
  62. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/router.py +0 -38
  63. xtr_http_kernel-4.1.0/src/xtr_http_kernel/routing/router_interface.py +0 -23
  64. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/LICENSE +0 -0
  65. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/configure.md +0 -0
  66. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/listeners.md +0 -0
  67. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/.agents/skills/xtr-http-kernel/references/rate-limits.md +0 -0
  68. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/__init__.py +0 -0
  69. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/_kernel_middleware.py +0 -0
  70. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/_route_contexts.py +0 -0
  71. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/_state.py +0 -0
  72. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/bridge/_headers.py +0 -0
  73. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/bridge/_scope_uri.py +0 -0
  74. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/bridge/response.py +0 -0
  75. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/bridge/response_factory.py +0 -0
  76. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/bridge/starlette_http_factory.py +0 -0
  77. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/bridge/starlette_http_factory_interface.py +0 -0
  78. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/bundle/http_kernel_config.py +0 -0
  79. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/command/__init__.py +0 -0
  80. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/command/debug_router_command.py +0 -0
  81. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/command/route_description.py +0 -0
  82. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/command/router_command.py +0 -0
  83. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/command/router_match_command.py +0 -0
  84. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/event/__init__.py +0 -0
  85. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/event_listener/__init__.py +0 -0
  86. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/event_listener/disallow_robots_indexing_listener.py +0 -0
  87. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/event_listener/log_unit_listener.py +0 -0
  88. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/exception/__init__.py +0 -0
  89. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/exception/http_kernel_error.py +0 -0
  90. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/exception/invalid_argument_error.py +0 -0
  91. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/exception/invalid_cookie_error.py +0 -0
  92. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/exception/invalid_middleware_priority_error.py +0 -0
  93. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/exception/invalid_rate_limit_error.py +0 -0
  94. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/exception/ranged_response_body_error.py +0 -0
  95. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/exception/response_already_a_message_error.py +0 -0
  96. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/exception/streamed_response_body_error.py +0 -0
  97. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/exception/too_many_requests_error.py +0 -0
  98. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/exception/unknown_rate_limiter_error.py +0 -0
  99. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/exception/unsupported_connection_error.py +0 -0
  100. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/kernel_events.py +0 -0
  101. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/middleware_stack.py +0 -0
  102. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/middleware_tag.py +0 -0
  103. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/py.typed +0 -0
  104. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/rate_limiter/__init__.py +0 -0
  105. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/rate_limiter/_applied_rate_limit.py +0 -0
  106. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/rate_limiter/rate_limited.py +0 -0
  107. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/recipe/__init__.py +0 -0
  108. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/recipe/manifest.toml +0 -0
  109. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/routing/exception/invalid_argument_error.py +0 -0
  110. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/routing/exception/routing_error.py +0 -0
  111. {xtr_http_kernel-4.1.0 → xtr_http_kernel-5.0.0}/src/xtr_http_kernel/testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xtr-http-kernel
3
- Version: 4.1.0
3
+ Version: 5.0.0
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,13 +16,13 @@ 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>=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
19
+ Requires-Dist: xtr-dependency-injection>=5.0,<6
20
+ Requires-Dist: xtr-event-dispatcher>=5.0,<6
21
+ Requires-Dist: xtr-event-dispatcher-contracts>=5.0,<6
22
+ Requires-Dist: xtr-http-message>=5.0,<6
23
+ Requires-Dist: xtr-http-message-contracts>=5.0,<6
24
+ Requires-Dist: xtr-logging-contracts>=5.0,<6
25
+ Requires-Dist: xtr-service-contracts>=5.0,<6
26
26
  Requires-Dist: typing-extensions>=4.4
27
27
  Requires-Dist: python-multipart>=0.0.18
28
28
  Requires-Python: >=3.11
@@ -163,7 +163,10 @@ surfaces then, rather than a request running without its lifecycle.
163
163
  ## The lifecycle
164
164
 
165
165
  Every request goes through the same events, and a listener joins wherever it has something to
166
- contribute. Each is dispatched at most once, however the request went:
166
+ contribute. Each is dispatched at most once, however the request went, and each carries the
167
+ request as a message — the served request below — plus `state`, the per-request state bag the
168
+ framework's own `request.state` wraps: what a listener puts there, the framework and every other
169
+ listener read:
167
170
 
168
171
  | Event | `KernelEvents` | When | What a listener may do |
169
172
  |---|---|---|---|
@@ -244,12 +247,34 @@ async def issue(
244
247
  return response.with_status(201).with_header("content-type", "application/json")
245
248
  ```
246
249
 
247
- - **Built only when asked.** The request is built the first time something asks for it, from
248
- FastAPI's own request object — the body it already read, the form it already parsed. A route
249
- that does not ask pays nothing, and nothing is buffered anywhere.
250
- - **Once per request.** Both are `lifetime="scoped"`, so every endpoint, dependency and service
251
- of one request sees the same request message and the same response. `RequestInterface` and
252
- `MessageInterface` lead to the same build, so a parameter typed by any of the three gets it.
250
+ - **The served request, read through the connection.** The request the events carry is the
251
+ message package's own `ServerRequest`, born connected over the bridge's `ServedConnection`:
252
+ its head — method, URI, headers, cookies, query parameters, environment parameters, protocol
253
+ version — is read straight off the connection, and its body is one lazy stream. Nothing is
254
+ read off the wire until a listener or an endpoint reads that stream; the first read
255
+ materialises the whole body once, parsing it per its content type on the way, and every later
256
+ read is served out of the buffer, seekable and rewindable. One instance is parked on the
257
+ connection's scope, so the events, the scoped `server_request` service and a route's injected
258
+ message are all the same object.
259
+ - **A listener that reads the body drains the wire** — so the application is handed a `receive`
260
+ that replays the buffered bytes, and its routes read the body the caller sent as if nobody
261
+ had. A request no listener reads passes its original channel through untouched: nothing is
262
+ buffered unless something asks to read it.
263
+ - **The parsed body answers by the time an endpoint asks.** A body that has a parsed side — a
264
+ form, JSON — is materialised before the container hands the message to a service or an
265
+ endpoint, so `get_parsed_body()` and `get_uploaded_files()` answer what the message contract
266
+ promises, unconditionally, for every consumer written against it. A body with no parsed side
267
+ is left unread. The events carry the message as it stands: a listener that asks for fields
268
+ reads the body stream first, and the reading parses — the one place the contract's wording
269
+ still needs that order, and a listener is the one consumer that can await.
270
+ - **The attributes are live.** `get_attributes()` reads the matched route's path parameters as
271
+ they are now — routing fills them after the message exists. The `with_*` members return plain
272
+ copies: immutable snapshots of the message as it stands, so a change a listener makes never
273
+ reaches the connection.
274
+ - **Once per request.** Both the request and the response are `lifetime="scoped"`, so every
275
+ endpoint, dependency and service of one request sees the same request message and the same
276
+ response. `RequestInterface` and `MessageInterface` lead to the same build, so a parameter
277
+ typed by any of the three gets it.
253
278
  - **One response, two names.** `xtr_http_kernel.bridge.Response` and `ResponseInterface` lead to
254
279
  that same build: an endpoint asks for the class and declares it returns it — parameter and
255
280
  return annotation the same type, nothing to narrow and nothing to configure — while a library
@@ -313,7 +338,7 @@ one:
313
338
 
314
339
  | Service | Does |
315
340
  |---|---|
316
- | `HttpMessageFactoryInterface` | `await create_request(request)` and `create_response(response)`: the framework's own, as messages |
341
+ | `HttpMessageFactoryInterface` | `serve(request)`: the framework's own request, as a served request — a read-through view nothing has read the body of; `await create_request(request)` and `create_response(response)`: the framework's own, as messages read whole |
317
342
  | `StarletteHttpFactoryInterface` | `await create_request(message, streamed=False)` and `await create_response(message, streamed=False)`: a message, as the framework's own |
318
343
 
319
344
  A response that arrived from outside the application — one a worker built with
@@ -357,11 +382,11 @@ The bundle registers these; which ones depend on what is installed and active:
357
382
 
358
383
  | Listener | Events | Active when | Does |
359
384
  |---|---|---|---|
360
- | `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 |
385
+ | `RequestIdListener` | `RequestEvent`, `ResponseEvent` | always | mints a `uuid4().hex` — or keeps a well-formed incoming id, where `trust_request_id` says to — puts it on the request's state bag, binds it to the log context when logging is around, and echoes it on the response |
361
386
  | `DisallowRobotsIndexingListener` | `ResponseEvent` | always | stamps `X-Robots-Tag: noindex` on every response, when the config turns it on |
362
387
  | `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 |
363
388
  | `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 |
364
- | `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 |
389
+ | `RateLimitHeadersListener` | `ResponseEvent` | rate limiter bundle active | writes the `X-RateLimit-*` headers of the [rate limit](#rate-limits) that speaks for the response — the one a route's limit left on the request's state bag — and makes it private |
365
390
 
366
391
  The two logging listeners join only when the logging bundle is active, and open and close the
367
392
  unit of work outside everything else so every record made while handling carries the request's
@@ -569,96 +594,98 @@ offending definition.
569
594
 
570
595
  ## Routes
571
596
 
572
- A bundle ships its routes, and an application gets them served with no code in its entry point.
573
- The application arranges them in its routes files, and `setup` adds the result.
597
+ Routes are native framework routes, end to end. A package ships them as *route resources* —
598
+ named routers its registration functions fill — and an application registers them in its
599
+ routes files. **Routing is never imported automatically:** a resource no routes file includes
600
+ is not served. The file a package's recipe writes into `<app>/config/routes/` is what
601
+ registers its endpoints; deleting the file uninstalls them.
574
602
 
575
- ### A bundle's routes
603
+ ### What a package ships
576
604
 
577
- A route loader is a class with `load_routes()`, declared with `@as_route_loader`. It names the
578
- *resource* an application imports its routes by:
605
+ A route resource is a plain function declared with `@as_route_resource`, filling an
606
+ `APIRouter` with the framework's own routes:
579
607
 
580
608
  ```python
581
- from xtr_http_kernel.routing import Route, RouteCollection
582
- from xtr_http_kernel.routing.decorator import as_route_loader
609
+ from fastapi import APIRouter
610
+ from xtr_http_kernel.routing.decorator import as_route_resource
583
611
 
584
612
 
585
- async def list_books() -> list[str]:
586
- return ["Dune"]
613
+ @as_route_resource("books")
614
+ def books_routes(router: APIRouter) -> None:
615
+ """Fill the router with the books endpoints."""
587
616
 
617
+ @router.get("/books", name="books_list")
618
+ async def list_books() -> list[str]:
619
+ return ["Dune"]
588
620
 
589
- @as_route_loader("books")
590
- class BooksRouteLoader:
591
- def load_routes(self) -> RouteCollection:
592
- routes = RouteCollection()
593
- routes.add("books_list", Route("/books", list_books, methods=("GET",)))
594
- return routes
621
+ @router.get("/books/{book_id}", name="books_show")
622
+ async def show_book(book_id: str) -> dict[str, str]:
623
+ return {"book": book_id}
595
624
  ```
596
625
 
597
- The loader is built with no arguments while the kernel compiles, before any service exists, so
598
- what it contributes is fixed by the code that ships it. A route a deployment turns off keeps
599
- answering — its endpoint refuses — and an application that wants it gone leaves it out in a
600
- routes file. Pass `prefixable=False` for routes a standard puts at a fixed place, such as a
601
- `/.well-known/` document: no routes file may move them under a prefix. A loader is found like any
602
- other declaration: in the application's scan, or in a module a bundle scans with
603
- `services.load(...)`.
604
-
605
- A `Route` carries a path, the endpoint the framework calls — its `Injected[...]` parameters
606
- untouched — and the options handed to the framework as they are: `methods`,
607
- `include_in_schema`, `tags`, `summary`, `status_code`, `response_model`. Its name is the key it
608
- has in its `RouteCollection`. A mounted route validates, documents and is reached by
609
- `url_path_for` exactly like the same function declared on a router.
626
+ Everything the framework's own decorators take — `dependencies=`, `response_class`,
627
+ `openapi_extra`, websockets — a resource takes: there is no layer in between. An endpoint
628
+ needing a service declares it `Injected[...]` as anywhere else; a controller is a service
629
+ like any other, and an application replaces one the way it replaces any service — its own
630
+ definition of the type outranks a bundle's.
631
+
632
+ The function is found like any other declaration — in the application's scan, or in a module
633
+ a bundle scans with `services.load(...)` — and `setup` calls it once per resource with a
634
+ fresh router, no services and no side effects: what a package contributes is fixed by the
635
+ code that ships it. Several functions may fill one resource; two routes of a resource
636
+ claiming one name are refused while staging. A route keeps the name it was declared with —
637
+ `url_path_for`, and anything building a URL a stranger keeps, builds it by name — and a
638
+ route a deployment turns off keeps answering: its endpoint refuses, and an application that
639
+ wants it gone filters it out of the staged router.
610
640
 
611
641
  ### The application's routes files
612
642
 
613
- A routes file is a function declared `@as_routes` in `<app>/config/routes/`, receiving a
614
- `RoutingConfigurator`:
643
+ A routes file is a function declared `@as_routes` in `<app>/config/routes/`, receiving the
644
+ application and every staged resource as a native router:
615
645
 
616
646
  ```python
617
647
  from xtr_http_kernel.routing.decorator import as_routes
618
- from xtr_http_kernel.routing.loader.configurator import RoutingConfigurator
619
648
 
620
649
 
621
650
  @as_routes
622
- def shop(routes: RoutingConfigurator) -> None:
623
- routes.import_("books").prefix("/shop").name_prefix("shop_")
624
- routes.add("shop_books_list", "/shop/catalogue") # move one route by its name
625
- routes.add("home", "/").endpoint(home).methods("GET")
626
-
627
-
628
- async def home() -> dict[str, str]:
629
- return {"page": "home"}
651
+ def shop(app: FastAPI, resources: Mapping[str, APIRouter]) -> None:
652
+ """Serve the books under /shop, under renamed names."""
653
+ books = resources["books"]
654
+ for route in books.routes:
655
+ route.name = f"shop_{route.name}"
656
+ app.include_router(books, prefix="/shop")
630
657
  ```
631
658
 
632
- - `import_(resource)` brings in a resource's routes, arranged with `prefix`, `name_prefix` and
633
- `exclude(*names)`; `skip(resource)` serves none of them. A resource no routes file imports or
634
- skips is served at its own paths and names.
635
- - `add(name, path)` defines a route. Under a name already in the table it replaces that route:
636
- without `.endpoint(...)` it moves the route and keeps the rest, with one it redefines it. Its
637
- options — `methods`, `priority`, `tags`, `summary`, `status_code`, `include_in_schema`,
638
- `response_model` — read like the `Route` fields.
639
- - `collection(name_prefix)` groups routes under one path prefix and name prefix;
640
- `alias(name, target)` adds another name for a route, optionally `.deprecate(...)`d.
641
- - Nothing reaches the table while the file runs: every call is applied when it returns, in the
642
- order written, so a route defined after an import replaces the imported one of that name.
643
- Each file starts from the resources as their loaders returned them.
644
-
645
- A mistake is refused while the kernel compiles — so at `setup` — with an `InvalidArgumentError`
646
- naming it: an unknown resource or route, a prefix on fixed routes, a new route with no endpoint,
647
- two loaders claiming one resource. The kernel scans only packages, so `config/routes/` needs an
648
- `__init__.py`; xtr-recipes creates it with the first routes file a recipe writes there.
659
+ The arrangement is native calls on a staged router — before it is included, so nothing
660
+ mounted is ever rebuilt:
661
+
662
+ - **Serve** — `app.include_router(resources["books"])`.
663
+ - **Move** — `app.include_router(resources["books"], prefix="/shop")`.
664
+ - **Skip** — do not include it; omission is the skip.
665
+ - **Leave one route out** — `books.routes = [r for r in books.routes if r.name != "books_show"]`
666
+ before including.
667
+ - **Rename the group** — the loop above sets `route.name` (and `route.operation_id`, when the
668
+ description matters); the framework has no name-prefixed include.
669
+ - **Replace a route** — declare the application's own before `setup` — the application's
670
+ routes come first — and filter the contributed one out of its resource.
671
+
672
+ The files run in scan order, and each sees the resources as staged. A resource is included by
673
+ the first file including it; a second include of one is refused, as every name of it would
674
+ land twice. A mistake is refused at `setup` with an `InvalidArgumentError` naming it: a file
675
+ asking for a resource nothing installed contributes, two routes of one resource claiming one
676
+ name. The kernel scans only packages, so `config/routes/` needs an `__init__.py`; xtr-recipes
677
+ creates it with the first routes file a recipe writes there.
649
678
 
650
679
  ### Collisions with the application's own routes
651
680
 
652
- The application's own routes are never changed, and they come first. A contributed route sharing
653
- a name with one the application declared, or one the application's routes would answer first —
654
- the same method and path under any parameter names, a catch-all, a mount, or a router the
655
- application included — fails `setup` with a `RouteConflictError` naming both; so do two
656
- contributed routes answering the same method and path. To change a contributed route on purpose,
657
- redefine it by its name in a routes file.
658
-
659
- An alias is mounted under its own name, out of the API description, so `url_path_for` reaches it.
660
- The table is also a service: `RouterInterface.get_route_collection()` returns a copy of what was
661
- mounted, the same on every start of the application, for code that reads a route by name.
681
+ The application's own routes — the ones declared before `setup` — are never changed, and they
682
+ come first: a catch-all or a mount of the application's shadowing a contributed route is the
683
+ application's own doing, and `router:match` answers who serves what. What `setup` refuses with
684
+ a `RouteConflictError` naming both is the silent kind: a contributed route sharing a name with
685
+ one the application declared, so `url_path_for` would guess; a contributed route answering a
686
+ method at the path shape of one the application declared — parameters told apart by shape, not
687
+ by name; and two contributed routes answering one method and path, the second of which no
688
+ request could ever reach.
662
689
 
663
690
  ## Router commands
664
691
 
@@ -737,20 +764,20 @@ itself, as well as from `xtr_http_kernel.exception`.
737
764
  | `UnknownRateLimiterError` | a `RateLimited` names a limiter the application did not configure; also a `LookupError` |
738
765
  | `UnsupportedConnectionError` | something asked for the request as a message while a WebSocket was being served |
739
766
 
740
- The route table's errors live in `xtr_http_kernel.routing`, under `RoutingError` — itself an
767
+ The routing layer's errors live in `xtr_http_kernel.routing`, under `RoutingError` — itself an
741
768
  `HttpKernelError`:
742
769
 
743
770
  | Error | Raised when |
744
771
  |---|---|
745
- | `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` |
746
- | `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 |
747
- | `RouteCircularReferenceError` | aliases lead back to a name they already passed |
772
+ | `InvalidArgumentError` | a routes file asks for a resource nothing installed contributes, or is `async`; two routes of one resource claim one name; `@as_route_resource` decorates anything but a plain function, or names no resource. Also the library's own `InvalidArgumentError`, so a `ValueError` |
773
+ | `RouteConflictError` | `setup` finds a contributed route sharing a name with one the application declared, answering a method at the path shape of one the application declared, or answering a method and path shape another contributed route answers; or a resource included twice |
748
774
 
749
775
  ## Layout
750
776
 
751
777
  ```
752
778
  xtr_http_kernel/
753
779
  ├── setup.py setup(app, kernel), the one call an application makes
780
+ ├── _staging.py stages the resources, runs the routes files, refuses the collisions
754
781
  ├── testing.py override_services, for a served application under test
755
782
  ├── bridge/ the request and the response as HTTP messages, both ways
756
783
  ├── event/ the five lifecycle events, one class per file
@@ -760,7 +787,7 @@ xtr_http_kernel/
760
787
  ├── middleware_stack.py the ordered chain a bundle contributes to
761
788
  ├── middleware_tag.py MIDDLEWARE_TAG, the tag bundles agree on
762
789
  ├── rate_limiter/ RateLimited, when xtr-rate-limiter is installed
763
- ├── routing/ Route, RouteCollection, Router, the loaders and configurators
790
+ ├── routing/ as_route_resource, as_routes, the registry setup stages from
764
791
  ├── command/ debug:router and router:match
765
792
  ├── exception/ HttpKernelError, the root of everything this library raises
766
793
  └── bundle/ HttpKernelBundle and HttpKernelConfig
@@ -133,7 +133,10 @@ surfaces then, rather than a request running without its lifecycle.
133
133
  ## The lifecycle
134
134
 
135
135
  Every request goes through the same events, and a listener joins wherever it has something to
136
- contribute. Each is dispatched at most once, however the request went:
136
+ contribute. Each is dispatched at most once, however the request went, and each carries the
137
+ request as a message — the served request below — plus `state`, the per-request state bag the
138
+ framework's own `request.state` wraps: what a listener puts there, the framework and every other
139
+ listener read:
137
140
 
138
141
  | Event | `KernelEvents` | When | What a listener may do |
139
142
  |---|---|---|---|
@@ -214,12 +217,34 @@ async def issue(
214
217
  return response.with_status(201).with_header("content-type", "application/json")
215
218
  ```
216
219
 
217
- - **Built only when asked.** The request is built the first time something asks for it, from
218
- FastAPI's own request object — the body it already read, the form it already parsed. A route
219
- that does not ask pays nothing, and nothing is buffered anywhere.
220
- - **Once per request.** Both are `lifetime="scoped"`, so every endpoint, dependency and service
221
- of one request sees the same request message and the same response. `RequestInterface` and
222
- `MessageInterface` lead to the same build, so a parameter typed by any of the three gets it.
220
+ - **The served request, read through the connection.** The request the events carry is the
221
+ message package's own `ServerRequest`, born connected over the bridge's `ServedConnection`:
222
+ its head — method, URI, headers, cookies, query parameters, environment parameters, protocol
223
+ version — is read straight off the connection, and its body is one lazy stream. Nothing is
224
+ read off the wire until a listener or an endpoint reads that stream; the first read
225
+ materialises the whole body once, parsing it per its content type on the way, and every later
226
+ read is served out of the buffer, seekable and rewindable. One instance is parked on the
227
+ connection's scope, so the events, the scoped `server_request` service and a route's injected
228
+ message are all the same object.
229
+ - **A listener that reads the body drains the wire** — so the application is handed a `receive`
230
+ that replays the buffered bytes, and its routes read the body the caller sent as if nobody
231
+ had. A request no listener reads passes its original channel through untouched: nothing is
232
+ buffered unless something asks to read it.
233
+ - **The parsed body answers by the time an endpoint asks.** A body that has a parsed side — a
234
+ form, JSON — is materialised before the container hands the message to a service or an
235
+ endpoint, so `get_parsed_body()` and `get_uploaded_files()` answer what the message contract
236
+ promises, unconditionally, for every consumer written against it. A body with no parsed side
237
+ is left unread. The events carry the message as it stands: a listener that asks for fields
238
+ reads the body stream first, and the reading parses — the one place the contract's wording
239
+ still needs that order, and a listener is the one consumer that can await.
240
+ - **The attributes are live.** `get_attributes()` reads the matched route's path parameters as
241
+ they are now — routing fills them after the message exists. The `with_*` members return plain
242
+ copies: immutable snapshots of the message as it stands, so a change a listener makes never
243
+ reaches the connection.
244
+ - **Once per request.** Both the request and the response are `lifetime="scoped"`, so every
245
+ endpoint, dependency and service of one request sees the same request message and the same
246
+ response. `RequestInterface` and `MessageInterface` lead to the same build, so a parameter
247
+ typed by any of the three gets it.
223
248
  - **One response, two names.** `xtr_http_kernel.bridge.Response` and `ResponseInterface` lead to
224
249
  that same build: an endpoint asks for the class and declares it returns it — parameter and
225
250
  return annotation the same type, nothing to narrow and nothing to configure — while a library
@@ -283,7 +308,7 @@ one:
283
308
 
284
309
  | Service | Does |
285
310
  |---|---|
286
- | `HttpMessageFactoryInterface` | `await create_request(request)` and `create_response(response)`: the framework's own, as messages |
311
+ | `HttpMessageFactoryInterface` | `serve(request)`: the framework's own request, as a served request — a read-through view nothing has read the body of; `await create_request(request)` and `create_response(response)`: the framework's own, as messages read whole |
287
312
  | `StarletteHttpFactoryInterface` | `await create_request(message, streamed=False)` and `await create_response(message, streamed=False)`: a message, as the framework's own |
288
313
 
289
314
  A response that arrived from outside the application — one a worker built with
@@ -327,11 +352,11 @@ The bundle registers these; which ones depend on what is installed and active:
327
352
 
328
353
  | Listener | Events | Active when | Does |
329
354
  |---|---|---|---|
330
- | `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 |
355
+ | `RequestIdListener` | `RequestEvent`, `ResponseEvent` | always | mints a `uuid4().hex` — or keeps a well-formed incoming id, where `trust_request_id` says to — puts it on the request's state bag, binds it to the log context when logging is around, and echoes it on the response |
331
356
  | `DisallowRobotsIndexingListener` | `ResponseEvent` | always | stamps `X-Robots-Tag: noindex` on every response, when the config turns it on |
332
357
  | `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 |
333
358
  | `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 |
334
- | `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 |
359
+ | `RateLimitHeadersListener` | `ResponseEvent` | rate limiter bundle active | writes the `X-RateLimit-*` headers of the [rate limit](#rate-limits) that speaks for the response — the one a route's limit left on the request's state bag — and makes it private |
335
360
 
336
361
  The two logging listeners join only when the logging bundle is active, and open and close the
337
362
  unit of work outside everything else so every record made while handling carries the request's
@@ -539,96 +564,98 @@ offending definition.
539
564
 
540
565
  ## Routes
541
566
 
542
- A bundle ships its routes, and an application gets them served with no code in its entry point.
543
- The application arranges them in its routes files, and `setup` adds the result.
567
+ Routes are native framework routes, end to end. A package ships them as *route resources* —
568
+ named routers its registration functions fill — and an application registers them in its
569
+ routes files. **Routing is never imported automatically:** a resource no routes file includes
570
+ is not served. The file a package's recipe writes into `<app>/config/routes/` is what
571
+ registers its endpoints; deleting the file uninstalls them.
544
572
 
545
- ### A bundle's routes
573
+ ### What a package ships
546
574
 
547
- A route loader is a class with `load_routes()`, declared with `@as_route_loader`. It names the
548
- *resource* an application imports its routes by:
575
+ A route resource is a plain function declared with `@as_route_resource`, filling an
576
+ `APIRouter` with the framework's own routes:
549
577
 
550
578
  ```python
551
- from xtr_http_kernel.routing import Route, RouteCollection
552
- from xtr_http_kernel.routing.decorator import as_route_loader
579
+ from fastapi import APIRouter
580
+ from xtr_http_kernel.routing.decorator import as_route_resource
553
581
 
554
582
 
555
- async def list_books() -> list[str]:
556
- return ["Dune"]
583
+ @as_route_resource("books")
584
+ def books_routes(router: APIRouter) -> None:
585
+ """Fill the router with the books endpoints."""
557
586
 
587
+ @router.get("/books", name="books_list")
588
+ async def list_books() -> list[str]:
589
+ return ["Dune"]
558
590
 
559
- @as_route_loader("books")
560
- class BooksRouteLoader:
561
- def load_routes(self) -> RouteCollection:
562
- routes = RouteCollection()
563
- routes.add("books_list", Route("/books", list_books, methods=("GET",)))
564
- return routes
591
+ @router.get("/books/{book_id}", name="books_show")
592
+ async def show_book(book_id: str) -> dict[str, str]:
593
+ return {"book": book_id}
565
594
  ```
566
595
 
567
- The loader is built with no arguments while the kernel compiles, before any service exists, so
568
- what it contributes is fixed by the code that ships it. A route a deployment turns off keeps
569
- answering — its endpoint refuses — and an application that wants it gone leaves it out in a
570
- routes file. Pass `prefixable=False` for routes a standard puts at a fixed place, such as a
571
- `/.well-known/` document: no routes file may move them under a prefix. A loader is found like any
572
- other declaration: in the application's scan, or in a module a bundle scans with
573
- `services.load(...)`.
574
-
575
- A `Route` carries a path, the endpoint the framework calls — its `Injected[...]` parameters
576
- untouched — and the options handed to the framework as they are: `methods`,
577
- `include_in_schema`, `tags`, `summary`, `status_code`, `response_model`. Its name is the key it
578
- has in its `RouteCollection`. A mounted route validates, documents and is reached by
579
- `url_path_for` exactly like the same function declared on a router.
596
+ Everything the framework's own decorators take — `dependencies=`, `response_class`,
597
+ `openapi_extra`, websockets — a resource takes: there is no layer in between. An endpoint
598
+ needing a service declares it `Injected[...]` as anywhere else; a controller is a service
599
+ like any other, and an application replaces one the way it replaces any service — its own
600
+ definition of the type outranks a bundle's.
601
+
602
+ The function is found like any other declaration — in the application's scan, or in a module
603
+ a bundle scans with `services.load(...)` — and `setup` calls it once per resource with a
604
+ fresh router, no services and no side effects: what a package contributes is fixed by the
605
+ code that ships it. Several functions may fill one resource; two routes of a resource
606
+ claiming one name are refused while staging. A route keeps the name it was declared with —
607
+ `url_path_for`, and anything building a URL a stranger keeps, builds it by name — and a
608
+ route a deployment turns off keeps answering: its endpoint refuses, and an application that
609
+ wants it gone filters it out of the staged router.
580
610
 
581
611
  ### The application's routes files
582
612
 
583
- A routes file is a function declared `@as_routes` in `<app>/config/routes/`, receiving a
584
- `RoutingConfigurator`:
613
+ A routes file is a function declared `@as_routes` in `<app>/config/routes/`, receiving the
614
+ application and every staged resource as a native router:
585
615
 
586
616
  ```python
587
617
  from xtr_http_kernel.routing.decorator import as_routes
588
- from xtr_http_kernel.routing.loader.configurator import RoutingConfigurator
589
618
 
590
619
 
591
620
  @as_routes
592
- def shop(routes: RoutingConfigurator) -> None:
593
- routes.import_("books").prefix("/shop").name_prefix("shop_")
594
- routes.add("shop_books_list", "/shop/catalogue") # move one route by its name
595
- routes.add("home", "/").endpoint(home).methods("GET")
596
-
597
-
598
- async def home() -> dict[str, str]:
599
- return {"page": "home"}
621
+ def shop(app: FastAPI, resources: Mapping[str, APIRouter]) -> None:
622
+ """Serve the books under /shop, under renamed names."""
623
+ books = resources["books"]
624
+ for route in books.routes:
625
+ route.name = f"shop_{route.name}"
626
+ app.include_router(books, prefix="/shop")
600
627
  ```
601
628
 
602
- - `import_(resource)` brings in a resource's routes, arranged with `prefix`, `name_prefix` and
603
- `exclude(*names)`; `skip(resource)` serves none of them. A resource no routes file imports or
604
- skips is served at its own paths and names.
605
- - `add(name, path)` defines a route. Under a name already in the table it replaces that route:
606
- without `.endpoint(...)` it moves the route and keeps the rest, with one it redefines it. Its
607
- options — `methods`, `priority`, `tags`, `summary`, `status_code`, `include_in_schema`,
608
- `response_model` — read like the `Route` fields.
609
- - `collection(name_prefix)` groups routes under one path prefix and name prefix;
610
- `alias(name, target)` adds another name for a route, optionally `.deprecate(...)`d.
611
- - Nothing reaches the table while the file runs: every call is applied when it returns, in the
612
- order written, so a route defined after an import replaces the imported one of that name.
613
- Each file starts from the resources as their loaders returned them.
614
-
615
- A mistake is refused while the kernel compiles — so at `setup` — with an `InvalidArgumentError`
616
- naming it: an unknown resource or route, a prefix on fixed routes, a new route with no endpoint,
617
- two loaders claiming one resource. The kernel scans only packages, so `config/routes/` needs an
618
- `__init__.py`; xtr-recipes creates it with the first routes file a recipe writes there.
629
+ The arrangement is native calls on a staged router — before it is included, so nothing
630
+ mounted is ever rebuilt:
631
+
632
+ - **Serve** — `app.include_router(resources["books"])`.
633
+ - **Move** — `app.include_router(resources["books"], prefix="/shop")`.
634
+ - **Skip** — do not include it; omission is the skip.
635
+ - **Leave one route out** — `books.routes = [r for r in books.routes if r.name != "books_show"]`
636
+ before including.
637
+ - **Rename the group** — the loop above sets `route.name` (and `route.operation_id`, when the
638
+ description matters); the framework has no name-prefixed include.
639
+ - **Replace a route** — declare the application's own before `setup` — the application's
640
+ routes come first — and filter the contributed one out of its resource.
641
+
642
+ The files run in scan order, and each sees the resources as staged. A resource is included by
643
+ the first file including it; a second include of one is refused, as every name of it would
644
+ land twice. A mistake is refused at `setup` with an `InvalidArgumentError` naming it: a file
645
+ asking for a resource nothing installed contributes, two routes of one resource claiming one
646
+ name. The kernel scans only packages, so `config/routes/` needs an `__init__.py`; xtr-recipes
647
+ creates it with the first routes file a recipe writes there.
619
648
 
620
649
  ### Collisions with the application's own routes
621
650
 
622
- The application's own routes are never changed, and they come first. A contributed route sharing
623
- a name with one the application declared, or one the application's routes would answer first —
624
- the same method and path under any parameter names, a catch-all, a mount, or a router the
625
- application included — fails `setup` with a `RouteConflictError` naming both; so do two
626
- contributed routes answering the same method and path. To change a contributed route on purpose,
627
- redefine it by its name in a routes file.
628
-
629
- An alias is mounted under its own name, out of the API description, so `url_path_for` reaches it.
630
- The table is also a service: `RouterInterface.get_route_collection()` returns a copy of what was
631
- mounted, the same on every start of the application, for code that reads a route by name.
651
+ The application's own routes — the ones declared before `setup` — are never changed, and they
652
+ come first: a catch-all or a mount of the application's shadowing a contributed route is the
653
+ application's own doing, and `router:match` answers who serves what. What `setup` refuses with
654
+ a `RouteConflictError` naming both is the silent kind: a contributed route sharing a name with
655
+ one the application declared, so `url_path_for` would guess; a contributed route answering a
656
+ method at the path shape of one the application declared — parameters told apart by shape, not
657
+ by name; and two contributed routes answering one method and path, the second of which no
658
+ request could ever reach.
632
659
 
633
660
  ## Router commands
634
661
 
@@ -707,20 +734,20 @@ itself, as well as from `xtr_http_kernel.exception`.
707
734
  | `UnknownRateLimiterError` | a `RateLimited` names a limiter the application did not configure; also a `LookupError` |
708
735
  | `UnsupportedConnectionError` | something asked for the request as a message while a WebSocket was being served |
709
736
 
710
- The route table's errors live in `xtr_http_kernel.routing`, under `RoutingError` — itself an
737
+ The routing layer's errors live in `xtr_http_kernel.routing`, under `RoutingError` — itself an
711
738
  `HttpKernelError`:
712
739
 
713
740
  | Error | Raised when |
714
741
  |---|---|
715
- | `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` |
716
- | `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 |
717
- | `RouteCircularReferenceError` | aliases lead back to a name they already passed |
742
+ | `InvalidArgumentError` | a routes file asks for a resource nothing installed contributes, or is `async`; two routes of one resource claim one name; `@as_route_resource` decorates anything but a plain function, or names no resource. Also the library's own `InvalidArgumentError`, so a `ValueError` |
743
+ | `RouteConflictError` | `setup` finds a contributed route sharing a name with one the application declared, answering a method at the path shape of one the application declared, or answering a method and path shape another contributed route answers; or a resource included twice |
718
744
 
719
745
  ## Layout
720
746
 
721
747
  ```
722
748
  xtr_http_kernel/
723
749
  ├── setup.py setup(app, kernel), the one call an application makes
750
+ ├── _staging.py stages the resources, runs the routes files, refuses the collisions
724
751
  ├── testing.py override_services, for a served application under test
725
752
  ├── bridge/ the request and the response as HTTP messages, both ways
726
753
  ├── event/ the five lifecycle events, one class per file
@@ -730,7 +757,7 @@ xtr_http_kernel/
730
757
  ├── middleware_stack.py the ordered chain a bundle contributes to
731
758
  ├── middleware_tag.py MIDDLEWARE_TAG, the tag bundles agree on
732
759
  ├── rate_limiter/ RateLimited, when xtr-rate-limiter is installed
733
- ├── routing/ Route, RouteCollection, Router, the loaders and configurators
760
+ ├── routing/ as_route_resource, as_routes, the registry setup stages from
734
761
  ├── command/ debug:router and router:match
735
762
  ├── exception/ HttpKernelError, the root of everything this library raises
736
763
  └── bundle/ HttpKernelBundle and HttpKernelConfig