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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any