openframe-protocol-http 0.5.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.
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe.protocol.http
|
|
3
|
+
==========================
|
|
4
|
+
HTTP/ASGI binding, both directions (draft 7/8's redefinition):
|
|
5
|
+
|
|
6
|
+
- `bind_query`/`bind_command`/`install_error_handlers` (`server.py`,
|
|
7
|
+
needs the `[server]` extra, i.e. FastAPI) — wire a `QueryHandler`/
|
|
8
|
+
`CommandHandler`/`UseCase` to a route through the shared error mapper
|
|
9
|
+
and `RequestContext` builder in `openframe.protocol.base`.
|
|
10
|
+
- `HttpRemoteQueryClient`/`HttpRemoteCommandClient` (`client.py`, needs
|
|
11
|
+
the `[client]` extra, i.e. httpx) — call another service built on
|
|
12
|
+
this platform, the outbound counterpart.
|
|
13
|
+
|
|
14
|
+
Lazily imported (PEP 562, `__getattr__` below) rather than eagerly
|
|
15
|
+
importing both submodules here — `server.py` needs FastAPI and
|
|
16
|
+
`client.py` needs httpx, and eagerly importing both from this file
|
|
17
|
+
would mean installing *either* extra alone still fails at
|
|
18
|
+
`import openframe.protocol.http` with the *other* extra's missing
|
|
19
|
+
dependency. Found and fixed before this ever shipped: both submodules
|
|
20
|
+
were originally imported unconditionally here, silently defeating the
|
|
21
|
+
`[server]`/`[client]` extras split this package's own `pyproject.toml`
|
|
22
|
+
already promised.
|
|
23
|
+
|
|
24
|
+
.. stability: experimental
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from openframe.protocol.http.server import bind_command, bind_query, create_app, install_error_handlers
|
|
32
|
+
from openframe.protocol.http.client import HttpRemoteCommandClient, HttpRemoteQueryClient, RequestSpec
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"bind_query",
|
|
36
|
+
"bind_command",
|
|
37
|
+
"install_error_handlers",
|
|
38
|
+
"create_app",
|
|
39
|
+
"HttpRemoteQueryClient",
|
|
40
|
+
"HttpRemoteCommandClient",
|
|
41
|
+
"RequestSpec",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
_FROM_SERVER = {"bind_query", "bind_command", "install_error_handlers", "create_app"}
|
|
45
|
+
_FROM_CLIENT = {"HttpRemoteQueryClient", "HttpRemoteCommandClient", "RequestSpec"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def __getattr__(name: str):
|
|
49
|
+
if name in _FROM_SERVER:
|
|
50
|
+
from openframe.protocol.http import server
|
|
51
|
+
|
|
52
|
+
return getattr(server, name)
|
|
53
|
+
if name in _FROM_CLIENT:
|
|
54
|
+
from openframe.protocol.http import client
|
|
55
|
+
|
|
56
|
+
return getattr(client, name)
|
|
57
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/protocol/http/client.py
|
|
3
|
+
====================================
|
|
4
|
+
`HttpRemoteQueryClient`/`HttpRemoteCommandClient` — HTTP implementations
|
|
5
|
+
of `openframe.core.remote`'s `RemoteQueryClient`/`RemoteCommandClient`,
|
|
6
|
+
for calling another service built on this platform (M1b).
|
|
7
|
+
|
|
8
|
+
Lifecycle, per draft 8's D10: no `BasePort`/`PluginRegistry` involvement.
|
|
9
|
+
Each client owns (or borrows) a plain `httpx.AsyncClient` and exposes
|
|
10
|
+
`aclose()` for a caller's own lifespan to manage directly — the same
|
|
11
|
+
reasoning that kept `RemoteQueryClient`/`RemoteCommandClient` themselves
|
|
12
|
+
out of the `Capability` system in the first place applies to their
|
|
13
|
+
lifecycle too: there's no dynamic resolution happening, so there's
|
|
14
|
+
nothing for a registry to orchestrate.
|
|
15
|
+
|
|
16
|
+
Request construction deliberately mirrors `server.py`'s own
|
|
17
|
+
`build_input` philosophy rather than guessing how to serialise an
|
|
18
|
+
arbitrary input: a `build_request` hook returns exactly which path
|
|
19
|
+
params, query params, and JSON body a given call needs. Support exactly
|
|
20
|
+
the shapes `items-postgres`'s own routes need (path param only, query
|
|
21
|
+
params only, JSON body only) — not a fully generic REST client.
|
|
22
|
+
|
|
23
|
+
Dependency order:
|
|
24
|
+
protocol/http/client → protocol/base (errors, for the reverse mapping)
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any, Callable, Generic, TypeVar
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
from opentelemetry import propagate
|
|
33
|
+
|
|
34
|
+
from openframe.core.inbound import RequestContext
|
|
35
|
+
from openframe.protocol.base.errors import ErrorCategory, ErrorEnvelope, categorize, raise_for_envelope
|
|
36
|
+
|
|
37
|
+
__all__ = ["RequestSpec", "HttpRemoteQueryClient", "HttpRemoteCommandClient"]
|
|
38
|
+
|
|
39
|
+
TInput = TypeVar("TInput")
|
|
40
|
+
TOutput = TypeVar("TOutput")
|
|
41
|
+
|
|
42
|
+
_STATUS_TO_CATEGORY: dict[int, ErrorCategory] = {
|
|
43
|
+
404: ErrorCategory.NOT_FOUND,
|
|
44
|
+
422: ErrorCategory.INVALID,
|
|
45
|
+
504: ErrorCategory.TIMEOUT,
|
|
46
|
+
503: ErrorCategory.UNAVAILABLE,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class RequestSpec:
|
|
52
|
+
"""
|
|
53
|
+
What one call actually sends — returned by a `build_request` hook,
|
|
54
|
+
given the handler's own input value.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
path_params: Substituted into the client's `path` template via
|
|
58
|
+
`str.format(**path_params)`, e.g. `{"item_id": "abc"}`
|
|
59
|
+
for a path of `"/items/{item_id}"`.
|
|
60
|
+
query_params: Sent as the URL query string.
|
|
61
|
+
json_body: Sent as the request's JSON body. `None` means no
|
|
62
|
+
body at all (correct for GET, not just "empty dict").
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
path_params: dict[str, Any] = field(default_factory=dict)
|
|
66
|
+
query_params: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
json_body: Any = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _default_request_spec(value: Any) -> RequestSpec:
|
|
71
|
+
"""
|
|
72
|
+
`build_request=None` default: if the input is falsy/None, send
|
|
73
|
+
nothing; otherwise send it as the JSON body. Covers the common
|
|
74
|
+
"one JSON-body input, no path or query params" case without
|
|
75
|
+
requiring every caller to write a trivial `build_request`.
|
|
76
|
+
"""
|
|
77
|
+
if value is None:
|
|
78
|
+
return RequestSpec()
|
|
79
|
+
return RequestSpec(json_body=value)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _HttpRemoteCallMixin:
|
|
83
|
+
"""Shared machinery both client classes below use — not public itself."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
base_url: str | None = None,
|
|
89
|
+
path: str,
|
|
90
|
+
method: str,
|
|
91
|
+
client: httpx.AsyncClient | None = None,
|
|
92
|
+
build_request: Callable[[Any], RequestSpec] | None = None,
|
|
93
|
+
remote_name: str | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
if client is None and base_url is None:
|
|
96
|
+
raise ValueError("Either base_url or an existing client must be provided.")
|
|
97
|
+
self._client = client if client is not None else httpx.AsyncClient(base_url=base_url)
|
|
98
|
+
self._owns_client = client is None
|
|
99
|
+
self._path = path
|
|
100
|
+
self._method = method
|
|
101
|
+
self._build_request = build_request or _default_request_spec
|
|
102
|
+
self._remote_name = remote_name or f"http:{method} {path}"
|
|
103
|
+
|
|
104
|
+
async def aclose(self) -> None:
|
|
105
|
+
"""Close the underlying httpx client — only if this instance created it."""
|
|
106
|
+
if self._owns_client:
|
|
107
|
+
await self._client.aclose()
|
|
108
|
+
|
|
109
|
+
async def _call(self, value: Any, context: RequestContext | None) -> Any:
|
|
110
|
+
spec = self._build_request(value)
|
|
111
|
+
url = self._path.format(**spec.path_params) if spec.path_params else self._path
|
|
112
|
+
|
|
113
|
+
headers: dict[str, str] = {}
|
|
114
|
+
propagate.inject(headers) # outgoing traceparent — joins the inbound trace, if any is active
|
|
115
|
+
if context is not None:
|
|
116
|
+
headers["X-Correlation-ID"] = context.correlation_id
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
response = await self._client.request(
|
|
120
|
+
self._method, url, params=spec.query_params or None, json=spec.json_body, headers=headers
|
|
121
|
+
)
|
|
122
|
+
except httpx.TimeoutException as exc:
|
|
123
|
+
from openframe.core.exceptions import AdapterTimeoutError
|
|
124
|
+
|
|
125
|
+
raise AdapterTimeoutError(
|
|
126
|
+
f"Timed out calling {self._remote_name}", adapter=self._remote_name, operation="call", cause=exc
|
|
127
|
+
) from exc
|
|
128
|
+
except httpx.ConnectError as exc:
|
|
129
|
+
from openframe.core.exceptions import AdapterConnectionError
|
|
130
|
+
|
|
131
|
+
raise AdapterConnectionError(
|
|
132
|
+
f"Could not connect for {self._remote_name}", adapter=self._remote_name, operation="call", cause=exc
|
|
133
|
+
) from exc
|
|
134
|
+
|
|
135
|
+
if response.status_code < 400:
|
|
136
|
+
if response.status_code == 204 or not response.content:
|
|
137
|
+
return None
|
|
138
|
+
return response.json()
|
|
139
|
+
|
|
140
|
+
category = _STATUS_TO_CATEGORY.get(response.status_code, ErrorCategory.INTERNAL)
|
|
141
|
+
body = response.json() if response.content else {}
|
|
142
|
+
error = body.get("error", body)
|
|
143
|
+
envelope = ErrorEnvelope(
|
|
144
|
+
code=error.get("code", "unknown"),
|
|
145
|
+
message=error.get("message", f"{self._remote_name} failed with status {response.status_code}"),
|
|
146
|
+
retryable=error.get("retryable", False),
|
|
147
|
+
correlation_id=error.get("correlation_id", ""),
|
|
148
|
+
)
|
|
149
|
+
raise_for_envelope(category, envelope, remote_name=self._remote_name)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class HttpRemoteQueryClient(_HttpRemoteCallMixin, Generic[TInput, TOutput]):
|
|
153
|
+
"""
|
|
154
|
+
`RemoteQueryClient` over HTTP. Satisfies
|
|
155
|
+
`openframe.core.remote.RemoteQueryClient` structurally.
|
|
156
|
+
|
|
157
|
+
Args: see `_HttpRemoteCallMixin.__init__` — `method` defaults
|
|
158
|
+
naturally to `"GET"` for queries (pass explicitly if a query uses a
|
|
159
|
+
different verb, though that would be unusual).
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
base_url: str | None = None,
|
|
166
|
+
path: str,
|
|
167
|
+
method: str = "GET",
|
|
168
|
+
client: httpx.AsyncClient | None = None,
|
|
169
|
+
build_request: Callable[[TInput], RequestSpec] | None = None,
|
|
170
|
+
remote_name: str | None = None,
|
|
171
|
+
) -> None:
|
|
172
|
+
super().__init__(
|
|
173
|
+
base_url=base_url, path=path, method=method, client=client,
|
|
174
|
+
build_request=build_request, remote_name=remote_name,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
async def execute(self, query: TInput, context: RequestContext | None = None) -> TOutput:
|
|
178
|
+
return await self._call(query, context)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class HttpRemoteCommandClient(_HttpRemoteCallMixin, Generic[TInput]):
|
|
182
|
+
"""
|
|
183
|
+
`RemoteCommandClient` over HTTP. Satisfies
|
|
184
|
+
`openframe.core.remote.RemoteCommandClient` structurally.
|
|
185
|
+
|
|
186
|
+
`method` has no default here (unlike the query client) — a command
|
|
187
|
+
is meaningfully POST, PUT, PATCH, or DELETE, and none of those is
|
|
188
|
+
clearly "the" default the way GET is for queries.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def __init__(
|
|
192
|
+
self,
|
|
193
|
+
*,
|
|
194
|
+
base_url: str | None = None,
|
|
195
|
+
path: str,
|
|
196
|
+
method: str,
|
|
197
|
+
client: httpx.AsyncClient | None = None,
|
|
198
|
+
build_request: Callable[[TInput], RequestSpec] | None = None,
|
|
199
|
+
remote_name: str | None = None,
|
|
200
|
+
) -> None:
|
|
201
|
+
super().__init__(
|
|
202
|
+
base_url=base_url, path=path, method=method, client=client,
|
|
203
|
+
build_request=build_request, remote_name=remote_name,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
async def execute(self, command: TInput, context: RequestContext | None = None) -> None:
|
|
207
|
+
await self._call(command, context)
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/protocol/http/server.py
|
|
3
|
+
=====================================
|
|
4
|
+
`bind_query`/`bind_command` — register a FastAPI route that calls into a
|
|
5
|
+
`UseCase`/`CommandHandler`/`QueryHandler` (`openframe.core.inbound`)
|
|
6
|
+
through the shared binder, instead of a hand-written route function
|
|
7
|
+
calling the handler and mapping `HTTPException`s itself.
|
|
8
|
+
|
|
9
|
+
Named `server.py`, paired with this package's own `client.py` — not
|
|
10
|
+
`binding.py` (its original name): "binding" described the mechanism,
|
|
11
|
+
while "client" described a role, an inconsistent naming style for two
|
|
12
|
+
files sitting right next to each other. `inbound.py`/`outbound.py` was
|
|
13
|
+
considered and rejected — those names are already owned by
|
|
14
|
+
`openframe.core.inbound`/`openframe.core.ports.outbound` at a different
|
|
15
|
+
level of granularity, and reusing them here for a narrower, HTTP-specific
|
|
16
|
+
concept would create two unrelated meanings of "inbound"/"outbound"
|
|
17
|
+
floating around the ecosystem.
|
|
18
|
+
|
|
19
|
+
Spiked first (see the repo's own build session, not reproduced here):
|
|
20
|
+
confirmed FastAPI 0.143 supports (a) a Pydantic `BaseModel` as a query
|
|
21
|
+
dependency via `Annotated[Model, Query()]`, with each field becoming its
|
|
22
|
+
own query param, and (b) a dynamically-constructed `inspect.Signature`
|
|
23
|
+
assigned to a plain `async def (**kwargs)` endpoint, so path/body/query
|
|
24
|
+
parameters can all be declared generically from data rather than one
|
|
25
|
+
hand-written function signature per route. Both confirmed against a real
|
|
26
|
+
`TestClient`, not assumed from FastAPI's docs.
|
|
27
|
+
|
|
28
|
+
Deliberately not fully generic beyond what `items-postgres` needs (per
|
|
29
|
+
the plan's own scope line) — `build_input` is the one place route-
|
|
30
|
+
specific shape lives: how a path param, a query model, and a body model
|
|
31
|
+
combine into the single argument a handler's `execute()` expects. Adding
|
|
32
|
+
a new combination later means adding to `build_input`, not changing this
|
|
33
|
+
module.
|
|
34
|
+
|
|
35
|
+
Dependency order:
|
|
36
|
+
protocol/http/binding → protocol/base (errors, context)
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import inspect
|
|
41
|
+
import logging
|
|
42
|
+
from contextlib import asynccontextmanager
|
|
43
|
+
from typing import Any, Awaitable, Callable, Sequence
|
|
44
|
+
|
|
45
|
+
from fastapi import APIRouter, Body, FastAPI, HTTPException, Path, Query, Request, Response
|
|
46
|
+
from fastapi.exceptions import RequestValidationError
|
|
47
|
+
from pydantic import BaseModel, ValidationError
|
|
48
|
+
|
|
49
|
+
from openframe.core.exceptions import AdapterConnectionError
|
|
50
|
+
from openframe.core.inbound import RequestContext
|
|
51
|
+
from openframe.core.middleware import TelemetryMiddleware
|
|
52
|
+
from openframe.core.telemetry import record_error, record_lifecycle_event, setup_telemetry, shutdown_telemetry
|
|
53
|
+
from openframe.protocol.base.context import build_request_context
|
|
54
|
+
from openframe.protocol.base.errors import ErrorCategory, build_envelope, categorize
|
|
55
|
+
|
|
56
|
+
__all__ = ["bind_query", "bind_command", "install_error_handlers", "create_app"]
|
|
57
|
+
|
|
58
|
+
_logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
_CATEGORY_TO_STATUS: dict[ErrorCategory, int] = {
|
|
61
|
+
ErrorCategory.NOT_FOUND: 404,
|
|
62
|
+
ErrorCategory.INVALID: 422,
|
|
63
|
+
ErrorCategory.TIMEOUT: 504,
|
|
64
|
+
ErrorCategory.UNAVAILABLE: 503,
|
|
65
|
+
ErrorCategory.INTERNAL: 500,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _build_signature(
|
|
70
|
+
*,
|
|
71
|
+
path_param: tuple[str, type] | None,
|
|
72
|
+
body_model: type[BaseModel] | None,
|
|
73
|
+
query_model: type[BaseModel] | None,
|
|
74
|
+
) -> inspect.Signature:
|
|
75
|
+
"""
|
|
76
|
+
Build the `inspect.Signature` FastAPI introspects to extract path,
|
|
77
|
+
body, and query parameters for a dynamically-constructed endpoint.
|
|
78
|
+
|
|
79
|
+
Fixed kwarg names so `build_input` can address them by name
|
|
80
|
+
regardless of the route: the path param keeps its own name (it's
|
|
81
|
+
also the URL template variable), the body model is always bound to
|
|
82
|
+
`body`, the query model to `query`.
|
|
83
|
+
"""
|
|
84
|
+
params: list[inspect.Parameter] = [
|
|
85
|
+
inspect.Parameter("request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request),
|
|
86
|
+
inspect.Parameter("response", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Response),
|
|
87
|
+
]
|
|
88
|
+
if path_param is not None:
|
|
89
|
+
name, annotation = path_param
|
|
90
|
+
params.append(
|
|
91
|
+
inspect.Parameter(
|
|
92
|
+
name, inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=annotation, default=Path(...)
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
if body_model is not None:
|
|
96
|
+
params.append(
|
|
97
|
+
inspect.Parameter(
|
|
98
|
+
"body", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=body_model, default=Body(...)
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
if query_model is not None:
|
|
102
|
+
params.append(
|
|
103
|
+
inspect.Parameter(
|
|
104
|
+
"query",
|
|
105
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
106
|
+
annotation=query_model,
|
|
107
|
+
default=Query(),
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
return inspect.Signature(params)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_input(build_input: Callable[..., Any] | None, kwargs: dict[str, Any]) -> Any:
|
|
114
|
+
"""
|
|
115
|
+
Call `build_input` with exactly the kwargs it declares, by name — so
|
|
116
|
+
a route with only a path param doesn't need a `build_input` that
|
|
117
|
+
also accepts `body`/`query` it'll never receive.
|
|
118
|
+
|
|
119
|
+
`build_input=None` is valid when exactly one of path/body/query is
|
|
120
|
+
present — the handler's input is just that one value directly.
|
|
121
|
+
"""
|
|
122
|
+
available = {k: v for k, v in kwargs.items() if k not in ("request", "response", "__handler__")}
|
|
123
|
+
if build_input is None:
|
|
124
|
+
if len(available) == 0:
|
|
125
|
+
# No path param, body, or query model — the handler takes no
|
|
126
|
+
# real input (e.g. "list everything", "run this check").
|
|
127
|
+
return None
|
|
128
|
+
if len(available) != 1:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
"build_input is required when more than one of path_param/body_model/"
|
|
131
|
+
"query_model is set — ambiguous which one is the handler's input."
|
|
132
|
+
)
|
|
133
|
+
return next(iter(available.values()))
|
|
134
|
+
sig = inspect.signature(build_input)
|
|
135
|
+
call_kwargs = {name: available[name] for name in sig.parameters if name in available}
|
|
136
|
+
return build_input(**call_kwargs)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _bind(
|
|
140
|
+
router: APIRouter,
|
|
141
|
+
path: str,
|
|
142
|
+
*,
|
|
143
|
+
method: str,
|
|
144
|
+
handler_dep: Callable[..., Any],
|
|
145
|
+
path_param: tuple[str, type] | None,
|
|
146
|
+
body_model: type[BaseModel] | None,
|
|
147
|
+
query_model: type[BaseModel] | None,
|
|
148
|
+
build_input: Callable[..., Any] | None,
|
|
149
|
+
response_model: type | None,
|
|
150
|
+
status_code: int,
|
|
151
|
+
not_found_when: Callable[[Any], bool] | None,
|
|
152
|
+
not_found_detail: Callable[[Any], str] | None,
|
|
153
|
+
) -> None:
|
|
154
|
+
from fastapi import Depends
|
|
155
|
+
|
|
156
|
+
async def endpoint(**kwargs: Any) -> Any:
|
|
157
|
+
request: Request = kwargs["request"]
|
|
158
|
+
response: Response = kwargs["response"]
|
|
159
|
+
handler = kwargs["__handler__"]
|
|
160
|
+
handler_input = _resolve_input(build_input, kwargs)
|
|
161
|
+
context: RequestContext = build_request_context(dict(request.headers))
|
|
162
|
+
response.headers["X-Correlation-ID"] = context.correlation_id
|
|
163
|
+
result = await handler.execute(handler_input, context)
|
|
164
|
+
if not_found_when is not None and not_found_when(result):
|
|
165
|
+
detail = not_found_detail(handler_input) if not_found_detail is not None else "not found"
|
|
166
|
+
raise HTTPException(status_code=404, detail=detail)
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
signature = _build_signature(path_param=path_param, body_model=body_model, query_model=query_model)
|
|
170
|
+
# Append the handler dependency as its own parameter so FastAPI resolves
|
|
171
|
+
# it per-request via Depends(), same lifecycle as every other dependency.
|
|
172
|
+
extra = list(signature.parameters.values())
|
|
173
|
+
extra.append(
|
|
174
|
+
inspect.Parameter(
|
|
175
|
+
"__handler__",
|
|
176
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
177
|
+
annotation=Any,
|
|
178
|
+
default=Depends(handler_dep),
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
endpoint.__signature__ = inspect.Signature(extra) # type: ignore[attr-defined]
|
|
182
|
+
|
|
183
|
+
router.add_api_route(
|
|
184
|
+
path,
|
|
185
|
+
endpoint,
|
|
186
|
+
methods=[method],
|
|
187
|
+
response_model=response_model,
|
|
188
|
+
status_code=status_code,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def bind_query(
|
|
193
|
+
router: APIRouter,
|
|
194
|
+
path: str,
|
|
195
|
+
*,
|
|
196
|
+
handler_dep: Callable[..., Any],
|
|
197
|
+
path_param: tuple[str, type] | None = None,
|
|
198
|
+
query_model: type[BaseModel] | None = None,
|
|
199
|
+
build_input: Callable[..., Any] | None = None,
|
|
200
|
+
response_model: type | None = None,
|
|
201
|
+
status_code: int = 200,
|
|
202
|
+
not_found_when: Callable[[Any], bool] | None = None,
|
|
203
|
+
not_found_detail: Callable[[Any], str] | None = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
"""
|
|
206
|
+
Register a GET route backed by a `QueryHandler`/`UseCase`.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
router: The `APIRouter` to register onto.
|
|
210
|
+
path: Route path, e.g. `"/items/{item_id}"`.
|
|
211
|
+
handler_dep: A FastAPI dependency (plain callable or
|
|
212
|
+
`Depends`-wrapped) resolving to the handler
|
|
213
|
+
instance for this request.
|
|
214
|
+
path_param: `(name, type)` if the route has a path parameter,
|
|
215
|
+
else `None`.
|
|
216
|
+
query_model: A `BaseModel` whose fields become query params
|
|
217
|
+
(via `Annotated[Model, Query()]`), bound to the
|
|
218
|
+
`query` kwarg — spiked and confirmed against a
|
|
219
|
+
real `TestClient` to work in FastAPI 0.143.
|
|
220
|
+
build_input: Combines whichever of `path_param`/`query_model`
|
|
221
|
+
are present into the handler's single `execute()`
|
|
222
|
+
argument. Required only when both are present —
|
|
223
|
+
with exactly one, that one value is passed as-is.
|
|
224
|
+
response_model: FastAPI response model, as with a hand-written route.
|
|
225
|
+
status_code: Defaults to 200 (query semantics).
|
|
226
|
+
not_found_when: If the result satisfies this, raise 404 instead
|
|
227
|
+
of returning it — e.g. `lambda item: item is None`.
|
|
228
|
+
not_found_detail: Builds the 404 body's `detail` string from the
|
|
229
|
+
handler's own input (e.g. `lambda item_id: f"Item
|
|
230
|
+
{item_id!r} not found"`), for a route whose
|
|
231
|
+
existing behavior put the id in the message —
|
|
232
|
+
a real second service needed this (`items-cached`,
|
|
233
|
+
migrated from a hand-written route that did
|
|
234
|
+
exactly this); defaults to the generic `"not
|
|
235
|
+
found"` previous routes used when omitted.
|
|
236
|
+
"""
|
|
237
|
+
_bind(
|
|
238
|
+
router,
|
|
239
|
+
path,
|
|
240
|
+
method="GET",
|
|
241
|
+
handler_dep=handler_dep,
|
|
242
|
+
path_param=path_param,
|
|
243
|
+
body_model=None,
|
|
244
|
+
query_model=query_model,
|
|
245
|
+
build_input=build_input,
|
|
246
|
+
response_model=response_model,
|
|
247
|
+
status_code=status_code,
|
|
248
|
+
not_found_when=not_found_when,
|
|
249
|
+
not_found_detail=not_found_detail,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def bind_command(
|
|
254
|
+
router: APIRouter,
|
|
255
|
+
path: str,
|
|
256
|
+
*,
|
|
257
|
+
method: str,
|
|
258
|
+
handler_dep: Callable[..., Any],
|
|
259
|
+
path_param: tuple[str, type] | None = None,
|
|
260
|
+
body_model: type[BaseModel] | None = None,
|
|
261
|
+
query_model: type[BaseModel] | None = None,
|
|
262
|
+
build_input: Callable[..., Any] | None = None,
|
|
263
|
+
response_model: type | None = None,
|
|
264
|
+
status_code: int = 200,
|
|
265
|
+
not_found_when: Callable[[Any], bool] | None = None,
|
|
266
|
+
not_found_detail: Callable[[Any], str] | None = None,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""
|
|
269
|
+
Register a POST/PUT/PATCH/DELETE route backed by a `UseCase`/`CommandHandler`.
|
|
270
|
+
|
|
271
|
+
Same parameter shape as `bind_query`, plus `body_model` (bound to the
|
|
272
|
+
`body` kwarg) and an explicit `method` since commands aren't always
|
|
273
|
+
one HTTP verb. A `CommandHandler` always returns `None` — a route
|
|
274
|
+
whose result the caller needs (the created entity, a deleted-count
|
|
275
|
+
bool) needs a `UseCase`, not a `CommandHandler`; this binder doesn't
|
|
276
|
+
care which protocol the concrete handler satisfies, both have the
|
|
277
|
+
same `execute()` shape.
|
|
278
|
+
|
|
279
|
+
`query_model` is supported here too, not just on `bind_query` — a
|
|
280
|
+
real second service (not `items-postgres`) needed a command whose
|
|
281
|
+
input is a path param plus a query param and no body at all (e.g.
|
|
282
|
+
`PATCH /{id}/status?status=...`), which `_bind` already handled
|
|
283
|
+
generically; only this public wrapper hadn't exposed it yet.
|
|
284
|
+
|
|
285
|
+
Args: see `bind_query` — identical except `body_model`/`method`.
|
|
286
|
+
"""
|
|
287
|
+
_bind(
|
|
288
|
+
router,
|
|
289
|
+
path,
|
|
290
|
+
method=method,
|
|
291
|
+
handler_dep=handler_dep,
|
|
292
|
+
path_param=path_param,
|
|
293
|
+
body_model=body_model,
|
|
294
|
+
query_model=query_model,
|
|
295
|
+
build_input=build_input,
|
|
296
|
+
response_model=response_model,
|
|
297
|
+
status_code=status_code,
|
|
298
|
+
not_found_when=not_found_when,
|
|
299
|
+
not_found_detail=not_found_detail,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def install_error_handlers(app: Any) -> None:
|
|
304
|
+
"""
|
|
305
|
+
Register FastAPI exception handlers mapping every error through the
|
|
306
|
+
shared `categorize()`/`build_envelope()` — the one place this
|
|
307
|
+
translation happens, instead of each route catching its own
|
|
308
|
+
exceptions.
|
|
309
|
+
|
|
310
|
+
Calls `record_error()` on every INTERNAL-category error (5xx) — the
|
|
311
|
+
built-in `TelemetryMiddleware` only records errors that escape all
|
|
312
|
+
the way past it unhandled; an exception handler, by design, catches
|
|
313
|
+
the error *before* it would otherwise propagate that far, so without
|
|
314
|
+
this call here an INTERNAL error would be correctly hidden from the
|
|
315
|
+
caller but silently never recorded anywhere.
|
|
316
|
+
"""
|
|
317
|
+
from fastapi import FastAPI
|
|
318
|
+
from fastapi.responses import JSONResponse
|
|
319
|
+
|
|
320
|
+
from openframe.core.exceptions import OpenFrameError
|
|
321
|
+
|
|
322
|
+
app = app # type: FastAPI
|
|
323
|
+
|
|
324
|
+
def _respond(exc: BaseException, correlation_id: str | None) -> JSONResponse:
|
|
325
|
+
envelope = build_envelope(exc, correlation_id=correlation_id)
|
|
326
|
+
category = categorize(exc)
|
|
327
|
+
status = _CATEGORY_TO_STATUS[category]
|
|
328
|
+
if category is ErrorCategory.INTERNAL:
|
|
329
|
+
record_error(exc if isinstance(exc, Exception) else Exception(str(exc)))
|
|
330
|
+
headers = {}
|
|
331
|
+
if envelope.retryable:
|
|
332
|
+
headers["Retry-After"] = "1"
|
|
333
|
+
return JSONResponse(
|
|
334
|
+
status_code=status,
|
|
335
|
+
headers=headers,
|
|
336
|
+
content={
|
|
337
|
+
"error": {
|
|
338
|
+
"code": envelope.code,
|
|
339
|
+
"message": envelope.message,
|
|
340
|
+
"retryable": envelope.retryable,
|
|
341
|
+
"correlation_id": envelope.correlation_id,
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
@app.exception_handler(OpenFrameError)
|
|
347
|
+
async def _handle_openframe_error(request: Request, exc: OpenFrameError) -> JSONResponse:
|
|
348
|
+
context = build_request_context(dict(request.headers))
|
|
349
|
+
return _respond(exc, context.correlation_id)
|
|
350
|
+
|
|
351
|
+
@app.exception_handler(RequestValidationError)
|
|
352
|
+
async def _handle_validation_error(
|
|
353
|
+
request: Request, exc: RequestValidationError
|
|
354
|
+
) -> JSONResponse:
|
|
355
|
+
context = build_request_context(dict(request.headers))
|
|
356
|
+
return _respond(exc, context.correlation_id)
|
|
357
|
+
|
|
358
|
+
@app.exception_handler(Exception)
|
|
359
|
+
async def _handle_unexpected_error(request: Request, exc: Exception) -> JSONResponse:
|
|
360
|
+
context = build_request_context(dict(request.headers))
|
|
361
|
+
return _respond(exc, context.correlation_id)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
# Server/app creation — the piece M1/M1b deliberately left outside protocol
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
#
|
|
368
|
+
# M1's own design notes (see this module's own history) drew the boundary at
|
|
369
|
+
# "protocol configures routes and error handling on a server object the
|
|
370
|
+
# caller already created" — FastAPI() instantiation, lifespan, middleware,
|
|
371
|
+
# and the uvicorn process stayed hand-written per service. That produced the
|
|
372
|
+
# exact same ~15-line lifespan block duplicated across every
|
|
373
|
+
# openframe-local-validation-framework service: setup_telemetry(),
|
|
374
|
+
# record_lifecycle_event("cold_start"), a try/except around the service's
|
|
375
|
+
# own initialise() catching (AdapterConnectionError, ValidationError) for
|
|
376
|
+
# degraded-mode startup, yield, shutdown(), shutdown_telemetry(). That's
|
|
377
|
+
# real, demonstrated duplication across seven real services, not a
|
|
378
|
+
# hypothetical one worth abstracting pre-emptively — so `create_app` now
|
|
379
|
+
# owns exactly that lifespan shape, parameterised by the one thing that
|
|
380
|
+
# actually differs per service: the startup/shutdown hooks themselves.
|
|
381
|
+
#
|
|
382
|
+
# Deliberately NOT owning what on_startup/on_shutdown do internally (which
|
|
383
|
+
# plugins to register, how many adapters, whether a background consumer
|
|
384
|
+
# task needs to start) — that stays each service's own `dependencies.py`,
|
|
385
|
+
# passed in as a plain callable. A service with extra lifespan needs (e.g.
|
|
386
|
+
# events-kafka's/research-pipeline's background Kafka consumer task) just
|
|
387
|
+
# does that extra work inside its own on_startup/on_shutdown; create_app
|
|
388
|
+
# doesn't need to know a consumer task exists at all.
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def create_app(
|
|
392
|
+
*,
|
|
393
|
+
title: str,
|
|
394
|
+
description: str | None = None,
|
|
395
|
+
on_startup: Callable[[], Awaitable[None]],
|
|
396
|
+
on_shutdown: Callable[[], Awaitable[None]],
|
|
397
|
+
routers: Sequence[APIRouter] = (),
|
|
398
|
+
degraded_mode_exceptions: tuple[type[BaseException], ...] = (AdapterConnectionError, ValidationError),
|
|
399
|
+
) -> FastAPI:
|
|
400
|
+
"""
|
|
401
|
+
Build a `FastAPI` app with the lifespan/middleware/error-handler shape
|
|
402
|
+
every `openframe-local-validation-framework` service hand-wrote
|
|
403
|
+
identically: telemetry setup on startup, degraded-mode tolerance for a
|
|
404
|
+
not-yet-ready backend, telemetry flush on shutdown, `TelemetryMiddleware`
|
|
405
|
+
installed, and `install_error_handlers` wired — all before the caller's
|
|
406
|
+
own routers are included.
|
|
407
|
+
|
|
408
|
+
Does NOT start uvicorn, does NOT decide what `on_startup`/`on_shutdown`
|
|
409
|
+
actually do (that's still each service's own `dependencies.initialise`/
|
|
410
|
+
`shutdown`, built from whichever `openframe-adapters` plugins it needs)
|
|
411
|
+
— this function's job ends at handing back a configured `FastAPI`
|
|
412
|
+
instance, same as it always has; the caller still does
|
|
413
|
+
`uvicorn.run(app, ...)` or the Dockerfile's `CMD` does.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
title: Passed straight to `FastAPI(title=...)`.
|
|
417
|
+
description: Passed straight to `FastAPI(description=...)`.
|
|
418
|
+
on_startup: Awaited inside the lifespan's startup phase. Any
|
|
419
|
+
exception in `degraded_mode_exceptions` is caught and
|
|
420
|
+
logged as a warning (degraded mode, not a crash) —
|
|
421
|
+
the exact same tolerance every service's own lifespan
|
|
422
|
+
hand-wrote around its own `initialise()` call.
|
|
423
|
+
on_shutdown: Awaited on app shutdown, after the lifespan yields.
|
|
424
|
+
routers: Included via `app.include_router()` after creation —
|
|
425
|
+
a service with more than one router (rare, but not
|
|
426
|
+
impossible) passes all of them here rather than
|
|
427
|
+
calling `app.include_router()` itself afterward.
|
|
428
|
+
degraded_mode_exceptions: Which exception types during `on_startup`
|
|
429
|
+
are tolerated (logged, not raised) rather than
|
|
430
|
+
crashing app startup. Defaults to the exact pair
|
|
431
|
+
every migrated service already used:
|
|
432
|
+
`AdapterConnectionError` (backend not reachable yet)
|
|
433
|
+
and pydantic's `ValidationError` (bad settings).
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
A `FastAPI` instance with `TelemetryMiddleware` added,
|
|
437
|
+
`install_error_handlers` wired, and every router in `routers`
|
|
438
|
+
included — ready for the caller to add any extra non-domain routes
|
|
439
|
+
(e.g. a `/health` or `/registry` endpoint) directly, exactly as
|
|
440
|
+
every migrated service still does today.
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
@asynccontextmanager
|
|
444
|
+
async def lifespan(app: FastAPI):
|
|
445
|
+
setup_telemetry()
|
|
446
|
+
record_lifecycle_event("cold_start")
|
|
447
|
+
try:
|
|
448
|
+
await on_startup()
|
|
449
|
+
except degraded_mode_exceptions as exc:
|
|
450
|
+
_logger.warning(
|
|
451
|
+
"%s: dependency not ready at startup: %s — running in degraded mode", title, exc
|
|
452
|
+
)
|
|
453
|
+
yield
|
|
454
|
+
await on_shutdown()
|
|
455
|
+
shutdown_telemetry()
|
|
456
|
+
|
|
457
|
+
app = FastAPI(title=title, description=description, lifespan=lifespan)
|
|
458
|
+
app.add_middleware(TelemetryMiddleware)
|
|
459
|
+
install_error_handlers(app)
|
|
460
|
+
for router in routers:
|
|
461
|
+
app.include_router(router)
|
|
462
|
+
return app
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openframe-protocol-http
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: OpenFrame Microservice Suite - HTTP/ASGI server binding for openframe.core.inbound handlers.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Furious-Meteors/openframe-protocol
|
|
6
|
+
Project-URL: Documentation, https://furious-meteors.github.io/openframe-protocol/
|
|
7
|
+
Project-URL: Repository, https://github.com/Furious-Meteors/openframe-protocol
|
|
8
|
+
Project-URL: Changelog, https://github.com/Furious-Meteors/openframe-protocol/blob/production/.github/CHANGELOG.md
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/Furious-Meteors/openframe-protocol/issues
|
|
10
|
+
Author-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
|
|
11
|
+
Maintainer-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
|
|
12
|
+
License: MIT
|
|
13
|
+
Keywords: fastapi,hexagonal,http,microservice,openframe,protocol
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: openframe-core<4,>=3.2.1
|
|
26
|
+
Requires-Dist: openframe-protocol-base<1,>=0.1
|
|
27
|
+
Provides-Extra: client
|
|
28
|
+
Requires-Dist: httpx>=0.27; extra == 'client'
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: fastapi>=0.110; extra == 'dev'
|
|
31
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-mock>=3.14; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
35
|
+
Provides-Extra: server
|
|
36
|
+
Requires-Dist: fastapi>=0.110; extra == 'server'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# openframe-protocol-http
|
|
40
|
+
|
|
41
|
+
HTTP/ASGI server binding. Wires a `QueryHandler`/`CommandHandler`/`UseCase`
|
|
42
|
+
(`openframe.core.inbound`) to a FastAPI route through the shared error
|
|
43
|
+
mapper and `RequestContext` builder in `openframe-protocol-base`, instead
|
|
44
|
+
of each route hand-mapping `HTTPException`s itself.
|
|
45
|
+
|
|
46
|
+
Install the `[server]` extra for the FastAPI/ASGI dependencies —
|
|
47
|
+
`openframe-protocol-http[server]`.
|
|
48
|
+
|
|
49
|
+
## Wiring example
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from openframe.protocol.http import bind_query, bind_command, install_error_handlers
|
|
53
|
+
|
|
54
|
+
router = APIRouter(prefix="/items")
|
|
55
|
+
|
|
56
|
+
bind_query(
|
|
57
|
+
router, "/{item_id}",
|
|
58
|
+
handler_dep=get_get_item_handler,
|
|
59
|
+
path_param=("item_id", str),
|
|
60
|
+
response_model=Item,
|
|
61
|
+
not_found_when=lambda item: item is None,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
bind_command(
|
|
65
|
+
router, "", method="POST",
|
|
66
|
+
handler_dep=get_create_item_handler,
|
|
67
|
+
body_model=Item,
|
|
68
|
+
status_code=201,
|
|
69
|
+
response_model=Item,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
install_error_handlers(app)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Status
|
|
76
|
+
|
|
77
|
+
Experimental, M1 of the project's own build plan — proves the inbound
|
|
78
|
+
handler contract survives a real transport before anything else
|
|
79
|
+
(HTTP client, gRPC, sessions) is attempted.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
openframe/protocol/http/__init__.py,sha256=mQv6d93_e-gWcQVNBgzubIiWnbfQ129vLOHFiwwS-Qw,2156
|
|
2
|
+
openframe/protocol/http/client.py,sha256=vmDnWnkmCcsKDh7Cx-WlwqLCmahZxJQs2BhZ5TECWKs,8116
|
|
3
|
+
openframe/protocol/http/server.py,sha256=aMry8jT9poEYK9joWZVq75FFeceXb6E9roGdgYeXUzA,20127
|
|
4
|
+
openframe_protocol_http-0.5.0.dist-info/METADATA,sha256=slpE9maPrVk6z_sGSNq9ydLVv8c9dnj-lL31qrGdDSU,2994
|
|
5
|
+
openframe_protocol_http-0.5.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
openframe_protocol_http-0.5.0.dist-info/RECORD,,
|