xtr-http-kernel 1.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xtr_http_kernel/__init__.py +52 -0
- xtr_http_kernel/_kernel_middleware.py +76 -0
- xtr_http_kernel/_state.py +17 -0
- xtr_http_kernel/bundle/__init__.py +8 -0
- xtr_http_kernel/bundle/http_kernel_bundle.py +211 -0
- xtr_http_kernel/bundle/http_kernel_config.py +62 -0
- xtr_http_kernel/bundle/request_lifecycle_middleware_factory.py +35 -0
- xtr_http_kernel/command/__init__.py +9 -0
- xtr_http_kernel/command/_route_contexts.py +113 -0
- xtr_http_kernel/command/debug_router_command.py +52 -0
- xtr_http_kernel/command/route_description.py +81 -0
- xtr_http_kernel/command/router_command.py +69 -0
- xtr_http_kernel/command/router_match_command.py +96 -0
- xtr_http_kernel/event/__init__.py +35 -0
- xtr_http_kernel/event/exception_event.py +65 -0
- xtr_http_kernel/event/finish_request_event.py +40 -0
- xtr_http_kernel/event/request_event.py +56 -0
- xtr_http_kernel/event/response_event.py +64 -0
- xtr_http_kernel/event/terminate_event.py +43 -0
- xtr_http_kernel/event_listener/__init__.py +23 -0
- xtr_http_kernel/event_listener/disallow_robots_indexing_listener.py +31 -0
- xtr_http_kernel/event_listener/error_logging_listener.py +52 -0
- xtr_http_kernel/event_listener/log_unit_listener.py +39 -0
- xtr_http_kernel/event_listener/request_id_listener.py +72 -0
- xtr_http_kernel/exception/__init__.py +14 -0
- xtr_http_kernel/exception/http_kernel_error.py +15 -0
- xtr_http_kernel/exception/invalid_middleware_priority_error.py +39 -0
- xtr_http_kernel/kernel_events.py +51 -0
- xtr_http_kernel/middleware_stack.py +52 -0
- xtr_http_kernel/middleware_tag.py +19 -0
- xtr_http_kernel/py.typed +0 -0
- xtr_http_kernel/request_lifecycle_middleware.py +182 -0
- xtr_http_kernel/setup.py +96 -0
- xtr_http_kernel/testing.py +52 -0
- xtr_http_kernel-1.4.0.dist-info/METADATA +432 -0
- xtr_http_kernel-1.4.0.dist-info/RECORD +39 -0
- xtr_http_kernel-1.4.0.dist-info/WHEEL +4 -0
- xtr_http_kernel-1.4.0.dist-info/entry_points.txt +3 -0
- xtr_http_kernel-1.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Requests turned into responses through events: a request lifecycle for web applications.
|
|
2
|
+
|
|
3
|
+
A web framework routes a request to the function that answers it. Everything
|
|
4
|
+
an application wants around that function — a request id on every response,
|
|
5
|
+
an uncaught exception written to the log with the request that caused it, a
|
|
6
|
+
header that keeps a page out of a search index — is not the endpoint's
|
|
7
|
+
business, and does not belong in each of them.
|
|
8
|
+
|
|
9
|
+
This library puts those between the framework and the endpoint as *events*:
|
|
10
|
+
a request announces itself, the response it produced announces itself, and
|
|
11
|
+
whoever listens contributes. Listeners come from the container, so they are
|
|
12
|
+
services like any other.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
18
|
+
|
|
19
|
+
from .event import (
|
|
20
|
+
ExceptionEvent,
|
|
21
|
+
FinishRequestEvent,
|
|
22
|
+
RequestEvent,
|
|
23
|
+
ResponseEvent,
|
|
24
|
+
TerminateEvent,
|
|
25
|
+
)
|
|
26
|
+
from .exception import HttpKernelError
|
|
27
|
+
from .kernel_events import KernelEvents
|
|
28
|
+
from .middleware_stack import MiddlewareStack
|
|
29
|
+
from .middleware_tag import MIDDLEWARE_TAG
|
|
30
|
+
from .request_lifecycle_middleware import RequestLifecycleMiddleware
|
|
31
|
+
from .setup import setup
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
__version__ = version("xtr-http-kernel")
|
|
35
|
+
except PackageNotFoundError: # pragma: no cover
|
|
36
|
+
# Running from a source tree with no installed metadata to read.
|
|
37
|
+
__version__ = "0+unknown"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"MIDDLEWARE_TAG",
|
|
41
|
+
"ExceptionEvent",
|
|
42
|
+
"FinishRequestEvent",
|
|
43
|
+
"HttpKernelError",
|
|
44
|
+
"KernelEvents",
|
|
45
|
+
"MiddlewareStack",
|
|
46
|
+
"RequestEvent",
|
|
47
|
+
"RequestLifecycleMiddleware",
|
|
48
|
+
"ResponseEvent",
|
|
49
|
+
"TerminateEvent",
|
|
50
|
+
"__version__",
|
|
51
|
+
"setup",
|
|
52
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The one middleware the setup call adds: a container scope around each request.
|
|
2
|
+
|
|
3
|
+
Plain ASGI on purpose — no base class dispatch, no response buffering — so
|
|
4
|
+
the scope opens before anything downstream runs and closes only when the
|
|
5
|
+
downstream returned, which is after the response's last chunk left. The
|
|
6
|
+
scope opener arrives through the constructor rather than an import, so this
|
|
7
|
+
module stays free of the container layer.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Final, cast, final
|
|
13
|
+
|
|
14
|
+
from ._state import STACK_KEY
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from contextlib import AbstractAsyncContextManager
|
|
19
|
+
|
|
20
|
+
from starlette.applications import Starlette
|
|
21
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
22
|
+
|
|
23
|
+
from .middleware_stack import MiddlewareStack
|
|
24
|
+
|
|
25
|
+
ScopeOpener = Callable[[Starlette], AbstractAsyncContextManager[None]]
|
|
26
|
+
|
|
27
|
+
__all__ = ["KernelMiddleware"]
|
|
28
|
+
|
|
29
|
+
_SCOPED_TYPES: Final = frozenset({"http", "websocket"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@final
|
|
33
|
+
class KernelMiddleware:
|
|
34
|
+
"""Runs each connection inside the scope its scoped services live in.
|
|
35
|
+
|
|
36
|
+
``http`` and ``websocket`` connections run through the middleware stack
|
|
37
|
+
the application's current life parked on its state — composed over the
|
|
38
|
+
downstream app — with the scope open around the whole of it, so a
|
|
39
|
+
scoped service lives until the response has been sent. The scope closes
|
|
40
|
+
on every path, a failing downstream included; the failure still
|
|
41
|
+
propagates. The ``lifespan`` connection passes through untouched.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
__slots__ = ("_app", "_chain", "_open_scope", "_stack")
|
|
45
|
+
|
|
46
|
+
def __init__(self, app: ASGIApp, *, open_scope: ScopeOpener) -> None:
|
|
47
|
+
"""Wrap ``app``, opening scopes through ``open_scope``."""
|
|
48
|
+
self._app = app
|
|
49
|
+
self._open_scope = open_scope
|
|
50
|
+
self._stack: MiddlewareStack | None = None
|
|
51
|
+
self._chain: ASGIApp = app
|
|
52
|
+
|
|
53
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
54
|
+
"""Carry one connection through, scoped when it is a request."""
|
|
55
|
+
if cast("str", scope["type"]) not in _SCOPED_TYPES:
|
|
56
|
+
await self._app(scope, receive, send)
|
|
57
|
+
return
|
|
58
|
+
application = cast("Starlette", scope["app"])
|
|
59
|
+
chain = self._composed(application)
|
|
60
|
+
async with self._open_scope(application):
|
|
61
|
+
await chain(scope, receive, send)
|
|
62
|
+
|
|
63
|
+
def _composed(self, application: Starlette) -> ASGIApp:
|
|
64
|
+
"""Return the chain for the application's current life, composed once.
|
|
65
|
+
|
|
66
|
+
The stack is frozen per application life, so its identity tells
|
|
67
|
+
whether the cached chain still stands.
|
|
68
|
+
"""
|
|
69
|
+
stack = cast(
|
|
70
|
+
"MiddlewareStack | None",
|
|
71
|
+
getattr(application.state, STACK_KEY, None),
|
|
72
|
+
)
|
|
73
|
+
if stack is not self._stack:
|
|
74
|
+
self._stack = stack
|
|
75
|
+
self._chain = self._app if stack is None else stack.wrap(self._app)
|
|
76
|
+
return self._chain
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Where the setup call, its middleware and the test seam meet: the app's state.
|
|
2
|
+
|
|
3
|
+
Kept apart so the middleware module shares these keys with the setup and
|
|
4
|
+
testing modules without importing either.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
__all__ = ["OVERRIDES_KEY", "STACK_KEY"]
|
|
12
|
+
|
|
13
|
+
STACK_KEY: Final = "_xtr_http_kernel_middleware"
|
|
14
|
+
"""Where each application life parks the kernel's middleware stack."""
|
|
15
|
+
|
|
16
|
+
OVERRIDES_KEY: Final = "_xtr_http_kernel_overrides"
|
|
17
|
+
"""Where :func:`xtr_http_kernel.testing.override_services` parks its mapping."""
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""The xtr-http-kernel bundle: the request lifecycle, under the container.
|
|
2
|
+
|
|
3
|
+
The bundle is the integration, never the library: an application drives the
|
|
4
|
+
lifecycle itself with :func:`xtr_http_kernel.setup`, and lists this bundle so
|
|
5
|
+
the container owns the pieces the lifecycle reaches for — the middleware
|
|
6
|
+
factory the setup call composes, and the listeners that give every request
|
|
7
|
+
an id, a log record on failure and a unit of work of its own.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Final, cast, final
|
|
13
|
+
|
|
14
|
+
from typing_extensions import override
|
|
15
|
+
|
|
16
|
+
# ServiceKey stays a runtime import: it annotates a factory parameter the
|
|
17
|
+
# container evaluates when the definitions are emitted.
|
|
18
|
+
from xtr_dependency_injection import (
|
|
19
|
+
Bundle,
|
|
20
|
+
ServiceKey,
|
|
21
|
+
as_bundle,
|
|
22
|
+
bundle_active,
|
|
23
|
+
required_bundle,
|
|
24
|
+
)
|
|
25
|
+
from xtr_event_dispatcher.bundle import EventDispatcherBundle
|
|
26
|
+
|
|
27
|
+
# Read at runtime: the container fills factory parameters from annotations.
|
|
28
|
+
from xtr_logging_contracts import LoggerInterface
|
|
29
|
+
from xtr_service_contracts import ContainerInterface # noqa: TC002 — read at runtime, as above
|
|
30
|
+
|
|
31
|
+
from xtr_http_kernel.event import ExceptionEvent, RequestEvent, ResponseEvent, TerminateEvent
|
|
32
|
+
from xtr_http_kernel.event_listener import (
|
|
33
|
+
DisallowRobotsIndexingListener,
|
|
34
|
+
ErrorLoggingListener,
|
|
35
|
+
RequestIdListener,
|
|
36
|
+
)
|
|
37
|
+
from xtr_http_kernel.exception import InvalidMiddlewarePriorityError
|
|
38
|
+
from xtr_http_kernel.middleware_stack import MiddlewareStack
|
|
39
|
+
from xtr_http_kernel.middleware_tag import MIDDLEWARE_TAG
|
|
40
|
+
|
|
41
|
+
from .http_kernel_config import HttpKernelConfig
|
|
42
|
+
from .request_lifecycle_middleware_factory import RequestLifecycleMiddlewareFactory
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from collections.abc import Callable, Coroutine
|
|
46
|
+
|
|
47
|
+
from xtr_dependency_injection import ContainerBuilder, ServiceConfigurator
|
|
48
|
+
|
|
49
|
+
from xtr_http_kernel.middleware_stack import MiddlewareFactory
|
|
50
|
+
|
|
51
|
+
__all__ = ["HttpKernelBundle"]
|
|
52
|
+
|
|
53
|
+
_LISTENER_TAG: Final = "event_dispatcher.listener"
|
|
54
|
+
"""The tag the event dispatcher bundle reads listeners from."""
|
|
55
|
+
|
|
56
|
+
# The unit of work opens before anything else contributes and closes after
|
|
57
|
+
# everything else has heard the terminate; the request id settles right after
|
|
58
|
+
# the unit opens, so every record made while handling carries it.
|
|
59
|
+
_UNIT_OPEN_PRIORITY: Final = 8192
|
|
60
|
+
_REQUEST_ID_PRIORITY: Final = 4096
|
|
61
|
+
_UNIT_CLOSE_PRIORITY: Final = -8192
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@final
|
|
65
|
+
@required_bundle(EventDispatcherBundle)
|
|
66
|
+
@required_bundle("xtr_logging.bundle:LoggingBundle", ignore_on_invalid=True)
|
|
67
|
+
@required_bundle("xtr_console.bundle:ConsoleBundle", ignore_on_invalid=True)
|
|
68
|
+
@as_bundle("http_kernel", config=HttpKernelConfig)
|
|
69
|
+
class HttpKernelBundle(Bundle[HttpKernelConfig]):
|
|
70
|
+
"""Puts the request lifecycle's services under the container."""
|
|
71
|
+
|
|
72
|
+
@override
|
|
73
|
+
def prepend_extension(self, builder: ContainerBuilder) -> None:
|
|
74
|
+
"""When ``logging`` is active, declare the default request channel on its config.
|
|
75
|
+
|
|
76
|
+
The channel name here is the config default: this bundle's own config
|
|
77
|
+
resolves after logging's, so a renamed channel is declared by the
|
|
78
|
+
application in its logging configuration instead.
|
|
79
|
+
"""
|
|
80
|
+
if bundle_active(builder, "logging"):
|
|
81
|
+
builder.prepend_extension_config("logging", _add_request_channel)
|
|
82
|
+
|
|
83
|
+
@override
|
|
84
|
+
def load_extension(
|
|
85
|
+
self,
|
|
86
|
+
config: HttpKernelConfig,
|
|
87
|
+
services: ServiceConfigurator,
|
|
88
|
+
builder: ContainerBuilder,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Register the middleware factory, the listeners and the commands from ``config``.
|
|
91
|
+
|
|
92
|
+
The listeners that write to a log join only when the logging bundle
|
|
93
|
+
is active, and the router commands only when a console bundle is —
|
|
94
|
+
loading their module late keeps the console dependency out of the
|
|
95
|
+
graph of an application that never runs one.
|
|
96
|
+
"""
|
|
97
|
+
_ = services.set(RequestLifecycleMiddlewareFactory).add_tag(
|
|
98
|
+
MIDDLEWARE_TAG, priority=config.middleware_priority
|
|
99
|
+
)
|
|
100
|
+
_ = services.set(_middleware_stack).set_argument("keys", ())
|
|
101
|
+
_ = (
|
|
102
|
+
services.set(RequestIdListener)
|
|
103
|
+
.set_argument("header", config.request_id_header)
|
|
104
|
+
.set_argument("trust_incoming", config.trust_request_id)
|
|
105
|
+
.add_tag(
|
|
106
|
+
_LISTENER_TAG,
|
|
107
|
+
event=RequestEvent,
|
|
108
|
+
method="on_request",
|
|
109
|
+
priority=_REQUEST_ID_PRIORITY,
|
|
110
|
+
)
|
|
111
|
+
.add_tag(_LISTENER_TAG, event=ResponseEvent, method="on_response")
|
|
112
|
+
)
|
|
113
|
+
_ = (
|
|
114
|
+
services.set(DisallowRobotsIndexingListener)
|
|
115
|
+
.set_argument("enabled", config.disallow_search_indexing)
|
|
116
|
+
.add_tag(_LISTENER_TAG, event=ResponseEvent, method="on_response")
|
|
117
|
+
)
|
|
118
|
+
if bundle_active(builder, "logging"):
|
|
119
|
+
# Needs the optional logging extra, which being here proves installed.
|
|
120
|
+
from xtr_http_kernel.event_listener.log_unit_listener import ( # noqa: PLC0415
|
|
121
|
+
LogUnitListener,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
_ = (
|
|
125
|
+
services.set(LogUnitListener)
|
|
126
|
+
.add_tag(
|
|
127
|
+
_LISTENER_TAG,
|
|
128
|
+
event=RequestEvent,
|
|
129
|
+
method="on_request",
|
|
130
|
+
priority=_UNIT_OPEN_PRIORITY,
|
|
131
|
+
)
|
|
132
|
+
.add_tag(
|
|
133
|
+
_LISTENER_TAG,
|
|
134
|
+
event=TerminateEvent,
|
|
135
|
+
method="on_terminate",
|
|
136
|
+
priority=_UNIT_CLOSE_PRIORITY,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
_ = services.set(_error_logging_listener(config.log_channel)).add_tag(
|
|
140
|
+
_LISTENER_TAG, event=ExceptionEvent, method="on_exception"
|
|
141
|
+
)
|
|
142
|
+
if bundle_active(builder, "console"):
|
|
143
|
+
services.load("xtr_http_kernel.command")
|
|
144
|
+
|
|
145
|
+
@override
|
|
146
|
+
def process(self, builder: ContainerBuilder) -> None:
|
|
147
|
+
"""Order every tagged middleware into the stack, highest priority outermost.
|
|
148
|
+
|
|
149
|
+
Runs after every bundle loaded, so the builder holds every tag. The
|
|
150
|
+
tag's ``priority`` attribute is 0 when absent; ties keep
|
|
151
|
+
registration order.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
InvalidMiddlewarePriorityError: If a tag's ``priority`` is not
|
|
155
|
+
an integer.
|
|
156
|
+
"""
|
|
157
|
+
entries: list[tuple[int, int, ServiceKey]] = []
|
|
158
|
+
for order, (key, attributes) in enumerate(
|
|
159
|
+
builder.find_tagged_service_ids(MIDDLEWARE_TAG).items()
|
|
160
|
+
):
|
|
161
|
+
priority = attributes[0].get("priority", 0)
|
|
162
|
+
if not isinstance(priority, int):
|
|
163
|
+
raise InvalidMiddlewarePriorityError(key, priority)
|
|
164
|
+
entries.append((priority, order, key))
|
|
165
|
+
entries.sort(key=lambda entry: (-entry[0], entry[1]))
|
|
166
|
+
_ = builder.get_definition(MiddlewareStack).set_argument(
|
|
167
|
+
"keys", tuple(key for _, _, key in entries)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _middleware_stack(
|
|
172
|
+
container: ContainerInterface, keys: tuple[ServiceKey, ...]
|
|
173
|
+
) -> MiddlewareStack:
|
|
174
|
+
"""Build the stack by resolving each ordered key through ``container``.
|
|
175
|
+
|
|
176
|
+
``keys`` comes from the bundle's ``process`` hook: the tagged
|
|
177
|
+
definitions, already ordered highest priority first.
|
|
178
|
+
"""
|
|
179
|
+
factories = [
|
|
180
|
+
cast("MiddlewareFactory", await container.get(provided, qualifier))
|
|
181
|
+
for provided, qualifier in keys
|
|
182
|
+
]
|
|
183
|
+
return MiddlewareStack(factories)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _error_logging_listener(
|
|
187
|
+
channel: str,
|
|
188
|
+
) -> Callable[[ContainerInterface], Coroutine[object, object, ErrorLoggingListener]]:
|
|
189
|
+
"""Build the factory giving the error listener the ``channel`` logger.
|
|
190
|
+
|
|
191
|
+
The channel comes from this bundle's config, so it cannot sit in an
|
|
192
|
+
annotation the way a constant channel could: the factory asks the
|
|
193
|
+
container at build time instead.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
async def error_logging_listener(container: ContainerInterface) -> ErrorLoggingListener:
|
|
197
|
+
return ErrorLoggingListener(await container.get(LoggerInterface, channel))
|
|
198
|
+
|
|
199
|
+
return error_logging_listener
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _add_request_channel(config: object) -> object:
|
|
203
|
+
"""Declare the default channel through the logging config's own ``with_channels``.
|
|
204
|
+
|
|
205
|
+
Duck-typed: this bundle depends on the logging contracts only, never on
|
|
206
|
+
xtr-logging, so it asks the config it is handed rather than importing
|
|
207
|
+
its type.
|
|
208
|
+
"""
|
|
209
|
+
with_channels = cast("Callable[[str], object] | None", getattr(config, "with_channels", None))
|
|
210
|
+
default_channel = HttpKernelConfig().log_channel
|
|
211
|
+
return with_channels(default_channel) if with_channels is not None else config
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Configuration for :class:`~xtr_http_kernel.bundle.http_kernel_bundle.HttpKernelBundle`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
__all__ = ["HttpKernelConfig"]
|
|
10
|
+
|
|
11
|
+
_HTTP_TOKEN: Final = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
|
|
12
|
+
"""What a header field name may be made of."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class HttpKernelConfig:
|
|
17
|
+
"""What an application may change about the request lifecycle.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
request_id_header: The header the request id is read from and echoed
|
|
21
|
+
on. Must be a non-empty HTTP token.
|
|
22
|
+
trust_request_id: Whether a well-formed incoming id is kept. When
|
|
23
|
+
false every request gets a fresh one.
|
|
24
|
+
disallow_search_indexing: Whether every response is marked
|
|
25
|
+
``X-Robots-Tag: noindex``.
|
|
26
|
+
log_channel: The logging channel the error listener writes to. The
|
|
27
|
+
bundle declares the default channel on the logging config; an
|
|
28
|
+
application choosing another name declares that channel in its
|
|
29
|
+
own logging configuration.
|
|
30
|
+
middleware_priority: Where the lifecycle middleware sits among the
|
|
31
|
+
contributed factories — highest outermost.
|
|
32
|
+
app: The ``"package.module:app"`` import string the console commands
|
|
33
|
+
load the application from, when they need one.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
ValueError: When ``request_id_header`` is not an HTTP token,
|
|
37
|
+
``log_channel`` is empty, or ``app`` does not hold exactly one
|
|
38
|
+
module and one attribute around a single ``:``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
request_id_header: str = "X-Request-Id"
|
|
42
|
+
trust_request_id: bool = True
|
|
43
|
+
disallow_search_indexing: bool = False
|
|
44
|
+
log_channel: str = "request"
|
|
45
|
+
middleware_priority: int = 0
|
|
46
|
+
app: str | None = None
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
"""Refuse values the lifecycle would silently misread."""
|
|
50
|
+
if not _HTTP_TOKEN.fullmatch(self.request_id_header):
|
|
51
|
+
message = (
|
|
52
|
+
f"request_id_header must be a non-empty HTTP token, not {self.request_id_header!r}"
|
|
53
|
+
)
|
|
54
|
+
raise ValueError(message)
|
|
55
|
+
if not self.log_channel:
|
|
56
|
+
message = "log_channel must not be empty"
|
|
57
|
+
raise ValueError(message)
|
|
58
|
+
if self.app is not None:
|
|
59
|
+
module, separator, attribute = self.app.partition(":")
|
|
60
|
+
if not (module and separator and attribute) or ":" in attribute:
|
|
61
|
+
message = f'app must read "package.module:app", not {self.app!r}'
|
|
62
|
+
raise ValueError(message)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The middleware factory the bundle tags for the kernel to order into the stack."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, final
|
|
6
|
+
|
|
7
|
+
# Read at runtime: the container fills the constructor from this annotation.
|
|
8
|
+
from xtr_event_dispatcher_contracts import EventDispatcherInterface # noqa: TC002
|
|
9
|
+
|
|
10
|
+
from xtr_http_kernel.request_lifecycle_middleware import RequestLifecycleMiddleware
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from starlette.types import ASGIApp
|
|
14
|
+
|
|
15
|
+
__all__ = ["RequestLifecycleMiddlewareFactory"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@final
|
|
19
|
+
class RequestLifecycleMiddlewareFactory:
|
|
20
|
+
"""Wraps a downstream app in the lifecycle middleware, around the container's dispatcher.
|
|
21
|
+
|
|
22
|
+
Tagged ``http_kernel.middleware`` with the config's
|
|
23
|
+
``middleware_priority`` as the tag's ``priority``, so the bundle orders
|
|
24
|
+
it into the stack when the kernel is built — highest outermost.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
__slots__ = ("_dispatcher",)
|
|
28
|
+
|
|
29
|
+
def __init__(self, dispatcher: EventDispatcherInterface) -> None:
|
|
30
|
+
"""Hand ``dispatcher`` to every middleware built."""
|
|
31
|
+
self._dispatcher = dispatcher
|
|
32
|
+
|
|
33
|
+
def __call__(self, app: ASGIApp) -> ASGIApp:
|
|
34
|
+
"""Return ``app`` wrapped in the lifecycle middleware."""
|
|
35
|
+
return RequestLifecycleMiddleware(app, dispatcher=self._dispatcher)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Console commands the http kernel bundle contributes when a console is active."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .debug_router_command import DebugRouterCommand
|
|
6
|
+
from .route_description import RouteDescription
|
|
7
|
+
from .router_match_command import RouterMatchCommand
|
|
8
|
+
|
|
9
|
+
__all__ = ["DebugRouterCommand", "RouteDescription", "RouterMatchCommand"]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""One view of an application's routes, whichever way the framework hands them over.
|
|
2
|
+
|
|
3
|
+
Newer releases keep a router included under a prefix as a wrapper around that
|
|
4
|
+
router rather than as the routes it holds, and offer the effective view of
|
|
5
|
+
every route — prefixes applied — through ``iter_route_contexts``. Older ones
|
|
6
|
+
put each route in the list directly, already effective. This module is the
|
|
7
|
+
only place that difference lives.
|
|
8
|
+
|
|
9
|
+
A release that grew the wrapper before it grew the view of one lists an
|
|
10
|
+
included router as the single opaque route the application holds: there is no
|
|
11
|
+
supported way in, so the row names the wrapper for what it is rather than
|
|
12
|
+
inventing a path for it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING, Protocol, cast, final
|
|
19
|
+
|
|
20
|
+
from fastapi import routing as fastapi_routing
|
|
21
|
+
from starlette.routing import Host, Mount, Route, WebSocketRoute
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
25
|
+
|
|
26
|
+
from fastapi.routing import RouteContext
|
|
27
|
+
from starlette.routing import BaseRoute, Match
|
|
28
|
+
from starlette.types import Scope
|
|
29
|
+
|
|
30
|
+
__all__ = ["RouteView", "route_contexts"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Matcher(Protocol):
|
|
34
|
+
"""What answers whether a request reaches a route."""
|
|
35
|
+
|
|
36
|
+
def matches(self, scope: Scope, /) -> tuple[Match, Scope]:
|
|
37
|
+
"""Return how well ``scope`` fits, and what the route reads out of it."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@final
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class RouteView:
|
|
44
|
+
"""What routing sees for one route, every prefix already applied.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
route: The route itself, for what only its kind can answer.
|
|
48
|
+
path: The path requests are matched against, ``None`` for a route the
|
|
49
|
+
framework keeps to itself.
|
|
50
|
+
name: The name the route answers to, ``None`` when it has none.
|
|
51
|
+
methods: The methods it answers, ``None`` when it takes whatever
|
|
52
|
+
arrives or is not a method route at all.
|
|
53
|
+
endpoint: What it hands the request to, ``None`` when it hands it to
|
|
54
|
+
another application instead.
|
|
55
|
+
matcher: What answers :meth:`matches` — the framework's own view of
|
|
56
|
+
the route, or the route itself.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
route: BaseRoute
|
|
60
|
+
path: str | None
|
|
61
|
+
name: str | None
|
|
62
|
+
methods: set[str] | None
|
|
63
|
+
endpoint: object | None
|
|
64
|
+
matcher: object
|
|
65
|
+
|
|
66
|
+
def matches(self, scope: Scope) -> tuple[Match, Scope]:
|
|
67
|
+
"""Return how well ``scope`` fits this route, and what it reads out of it.
|
|
68
|
+
|
|
69
|
+
The framework's own view of a route stands in for it and forwards what
|
|
70
|
+
it is asked for, so it answers this without declaring it; the hop
|
|
71
|
+
through ``object`` is what says the shape is known rather than guessed.
|
|
72
|
+
"""
|
|
73
|
+
return cast("_Matcher", self.matcher).matches(scope)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def route_contexts(routes: Sequence[BaseRoute]) -> Sequence[RouteView]:
|
|
77
|
+
"""Read every route of an application, prefixes applied.
|
|
78
|
+
|
|
79
|
+
A release offering the effective view of each route is asked for it; the
|
|
80
|
+
rest hand their routes over already effective.
|
|
81
|
+
"""
|
|
82
|
+
effective = cast(
|
|
83
|
+
"Callable[[Sequence[BaseRoute]], Iterable[RouteContext]] | None",
|
|
84
|
+
getattr(fastapi_routing, "iter_route_contexts", None),
|
|
85
|
+
)
|
|
86
|
+
if effective is None:
|
|
87
|
+
return [_of_route(route) for route in routes]
|
|
88
|
+
return [_of_context(context) for context in effective(routes)]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _of_context(context: RouteContext) -> RouteView:
|
|
92
|
+
"""Read the framework's own view of a route."""
|
|
93
|
+
return RouteView(
|
|
94
|
+
route=context.route,
|
|
95
|
+
path=context.path,
|
|
96
|
+
name=context.name,
|
|
97
|
+
methods=context.methods,
|
|
98
|
+
endpoint=context.endpoint,
|
|
99
|
+
matcher=context,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _of_route(route: BaseRoute) -> RouteView:
|
|
104
|
+
"""Read a route a release hands over already effective.
|
|
105
|
+
|
|
106
|
+
Each line below names the kinds of route that can answer that much; a kind
|
|
107
|
+
that answers none of it is left to be named by itself.
|
|
108
|
+
"""
|
|
109
|
+
methods = route.methods if isinstance(route, Route) else None
|
|
110
|
+
endpoint = route.endpoint if isinstance(route, (Route, WebSocketRoute)) else None
|
|
111
|
+
path = route.path if isinstance(route, (Mount, Route, WebSocketRoute)) else None
|
|
112
|
+
name = route.name if isinstance(route, (Host, Mount, Route, WebSocketRoute)) else None
|
|
113
|
+
return RouteView(route, path, name, methods, endpoint, route)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""``debug:router``: every route of an application, as routing reads them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from xtr_console import ConsoleStyle, ExitCode, as_command, escape
|
|
8
|
+
|
|
9
|
+
from ._route_contexts import route_contexts
|
|
10
|
+
from .route_description import RouteDescription
|
|
11
|
+
from .router_command import RouterCommand
|
|
12
|
+
|
|
13
|
+
__all__ = ["DebugRouterCommand"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@as_command("debug:router")
|
|
17
|
+
@final
|
|
18
|
+
class DebugRouterCommand(RouterCommand):
|
|
19
|
+
"""Lists every route an application holds, in the order routing tries them."""
|
|
20
|
+
|
|
21
|
+
__slots__ = ()
|
|
22
|
+
|
|
23
|
+
async def __call__(self, io: ConsoleStyle, *, app: str | None = None) -> int:
|
|
24
|
+
"""List the application's routes, prefixes applied.
|
|
25
|
+
|
|
26
|
+
A router included under a prefix is listed under it, and a mounted
|
|
27
|
+
application is listed as the one route that reaches it — which is
|
|
28
|
+
what routing sees.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
io: Where the command writes.
|
|
32
|
+
app: The application to read, as ``package.module:app``; the
|
|
33
|
+
configured one when left out.
|
|
34
|
+
"""
|
|
35
|
+
application = self._app_or_report(io, app)
|
|
36
|
+
if application is None:
|
|
37
|
+
return ExitCode.INVALID
|
|
38
|
+
described = [RouteDescription.of(view) for view in route_contexts(application.routes)]
|
|
39
|
+
io.section(f"Routes ({len(described)})")
|
|
40
|
+
io.table(
|
|
41
|
+
("Methods", "Path", "Name", "Endpoint"),
|
|
42
|
+
[
|
|
43
|
+
(
|
|
44
|
+
escape(route.methods),
|
|
45
|
+
escape(route.path),
|
|
46
|
+
escape(route.name),
|
|
47
|
+
escape(route.endpoint),
|
|
48
|
+
)
|
|
49
|
+
for route in described
|
|
50
|
+
],
|
|
51
|
+
)
|
|
52
|
+
return ExitCode.SUCCESS
|