mgf-fastapi 0.1.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,73 @@
1
+ """``mgf.fastapi`` — FastAPI integration adapters for ``mgf-common``.
2
+
3
+ Sibling of :mod:`mgf.common` under the ``mgf.*`` namespace. Houses
4
+ every FastAPI-specific adapter that previously lived under
5
+ ``mgf.common.fastapi.*`` — extracted at mgf-common v0.28 /
6
+ mgf-fastapi v0.1 per the federation split plan.
7
+
8
+ A consumer building a FastAPI service shouldn't reinvent the wiring
9
+ for these every time:
10
+
11
+ - :func:`bootstrap` ↔ FastAPI lifespan (start/stop wiring).
12
+ - Per-request ``X-Request-Id`` generation + propagation to logs.
13
+ - Typed-exception → JSON response mapping
14
+ (``ConfigError`` → 400, ``ResourceNotFoundError`` → 404,
15
+ HTTP-01 leaves resolved via ``http_status``, etc.).
16
+ - ``Depends()`` helpers for request-scoped data (settings,
17
+ request_id, ``AppContext``).
18
+
19
+ The submodules:
20
+
21
+ - :mod:`mgf.fastapi.webhooks` — Svix-shape HMAC webhook
22
+ verification (``HmacWebhookVerifier``, ``verify_request``).
23
+ - :mod:`mgf.fastapi.security` — request-side security
24
+ primitives (``IpAllowlist``).
25
+ - :mod:`mgf.fastapi.testing` — async context-manager test
26
+ server (``run_test_app``).
27
+ - :mod:`mgf.fastapi.exceptions` — sibling-domain concrete
28
+ exception leaves (``HmacVerificationError``).
29
+
30
+ Install: ``pip install mgf-fastapi`` (or
31
+ ``pip install 'mgf-fastapi[testing]'`` for ``run_test_app``).
32
+
33
+ The integration is **adapter-shaped, not framework-shaped**. Nothing
34
+ here re-exports FastAPI primitives or hides them. The consumer's app
35
+ is still a regular FastAPI app — these helpers just plug into the
36
+ seams FastAPI provides (lifespan, middleware, dependencies).
37
+
38
+ Closed-box invariant: ``mgf-fastapi`` depends on ``mgf-common`` +
39
+ ``fastapi`` only. Consumers using only ``mgf-common`` never see
40
+ the import.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ from mgf.fastapi._dependencies import (
46
+ get_app_context,
47
+ get_request_id,
48
+ get_settings,
49
+ )
50
+ from mgf.fastapi._exceptions import (
51
+ DEFAULT_STATUS_MAP,
52
+ ExceptionTranslationMiddleware,
53
+ )
54
+ from mgf.fastapi._lifespan import setup_lifespan
55
+ from mgf.fastapi._request_id import (
56
+ REQUEST_ID_HEADER,
57
+ RequestIdMiddleware,
58
+ current_request_id,
59
+ )
60
+ from mgf.fastapi.exceptions import HmacVerificationError
61
+
62
+ __all__ = [
63
+ "DEFAULT_STATUS_MAP",
64
+ "REQUEST_ID_HEADER",
65
+ "ExceptionTranslationMiddleware",
66
+ "HmacVerificationError",
67
+ "RequestIdMiddleware",
68
+ "current_request_id",
69
+ "get_app_context",
70
+ "get_request_id",
71
+ "get_settings",
72
+ "setup_lifespan",
73
+ ]
@@ -0,0 +1,93 @@
1
+ """FastAPI ``Depends()`` helpers for mgf-common-shaped data.
2
+
3
+ Each function is a plain callable suitable for use as a FastAPI
4
+ dependency::
5
+
6
+ from fastapi import Depends, FastAPI
7
+ from mgf.fastapi import get_app_context, get_request_id
8
+
9
+ @app.get("/whoami")
10
+ async def whoami(
11
+ ctx: AppContext = Depends(get_app_context),
12
+ request_id: str = Depends(get_request_id),
13
+ ) -> dict[str, str]:
14
+ return {
15
+ "app": ctx.app_name,
16
+ "request": request_id,
17
+ }
18
+
19
+ The dependencies don't construct anything — they read state set by
20
+ :func:`setup_lifespan` and :class:`RequestIdMiddleware`. Missing
21
+ state raises :class:`ApiprobeConfigError`-shaped errors so the
22
+ consumer's exception translation maps them cleanly.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import TYPE_CHECKING
28
+
29
+ from starlette.requests import (
30
+ Request, # noqa: TC002 — FastAPI needs runtime type for Depends introspection
31
+ )
32
+
33
+ from mgf.common.exceptions import AppConfigError
34
+
35
+ if TYPE_CHECKING:
36
+ from mgf.common._lifecycle import AppContext
37
+ from mgf.common.settings import MgfSettings
38
+
39
+
40
+ def get_app_context(request: Request) -> AppContext:
41
+ """Return the active :class:`AppContext` set by :func:`setup_lifespan`.
42
+
43
+ Raises :class:`AppConfigError` if the lifespan hasn't run (test
44
+ fixtures that bypass lifespan, missing ``setup_lifespan(...)``
45
+ in the FastAPI constructor).
46
+ """
47
+ from mgf.common._lifecycle import AppContext as _AppContext
48
+
49
+ ctx = getattr(request.app.state, "mgf_context", None)
50
+ if ctx is None:
51
+ raise AppConfigError(
52
+ "mgf.fastapi: app.state.mgf_context is unset. "
53
+ "Did you pass `lifespan=setup_lifespan(...)` to FastAPI()?"
54
+ )
55
+ if not isinstance(ctx, _AppContext):
56
+ raise AppConfigError(
57
+ f"mgf.fastapi: app.state.mgf_context is not an "
58
+ f"AppContext (got {type(ctx).__name__}). Use "
59
+ f"setup_lifespan() to wire it correctly."
60
+ )
61
+ return ctx
62
+
63
+
64
+ def get_settings(request: Request) -> MgfSettings:
65
+ """Return the :class:`MgfSettings` bound to the active context.
66
+
67
+ Convenience wrapper over :func:`get_app_context` — same error
68
+ semantics if the lifespan didn't run.
69
+ """
70
+ return get_app_context(request).settings
71
+
72
+
73
+ def get_request_id(request: Request) -> str:
74
+ """Return the current request id set by :class:`RequestIdMiddleware`.
75
+
76
+ Returns an empty string if the middleware isn't installed (the
77
+ consumer's choice — empty string is a valid value, not an
78
+ error). Consumers that want a hard error should raise from the
79
+ endpoint::
80
+
81
+ @app.get("/x")
82
+ async def my_endpoint(rid: str = Depends(get_request_id)):
83
+ if not rid:
84
+ raise AppConfigError("RequestIdMiddleware not installed")
85
+ """
86
+ return getattr(request.state, "request_id", "")
87
+
88
+
89
+ __all__ = [
90
+ "get_app_context",
91
+ "get_request_id",
92
+ "get_settings",
93
+ ]
@@ -0,0 +1,200 @@
1
+ """``ExceptionTranslationMiddleware`` — typed AppError → typed HTTP response.
2
+
3
+ A consumer that already has a typed ``AppError`` hierarchy (per
4
+ ``mgf.common.exceptions``) shouldn't have to repeat itself in every
5
+ handler with ``try / except / HTTPException(...)``. This middleware
6
+ catches :class:`AppError` subclasses bubbling out of handlers + maps
7
+ them to clean JSON responses with the right status code.
8
+
9
+ Default mapping (override via the ``status_map=`` constructor kwarg):
10
+
11
+ - :class:`SchemaValidationError` → 400 Bad Request
12
+ - :class:`AppConfigError` / :class:`ConfigError` → 500 Internal Server Error
13
+ (config errors are server-side; the consumer fix the config, not the client)
14
+ - :class:`ResourceNotFoundError` → 404 Not Found
15
+ - :class:`ResourceAlreadyExistsError` → 409 Conflict
16
+ - :class:`HostEnvironmentError` → 503 Service Unavailable
17
+ - :class:`VaultError` → 500 Internal Server Error
18
+ - :class:`OperationError` → 500 Internal Server Error
19
+ - :class:`AppError` (catch-all) → 500 Internal Server Error
20
+
21
+ The response shape is:
22
+
23
+ {
24
+ "error": {
25
+ "type": "ResourceNotFoundError",
26
+ "message": "user not found: alice",
27
+ "request_id": "<...>" # if RequestIdMiddleware is installed
28
+ }
29
+ }
30
+
31
+ Consumers wanting different shapes (RFC 7807 problem+json, JSON:API,
32
+ etc.) provide a custom ``response_factory`` to the constructor.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from typing import TYPE_CHECKING, Any
38
+
39
+ from starlette.middleware.base import BaseHTTPMiddleware
40
+ from starlette.responses import JSONResponse
41
+
42
+ from mgf.common.exceptions import (
43
+ AppError,
44
+ ConfigError,
45
+ HostEnvironmentError,
46
+ OperationError,
47
+ ResourceAlreadyExistsError,
48
+ ResourceNotFoundError,
49
+ SchemaValidationError,
50
+ VaultError,
51
+ )
52
+
53
+ if TYPE_CHECKING:
54
+ from collections.abc import Awaitable, Callable
55
+
56
+ from starlette.requests import Request
57
+ from starlette.responses import Response
58
+
59
+
60
+ # Default exception → status code mapping. Order matters: more
61
+ # specific subclasses BEFORE their parent classes, so the dispatch
62
+ # loop picks the most specific match.
63
+ DEFAULT_STATUS_MAP: dict[type[AppError], int] = {
64
+ SchemaValidationError: 400,
65
+ ResourceNotFoundError: 404,
66
+ ResourceAlreadyExistsError: 409,
67
+ HostEnvironmentError: 503,
68
+ ConfigError: 500,
69
+ VaultError: 500,
70
+ OperationError: 500,
71
+ AppError: 500,
72
+ }
73
+
74
+
75
+ class ExceptionTranslationMiddleware(BaseHTTPMiddleware):
76
+ """Catch :class:`AppError` from handlers; translate to JSON response.
77
+
78
+ Install at app construction time::
79
+
80
+ from fastapi import FastAPI
81
+ from mgf.fastapi import (
82
+ ExceptionTranslationMiddleware,
83
+ RequestIdMiddleware,
84
+ )
85
+
86
+ app = FastAPI()
87
+ app.add_middleware(ExceptionTranslationMiddleware)
88
+ app.add_middleware(RequestIdMiddleware)
89
+
90
+ Order: install RequestIdMiddleware AFTER (so it runs FIRST),
91
+ so the request id is set before the translation middleware needs
92
+ to read it for the response payload.
93
+
94
+ Custom mapping::
95
+
96
+ from mgf.fastapi import ExceptionTranslationMiddleware
97
+ from mgf.common.exceptions import AppError
98
+
99
+ class MyDomainError(AppError): ...
100
+
101
+ app.add_middleware(
102
+ ExceptionTranslationMiddleware,
103
+ status_map={MyDomainError: 422, **DEFAULT_STATUS_MAP},
104
+ )
105
+
106
+ Custom response shape::
107
+
108
+ def my_response(exc, status, request_id):
109
+ return JSONResponse(
110
+ {"detail": str(exc), "trace": request_id},
111
+ status_code=status,
112
+ )
113
+
114
+ app.add_middleware(
115
+ ExceptionTranslationMiddleware,
116
+ response_factory=my_response,
117
+ )
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ app: Any,
123
+ *,
124
+ status_map: dict[type[AppError], int] | None = None,
125
+ response_factory: (
126
+ Callable[[AppError, int, str], Response] | None
127
+ ) = None,
128
+ ) -> None:
129
+ super().__init__(app)
130
+ self._status_map = status_map or DEFAULT_STATUS_MAP
131
+ self._response_factory = response_factory or _default_response_factory
132
+
133
+ async def dispatch(
134
+ self,
135
+ request: Request,
136
+ call_next: Callable[[Request], Awaitable[Response]],
137
+ ) -> Response:
138
+ try:
139
+ return await call_next(request)
140
+ except AppError as exc:
141
+ status = self._lookup_status(type(exc))
142
+ request_id = getattr(request.state, "request_id", "")
143
+ return self._response_factory(exc, status, request_id)
144
+
145
+ def _lookup_status(self, exc_type: type[AppError]) -> int:
146
+ """Return the status for ``exc_type`` walking MRO until a hit.
147
+
148
+ Resolution order (closes PAPER-24 — HTTP-01 ↔ middleware
149
+ composition):
150
+
151
+ 1. ``status_map`` entry for any non-``AppError`` ancestor —
152
+ this is the "explicit override" seam. ``AppError`` itself
153
+ is deferred because it acts as a catch-all and would mask
154
+ the class-declared status below.
155
+ 2. Class-declared :attr:`HttpError.http_status` — HTTP-01 leaves
156
+ (``HttpNotFound``, ``HttpConflict``, …) carry their status as
157
+ a class attribute. A consumer who roots their domain
158
+ exceptions in ``HttpError`` subclasses gets the right status
159
+ without having to register every leaf in ``status_map``.
160
+ 3. ``status_map[AppError]`` catch-all (default 500).
161
+ """
162
+ appbase_status: int | None = None
163
+ for ancestor in exc_type.__mro__:
164
+ if not isinstance(ancestor, type):
165
+ continue
166
+ if not issubclass(ancestor, AppError):
167
+ continue
168
+ if ancestor not in self._status_map:
169
+ continue
170
+ if ancestor is AppError:
171
+ appbase_status = self._status_map[ancestor]
172
+ continue
173
+ return self._status_map[ancestor]
174
+ declared = getattr(exc_type, "http_status", None)
175
+ if isinstance(declared, int):
176
+ return declared
177
+ return appbase_status if appbase_status is not None else 500
178
+
179
+
180
+ def _default_response_factory(
181
+ exc: AppError,
182
+ status: int,
183
+ request_id: str,
184
+ ) -> Response:
185
+ """Default JSON response shape for translated AppErrors."""
186
+ body: dict[str, Any] = {
187
+ "error": {
188
+ "type": type(exc).__name__,
189
+ "message": str(exc),
190
+ },
191
+ }
192
+ if request_id:
193
+ body["error"]["request_id"] = request_id
194
+ return JSONResponse(body, status_code=status)
195
+
196
+
197
+ __all__ = [
198
+ "DEFAULT_STATUS_MAP",
199
+ "ExceptionTranslationMiddleware",
200
+ ]
@@ -0,0 +1,72 @@
1
+ """``setup_lifespan`` — wire :func:`mgf.common.bootstrap` into FastAPI.
2
+
3
+ Returns an async context manager FastAPI accepts as its
4
+ ``lifespan=`` argument. Inside the lifespan, :func:`bootstrap` runs;
5
+ the resulting :class:`AppContext` is stashed on ``app.state.mgf_context``
6
+ so dependencies can read it.
7
+
8
+ When the app shuts down (signal, ASGI shutdown, etc.), the
9
+ :class:`AppContext` LIFO-unwinds — exactly like a synchronous
10
+ ``with bootstrap(...):`` block would.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
16
+ from typing import TYPE_CHECKING
17
+
18
+ from mgf.common._lifecycle import bootstrap
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Callable
22
+
23
+ from fastapi import FastAPI
24
+
25
+ from mgf.common.settings import MgfSettings
26
+
27
+
28
+ def setup_lifespan(
29
+ *,
30
+ app_name: str | None = None,
31
+ app_version: str | None = None,
32
+ settings: MgfSettings | None = None,
33
+ ) -> Callable[[FastAPI], AbstractAsyncContextManager[None]]:
34
+ """Build a FastAPI lifespan that runs :func:`mgf.common.bootstrap`.
35
+
36
+ Use as::
37
+
38
+ from fastapi import FastAPI
39
+ from mgf.fastapi import setup_lifespan
40
+
41
+ app = FastAPI(
42
+ lifespan=setup_lifespan(
43
+ app_name="myapp",
44
+ app_version="1.0.0",
45
+ )
46
+ )
47
+
48
+ With ``app_name`` / ``app_version`` omitted, the same auto-derivation
49
+ rules apply as with bare :func:`bootstrap` (see
50
+ ``docs/reference/lifecycle.md``). The resulting :class:`AppContext`
51
+ is stashed on ``app.state.mgf_context`` for dependency injection
52
+ via :func:`mgf.fastapi.get_app_context`.
53
+
54
+ The lifespan is an async context manager; on app shutdown the
55
+ :class:`AppContext` LIFO-unwinds (logging handlers detach, OTel
56
+ flushes, crash-reporter excepthook restores, etc.).
57
+ """
58
+
59
+ @asynccontextmanager
60
+ async def _lifespan(app: FastAPI): # type: ignore[no-untyped-def]
61
+ with bootstrap(
62
+ app_name=app_name,
63
+ app_version=app_version,
64
+ settings=settings,
65
+ ) as ctx:
66
+ app.state.mgf_context = ctx
67
+ yield
68
+
69
+ return _lifespan
70
+
71
+
72
+ __all__ = ["setup_lifespan"]
@@ -0,0 +1,87 @@
1
+ """``RequestIdMiddleware`` — per-request X-Request-Id generation + propagation.
2
+
3
+ Every HTTP request gets a request id:
4
+
5
+ - If the inbound request carries ``X-Request-Id`` (canonical name —
6
+ case-insensitive lookup), reuse it.
7
+ - Otherwise, generate a UUID4.
8
+
9
+ The request id is:
10
+
11
+ - Set on ``request.state.request_id`` (FastAPI standard surface).
12
+ - Set on a contextvar so :func:`current_request_id` works from any
13
+ code path inside the request — including async task children
14
+ via :pep:`567` propagation.
15
+ - Echoed in the response's ``X-Request-Id`` header.
16
+
17
+ Logging integration: add a logging filter that injects the contextvar
18
+ into log records. The filter is shipped in v0.7 Phase D's async-logging
19
+ work; for v0.6, consumers can attach it manually via ``%(request_id)s``
20
+ format directive in their log config.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import TYPE_CHECKING
26
+
27
+ from starlette.middleware.base import BaseHTTPMiddleware
28
+
29
+ # Shared contextvar + helpers — same identity across framework
30
+ # adapters so request-id correlation works in mixed processes.
31
+ from mgf.common._request_id import (
32
+ _REQUEST_ID_VAR,
33
+ REQUEST_ID_HEADER,
34
+ _generate_request_id,
35
+ current_request_id,
36
+ )
37
+
38
+ if TYPE_CHECKING:
39
+ from collections.abc import Awaitable, Callable
40
+
41
+ from starlette.requests import Request
42
+ from starlette.responses import Response
43
+
44
+
45
+ class RequestIdMiddleware(BaseHTTPMiddleware):
46
+ """ASGI middleware that injects/forwards ``X-Request-Id``.
47
+
48
+ Install at app construction time::
49
+
50
+ from fastapi import FastAPI
51
+ from mgf.fastapi import RequestIdMiddleware
52
+
53
+ app = FastAPI()
54
+ app.add_middleware(RequestIdMiddleware)
55
+
56
+ Order matters when combined with other middlewares: install
57
+ :class:`RequestIdMiddleware` **early** so downstream middlewares +
58
+ handlers see ``request.state.request_id`` populated.
59
+ """
60
+
61
+ async def dispatch(
62
+ self,
63
+ request: Request,
64
+ call_next: Callable[[Request], Awaitable[Response]],
65
+ ) -> Response:
66
+ # Case-insensitive header lookup. Starlette normalises header
67
+ # keys to lowercase internally.
68
+ existing = request.headers.get(REQUEST_ID_HEADER.lower())
69
+ request_id = existing or _generate_request_id()
70
+
71
+ request.state.request_id = request_id
72
+ token = _REQUEST_ID_VAR.set(request_id)
73
+ try:
74
+ response = await call_next(request)
75
+ finally:
76
+ _REQUEST_ID_VAR.reset(token)
77
+
78
+ # Echo in the response so clients can correlate too.
79
+ response.headers[REQUEST_ID_HEADER] = request_id
80
+ return response
81
+
82
+
83
+ __all__ = [
84
+ "REQUEST_ID_HEADER",
85
+ "RequestIdMiddleware",
86
+ "current_request_id",
87
+ ]
@@ -0,0 +1,43 @@
1
+ """``mgf.fastapi.exceptions`` — sibling-domain concrete exception leaves.
2
+
3
+ Per the federation rule (decision **D3** in
4
+ ``mgf-common/docs/release/federation_roadmap.md``): **siblings own
5
+ their domain concretes; mgf-common owns hierarchy roots + status
6
+ leaves.** The HTTP-01 hierarchy roots (`AppError`, `HttpError`,
7
+ `HttpClientError`, `HttpServerError`) and the generic status-code
8
+ leaves (`HttpUnauthorized`, `HttpForbidden`, `HttpNotFound`, etc.)
9
+ stay in :mod:`mgf.common.exceptions`. Sibling-domain concretes that
10
+ inherit from them live here.
11
+
12
+ Today the only such concrete is
13
+ :class:`HmacVerificationError`. Future framework-domain leaves
14
+ (e.g. for FastAPI-specific routing-validation failures) will land
15
+ here as they're added.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from mgf.common.exceptions import HttpUnauthorized
21
+
22
+ __all__ = ["HmacVerificationError"]
23
+
24
+
25
+ class HmacVerificationError(HttpUnauthorized):
26
+ """A webhook HMAC signature failed verification.
27
+
28
+ Raised by :class:`mgf.fastapi.webhooks.HmacWebhookVerifier`
29
+ on any failure of the Svix-shape verification dance: missing
30
+ header, unparseable timestamp, replay-window miss, signature
31
+ mismatch.
32
+
33
+ Inherits from :class:`mgf.common.exceptions.HttpUnauthorized` so
34
+ PAPER-24's ``http_status`` resolution maps it to 401 in
35
+ :class:`mgf.fastapi.ExceptionTranslationMiddleware` without an
36
+ explicit ``status_map`` entry.
37
+
38
+ Lived under :mod:`mgf.common.exceptions` through mgf-common
39
+ v0.27.0; moved here at the v0.28 / v0.1 federation cutover
40
+ per the rule above.
41
+ """
42
+
43
+ http_status: int = 401
mgf/fastapi/py.typed ADDED
File without changes
@@ -0,0 +1,137 @@
1
+ """``mgf.fastapi.security`` — request-side security primitives.
2
+
3
+ Closes FEEDBACK PAPER-27 (v0.27.0): :class:`IpAllowlist`. A FastAPI
4
+ dependency that gates routes on a CIDR allowlist. Concentrates the
5
+ proxy-trust footgun (``X-Forwarded-For`` honouring) in one place
6
+ with a clearly-named opt-in (``trust_proxy=False`` default).
7
+
8
+ Use case: ops-only / staging-only / pre-auth-shipping admin
9
+ endpoints that need a network gate even before SaaS auth is wired.
10
+
11
+ The dependency reads ``request.client.host`` directly by default. If
12
+ the consumer's deployment is behind a reverse proxy, a forwarded-
13
+ headers middleware MUST be installed AND ``trust_proxy=True`` set
14
+ explicitly — otherwise every request appears to originate from the
15
+ proxy.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import ipaddress
21
+ import logging
22
+ from functools import lru_cache
23
+ from typing import TYPE_CHECKING
24
+
25
+ # ``Request`` looks type-only here, but FastAPI evaluates the
26
+ # dependency's annotations at registration time via ``get_type_hints``
27
+ # to bind the parameter — moving this into ``TYPE_CHECKING`` would
28
+ # crash dep wiring at app-startup time.
29
+ from fastapi import Request # noqa: TC002
30
+
31
+ from mgf.common.exceptions import HttpForbidden
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Iterable
35
+
36
+ __all__ = ["LOOPBACK_CIDRS", "IpAllowlist"]
37
+
38
+ LOG = logging.getLogger(__name__)
39
+
40
+ #: Loopback CIDRs that match local-only traffic (IPv4 + IPv6).
41
+ #: The default :class:`IpAllowlist` includes these so tests work
42
+ #: out-of-the-box; consumers OVERRIDE ``cidrs=`` for production.
43
+ LOOPBACK_CIDRS: tuple[str, ...] = ("127.0.0.0/8", "::1/128")
44
+
45
+
46
+ @lru_cache(maxsize=128)
47
+ def _parse_cidr(cidr: str) -> ipaddress.IPv4Network | ipaddress.IPv6Network:
48
+ """Cache-coerce a CIDR string. The allowlist is short and stable."""
49
+ return ipaddress.ip_network(cidr, strict=False)
50
+
51
+
52
+ class IpAllowlist:
53
+ """FastAPI dependency that 403s requests whose client IP isn't in ``cidrs``.
54
+
55
+ Use as a route ``Depends`` annotation::
56
+
57
+ from fastapi import Depends, FastAPI
58
+ from mgf.fastapi.security import IpAllowlist
59
+
60
+ admin_only = IpAllowlist(cidrs=["10.0.0.0/8", "192.168.1.0/24"])
61
+
62
+ app = FastAPI()
63
+
64
+ @app.post("/admin/trackers", dependencies=[Depends(admin_only)])
65
+ async def create_tracker(): ...
66
+
67
+ :param cidrs: Allowed CIDRs (IPv4 and/or IPv6). Empty iterable
68
+ denies every request — useful as a "this endpoint exists
69
+ but is currently disabled" signal.
70
+ :param trust_proxy: When ``True``, the dependency honours the
71
+ first entry in ``X-Forwarded-For`` instead of
72
+ ``request.client.host``. Default ``False`` — your deployment
73
+ MUST explicitly opt in once a forwarded-headers middleware
74
+ is in place. Honouring forwarded headers without proxy trust
75
+ is the canonical IP-spoofing footgun.
76
+
77
+ Raises :class:`HttpForbidden` (403) on miss. Pair with
78
+ :class:`mgf.fastapi.ExceptionTranslationMiddleware` for
79
+ the JSON 403 response shape.
80
+ """
81
+
82
+ __slots__ = ("_networks", "_trust_proxy")
83
+
84
+ def __init__(
85
+ self,
86
+ cidrs: Iterable[str] = LOOPBACK_CIDRS,
87
+ *,
88
+ trust_proxy: bool = False,
89
+ ) -> None:
90
+ # Parse + validate CIDRs eagerly so a misconfiguration
91
+ # surfaces at construction, not at first request.
92
+ self._networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
93
+ for cidr in cidrs:
94
+ try:
95
+ self._networks.append(_parse_cidr(cidr))
96
+ except ValueError as exc:
97
+ msg = f"IpAllowlist: invalid CIDR {cidr!r}"
98
+ raise ValueError(msg) from exc
99
+ self._trust_proxy = trust_proxy
100
+
101
+ def __call__(self, request: Request) -> None:
102
+ """FastAPI dependency form. 403 on miss; returns ``None`` on hit."""
103
+ client_host = self._extract_client_host(request)
104
+ if client_host is None:
105
+ LOG.warning("IpAllowlist: request had no client IP — denying")
106
+ raise HttpForbidden("client IP not in allowlist")
107
+
108
+ try:
109
+ client_ip = ipaddress.ip_address(client_host)
110
+ except ValueError as exc:
111
+ LOG.warning("IpAllowlist: unparseable client IP %r — denying", client_host)
112
+ raise HttpForbidden("client IP not in allowlist") from exc
113
+
114
+ for network in self._networks:
115
+ # IPv4 in IPv4 network OR IPv6 in IPv6 network — version mismatch
116
+ # always means "not a match" so we skip the cross-version pair.
117
+ if client_ip.version == network.version and client_ip in network:
118
+ return
119
+
120
+ LOG.warning(
121
+ "IpAllowlist: client IP %s not in allowlist (%d networks)",
122
+ client_host,
123
+ len(self._networks),
124
+ )
125
+ raise HttpForbidden("client IP not in allowlist")
126
+
127
+ def _extract_client_host(self, request: Request) -> str | None:
128
+ """Return the source IP per ``trust_proxy`` policy."""
129
+ if self._trust_proxy:
130
+ forwarded = request.headers.get("x-forwarded-for")
131
+ if forwarded:
132
+ # Take the first entry — the originating client.
133
+ # Subsequent entries are intermediate proxies.
134
+ return forwarded.split(",", 1)[0].strip()
135
+ if request.client is not None:
136
+ return request.client.host
137
+ return None
mgf/fastapi/testing.py ADDED
@@ -0,0 +1,130 @@
1
+ """``mgf.fastapi.testing`` — context-manager test API server.
2
+
3
+ Closes FEEDBACK PAPER-28 (v0.27.0): :func:`run_test_app`. Pulls the
4
+ "start uvicorn on a free port for Playwright / e2e tests" dance into
5
+ one helper. Replaces ~120 LOC per consumer (free-port discovery +
6
+ ``server.started`` race + clean-shutdown choreography).
7
+
8
+ The server-lifecycle is generic; consumer-specific seeding (test DB
9
+ reset, fixture loading) stays in consumer code — ``run_test_app``
10
+ takes a fully-built :class:`fastapi.FastAPI` and yields the base
11
+ URL.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import contextlib
18
+ import logging
19
+ import socket
20
+ from typing import TYPE_CHECKING
21
+
22
+ import uvicorn
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import AsyncIterator
26
+
27
+ from fastapi import FastAPI
28
+
29
+ __all__ = ["run_test_app"]
30
+
31
+ LOG = logging.getLogger(__name__)
32
+
33
+
34
+ def _free_port(host: str = "127.0.0.1") -> int:
35
+ """Pick a free TCP port by binding ephemerally on ``host``.
36
+
37
+ Race-prone in theory (the kernel could reassign the port between
38
+ bind-and-release here and uvicorn's bind below) but reliable in
39
+ practice — every consumer's test fixture uses this pattern.
40
+ """
41
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
42
+ s.bind((host, 0))
43
+ return int(s.getsockname()[1])
44
+
45
+
46
+ @contextlib.asynccontextmanager
47
+ async def run_test_app(
48
+ app: FastAPI,
49
+ *,
50
+ port: int | None = None,
51
+ host: str = "127.0.0.1",
52
+ log_level: str = "warning",
53
+ startup_timeout_seconds: float = 5.0,
54
+ ) -> AsyncIterator[str]:
55
+ """Start ``app`` under uvicorn; yield the base URL; tear down on exit.
56
+
57
+ Usage in a pytest-asyncio test::
58
+
59
+ from mgf.fastapi.testing import run_test_app
60
+
61
+ async def test_my_endpoint():
62
+ app = build_my_test_app() # consumer-side fixture
63
+ async with run_test_app(app) as base_url:
64
+ async with httpx.AsyncClient(base_url=base_url) as client:
65
+ response = await client.get("/health")
66
+ assert response.status_code == 200
67
+
68
+ :param app: A :class:`fastapi.FastAPI` instance ready to serve.
69
+ Lifespan handlers fire as part of uvicorn startup.
70
+ :param port: Port to bind. ``None`` (default) picks a free
71
+ ephemeral port — the right answer for parallel test runs.
72
+ :param host: Bind host. Default ``127.0.0.1`` — never expose
73
+ a test server externally. Override only for container-bridge
74
+ scenarios where the test runner reaches the server through a
75
+ non-loopback address.
76
+ :param log_level: uvicorn log level. Default ``warning`` — uvicorn's
77
+ info-level chatter ("Started server process", etc.) crowds
78
+ test output.
79
+ :param startup_timeout_seconds: Max wait for uvicorn's
80
+ ``server.started`` flag. Default 5 s. Raises
81
+ :class:`TimeoutError` on miss.
82
+
83
+ Yields the base URL (``http://<host>:<port>``).
84
+
85
+ On exit (success OR exception), uvicorn is signalled to shut
86
+ down and the underlying task is awaited. ``CancelledError`` from
87
+ the server task is suppressed — it's the expected outcome of a
88
+ clean shutdown, not a failure.
89
+ """
90
+ bind_port = port if port is not None else _free_port(host=host)
91
+ base_url = f"http://{host}:{bind_port}"
92
+
93
+ config = uvicorn.Config(
94
+ app,
95
+ host=host,
96
+ port=bind_port,
97
+ log_level=log_level,
98
+ lifespan="on",
99
+ )
100
+ server = uvicorn.Server(config)
101
+
102
+ serve_task = asyncio.create_task(server.serve())
103
+ # Poll for uvicorn's started flag with a sensible bound.
104
+ deadline = asyncio.get_event_loop().time() + startup_timeout_seconds
105
+ while not server.started:
106
+ if asyncio.get_event_loop().time() > deadline:
107
+ server.should_exit = True
108
+ with contextlib.suppress(asyncio.CancelledError):
109
+ await serve_task
110
+ msg = (
111
+ f"run_test_app: server failed to start within "
112
+ f"{startup_timeout_seconds}s on {base_url}"
113
+ )
114
+ raise TimeoutError(msg)
115
+ # If the serve task has already crashed (e.g. port collision),
116
+ # surface its exception now instead of timing out silently.
117
+ if serve_task.done():
118
+ exc = serve_task.exception()
119
+ if exc is not None:
120
+ raise exc
121
+ msg = "run_test_app: server task exited before reaching started"
122
+ raise RuntimeError(msg)
123
+ await asyncio.sleep(0.02)
124
+
125
+ try:
126
+ yield base_url
127
+ finally:
128
+ server.should_exit = True
129
+ with contextlib.suppress(asyncio.CancelledError):
130
+ await serve_task
@@ -0,0 +1,43 @@
1
+ """``mgf.fastapi.webhooks`` — Svix-shape HMAC webhook verification.
2
+
3
+ Closes FEEDBACK PAPER-26 (v0.26.0). Replaces the per-consumer
4
+ HMAC verification dance every web app reinvents:
5
+
6
+ * Read ``<id>``, ``<ts>``, ``<sig>`` headers (case-insensitive).
7
+ * Validate timestamp against a replay window (default 5 minutes).
8
+ * Compute ``HMAC-SHA256(<id>.<ts>.<body>)``, base64-encode.
9
+ * Constant-time-compare against the signature header (multi-version
10
+ space-separated for key rotation).
11
+
12
+ The core verifier (:class:`HmacWebhookVerifier`) is transport-
13
+ agnostic — takes a header mapping and a body bytes object. The
14
+ FastAPI request adapter (:func:`verify_request`) lifts a
15
+ ``Request`` into that shape; it lives separately so the core stays
16
+ importable without ``fastapi``.
17
+
18
+ Pairs with :class:`mgf.common.exceptions.HmacVerificationError`
19
+ (inherits from :class:`HttpUnauthorized` so PAPER-24's
20
+ ``http_status`` resolution maps it to 401 without an explicit
21
+ ``status_map`` entry).
22
+
23
+ v0.26.0 ships :data:`SVIX_SCHEMA` as the only built-in preset.
24
+ Stripe / GitHub / Slack use related-but-distinct shapes (combined
25
+ headers, hex encoding, alternate prefixes) and are deferred until
26
+ a second consumer surfaces friction — file via :file:`FEEDBACK.md`.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from mgf.fastapi.webhooks._request import verify_request
32
+ from mgf.fastapi.webhooks._verifier import (
33
+ SVIX_SCHEMA,
34
+ HmacWebhookVerifier,
35
+ WebhookHeaderSchema,
36
+ )
37
+
38
+ __all__ = [
39
+ "SVIX_SCHEMA",
40
+ "HmacWebhookVerifier",
41
+ "WebhookHeaderSchema",
42
+ "verify_request",
43
+ ]
@@ -0,0 +1,57 @@
1
+ """FastAPI ``Request``-adapter for :class:`HmacWebhookVerifier`.
2
+
3
+ The verifier itself is transport-agnostic. This module provides the
4
+ thin lift from a FastAPI ``Request`` into the verifier's
5
+ ``Mapping[str, str]`` + ``bytes`` shape. Lives in its own file so the
6
+ core verifier stays importable without ``fastapi`` installed (matches
7
+ the closed-box invariant for ``mgf.fastapi``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from fastapi import Request
16
+
17
+ from mgf.fastapi.webhooks._verifier import HmacWebhookVerifier
18
+
19
+ __all__ = ["verify_request"]
20
+
21
+
22
+ async def verify_request(
23
+ request: Request,
24
+ verifier: HmacWebhookVerifier,
25
+ ) -> tuple[str, int]:
26
+ """Read body + headers from a FastAPI ``Request`` and verify.
27
+
28
+ :param request: A FastAPI ``Request``. Caller must NOT have
29
+ consumed the body yet — this function reads it via
30
+ ``await request.body()``.
31
+ :param verifier: The configured :class:`HmacWebhookVerifier`.
32
+ :returns: ``(event_id, timestamp)`` per :meth:`HmacWebhookVerifier.verify`.
33
+ :raises HmacVerificationError: on any verification failure.
34
+
35
+ Typical usage in a FastAPI route::
36
+
37
+ from mgf.fastapi.webhooks import (
38
+ HmacWebhookVerifier,
39
+ verify_request,
40
+ )
41
+
42
+ verifier = HmacWebhookVerifier(secret=settings.webhook_secret)
43
+
44
+ @app.post("/webhooks/clerk")
45
+ async def clerk_webhook(request: Request) -> dict:
46
+ event_id, ts = await verify_request(request, verifier)
47
+ payload = await request.json()
48
+ ...
49
+ return {"ok": True}
50
+
51
+ Pair with :class:`mgf.fastapi.ExceptionTranslationMiddleware`
52
+ to get the 401 response shape automatically — :class:`HmacVerificationError`
53
+ inherits from :class:`HttpUnauthorized` so PAPER-24's ``http_status``
54
+ resolution maps it without an explicit ``status_map`` entry.
55
+ """
56
+ body = await request.body()
57
+ return verifier.verify(request.headers, body)
@@ -0,0 +1,188 @@
1
+ """Transport-agnostic Svix-shape HMAC webhook verifier.
2
+
3
+ The verifier core takes a ``Mapping[str, str]`` of headers and a
4
+ ``bytes`` body — no FastAPI / Starlette / framework dependency.
5
+ The framework adapter (``verify_request``) lives separately so the
6
+ core is testable in isolation and reusable from non-FastAPI code
7
+ paths (CLI, batch jobs, alternative HTTP frameworks).
8
+
9
+ The signing scheme is Svix's: ``HMAC-SHA256(<id>.<ts>.<body>)`` keyed
10
+ on the shared secret, base64-encoded, prefixed ``v1,`` per signature
11
+ version, multiple versions space-separated for key rotation. Clerk
12
+ ships Svix verbatim; Stripe / GitHub / Slack use related-but-distinct
13
+ shapes (combined headers, hex encoding, alternate prefixes) and are
14
+ NOT covered at v0.26 — see PAPER-26 retrospective.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import hmac
22
+ import time
23
+ from dataclasses import dataclass
24
+ from typing import TYPE_CHECKING
25
+
26
+ from mgf.fastapi.exceptions import HmacVerificationError
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Callable, Mapping
30
+
31
+ __all__ = [
32
+ "SVIX_SCHEMA",
33
+ "HmacWebhookVerifier",
34
+ "WebhookHeaderSchema",
35
+ ]
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class WebhookHeaderSchema:
40
+ """Names of the three Svix-style headers a provider uses.
41
+
42
+ Header names are matched case-insensitively against the input
43
+ mapping; the values stored here are the canonical lowercase form.
44
+ """
45
+
46
+ id_header: str
47
+ timestamp_header: str
48
+ signature_header: str
49
+
50
+ def __post_init__(self) -> None:
51
+ # Force-lowercase the canonical names so matching is uniform —
52
+ # consumers can pass any case and the verifier still finds them.
53
+ object.__setattr__(self, "id_header", self.id_header.lower())
54
+ object.__setattr__(self, "timestamp_header", self.timestamp_header.lower())
55
+ object.__setattr__(self, "signature_header", self.signature_header.lower())
56
+
57
+
58
+ SVIX_SCHEMA = WebhookHeaderSchema(
59
+ id_header="svix-id",
60
+ timestamp_header="svix-timestamp",
61
+ signature_header="svix-signature",
62
+ )
63
+ """Svix-flavoured header names. Used directly by Clerk webhooks.
64
+
65
+ Stripe / GitHub / Slack are deliberately not shipped at v0.26 — their
66
+ signing-header conventions differ enough (combined headers, hex
67
+ encoding, alternate prefixes) that a single :class:`WebhookHeaderSchema`
68
+ doesn't fit them. Adding them is tracked for follow-up papers.
69
+ """
70
+
71
+
72
+ class HmacWebhookVerifier:
73
+ """Verify a Svix-shape HMAC-signed webhook.
74
+
75
+ The verifier is constructed once per webhook secret and reused
76
+ across requests. It is stateless and thread-safe; concurrent
77
+ callers MAY share a single instance.
78
+
79
+ :param secret: The shared secret. ``bytes`` is preferred; ``str``
80
+ is encoded as UTF-8 for convenience. For Svix/Clerk, the
81
+ ``whsec_…`` prefix is part of the secret and SHOULD be passed
82
+ through verbatim (do not strip).
83
+ :param schema: Header-name schema. Defaults to :data:`SVIX_SCHEMA`.
84
+ :param replay_window_seconds: Maximum age (in seconds) of a webhook
85
+ timestamp to accept. Default 300 (5 minutes), matching Svix's
86
+ published recommendation.
87
+ :param signature_prefix: The version prefix. Svix uses ``"v1,"``
88
+ (the comma is part of the prefix; the base64 sig follows).
89
+ Multi-version sigs are space-separated; the verifier accepts
90
+ any one matching this prefix.
91
+ :param now: Override the timestamp source. Tests pass a fixed
92
+ ``lambda: 1614265330``; production uses the default
93
+ :func:`time.time`. Returns float seconds-since-epoch.
94
+ """
95
+
96
+ __slots__ = (
97
+ "_now",
98
+ "_replay_window_seconds",
99
+ "_schema",
100
+ "_secret",
101
+ "_signature_prefix",
102
+ )
103
+
104
+ def __init__(
105
+ self,
106
+ *,
107
+ secret: bytes | str,
108
+ schema: WebhookHeaderSchema = SVIX_SCHEMA,
109
+ replay_window_seconds: int = 300,
110
+ signature_prefix: str = "v1,",
111
+ now: Callable[[], float] | None = None,
112
+ ) -> None:
113
+ self._secret = secret.encode("utf-8") if isinstance(secret, str) else secret
114
+ self._schema = schema
115
+ self._replay_window_seconds = replay_window_seconds
116
+ self._signature_prefix = signature_prefix
117
+ self._now = now if now is not None else time.time
118
+
119
+ def verify(
120
+ self,
121
+ headers: Mapping[str, str],
122
+ body: bytes,
123
+ ) -> tuple[str, int]:
124
+ """Verify the signature on ``body`` against the headers.
125
+
126
+ :returns: ``(event_id, timestamp)`` on success — the verified
127
+ header values, useful for callers that want to log the
128
+ event id or de-dupe.
129
+ :raises HmacVerificationError: on missing header, unparseable
130
+ timestamp, replay-window miss, or signature mismatch.
131
+ The message gives the proximate cause (no secret material).
132
+ """
133
+ # Case-insensitive header lookup. Build a single dict once;
134
+ # cheap relative to HMAC compute.
135
+ h = {k.lower(): v for k, v in headers.items()}
136
+
137
+ event_id = h.get(self._schema.id_header)
138
+ if event_id is None:
139
+ raise HmacVerificationError(
140
+ f"missing header {self._schema.id_header!r}"
141
+ )
142
+
143
+ ts_raw = h.get(self._schema.timestamp_header)
144
+ if ts_raw is None:
145
+ raise HmacVerificationError(
146
+ f"missing header {self._schema.timestamp_header!r}"
147
+ )
148
+ try:
149
+ ts = int(ts_raw)
150
+ except ValueError as exc:
151
+ raise HmacVerificationError(
152
+ f"header {self._schema.timestamp_header!r} is not an integer"
153
+ ) from exc
154
+
155
+ sig_header = h.get(self._schema.signature_header)
156
+ if sig_header is None:
157
+ raise HmacVerificationError(
158
+ f"missing header {self._schema.signature_header!r}"
159
+ )
160
+
161
+ # Replay window: reject timestamps too far in the past or future.
162
+ # Future skew is bounded too — a clock that's drifted way ahead
163
+ # is as suspicious as a stale one.
164
+ delta = abs(self._now() - ts)
165
+ if delta > self._replay_window_seconds:
166
+ raise HmacVerificationError(
167
+ f"timestamp outside replay window "
168
+ f"({self._replay_window_seconds}s)"
169
+ )
170
+
171
+ # Compute expected signature: HMAC-SHA256(<id>.<ts>.<body>)
172
+ # base64-encoded.
173
+ signed_payload = f"{event_id}.{ts}.".encode() + body
174
+ digest = hmac.new(self._secret, signed_payload, hashlib.sha256).digest()
175
+ expected = base64.b64encode(digest).decode("ascii")
176
+
177
+ # Multi-version signatures: Svix sends ``v1,sigA v1,sigB`` for
178
+ # key rotation. Accept any one matching the configured prefix.
179
+ candidates = sig_header.split(" ")
180
+ for candidate in candidates:
181
+ if not candidate.startswith(self._signature_prefix):
182
+ continue
183
+ received = candidate[len(self._signature_prefix) :]
184
+ # Constant-time comparison to prevent timing-oracle leak.
185
+ if hmac.compare_digest(received, expected):
186
+ return event_id, ts
187
+
188
+ raise HmacVerificationError("signature mismatch")
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: mgf-fastapi
3
+ Version: 0.1.0
4
+ Summary: FastAPI integration adapters for mgf-common — request-id + exception translation + lifespan + webhooks (Svix HMAC) + IpAllowlist + run_test_app. Sibling of mgf-common under the mgf.* namespace.
5
+ Project-URL: Homepage, https://codeberg.org/magogi-admin/mgf-fastapi
6
+ Project-URL: Issues, https://codeberg.org/magogi-admin/mgf-fastapi/issues
7
+ Project-URL: Changelog, https://codeberg.org/magogi-admin/mgf-fastapi/src/branch/master/CHANGELOG.md
8
+ Author: Bassam Alsanie, mgf-fastapi contributors
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: fastapi,ip-allowlist,middleware,request-id,svix,webhooks
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: FastAPI
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Internet :: WWW/HTTP
24
+ Classifier: Topic :: Software Development :: Libraries
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.11
27
+ Requires-Dist: fastapi>=0.110
28
+ Requires-Dist: mgf-common<0.29,>=0.28
29
+ Provides-Extra: dev
30
+ Requires-Dist: httpx>=0.27; extra == 'dev'
31
+ Requires-Dist: hypothesis>=6.100; extra == 'dev'
32
+ Requires-Dist: import-linter>=2.0; extra == 'dev'
33
+ Requires-Dist: mypy>=1.10; extra == 'dev'
34
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
35
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
36
+ Requires-Dist: pytest>=8.0; extra == 'dev'
37
+ Requires-Dist: ruff>=0.4; extra == 'dev'
38
+ Requires-Dist: uvicorn>=0.30; extra == 'dev'
39
+ Provides-Extra: testing
40
+ Requires-Dist: httpx>=0.27; extra == 'testing'
41
+ Requires-Dist: uvicorn>=0.30; extra == 'testing'
42
+ Description-Content-Type: text/markdown
43
+
44
+ # `mgf-fastapi` — FastAPI integration adapters for mgf-common
45
+
46
+ [![PyPI](https://img.shields.io/pypi/v/mgf-fastapi)](https://pypi.org/project/mgf-fastapi/)
47
+ [![Python](https://img.shields.io/pypi/pyversions/mgf-fastapi)](https://pypi.org/project/mgf-fastapi/)
48
+
49
+ > **Sibling of [`mgf-common`](https://pypi.org/project/mgf-common/)
50
+ > under the `mgf.*` namespace.** Houses every FastAPI-specific
51
+ > adapter that previously lived under `mgf.common.fastapi.*` —
52
+ > extracted at mgf-common v0.28 / mgf-fastapi v0.1 per the
53
+ > [federation split plan](https://codeberg.org/magogi-admin/mgf_common/src/branch/master/docs/release/federation_roadmap.md).
54
+
55
+ ## What this provides
56
+
57
+ | Submodule | What |
58
+ |---|---|
59
+ | `mgf.fastapi` | `bootstrap` ↔ FastAPI lifespan; `RequestIdMiddleware`; `ExceptionTranslationMiddleware`; `Depends()` helpers (`get_app_context`, `get_settings`, `get_request_id`); `setup_lifespan`. |
60
+ | `mgf.fastapi.webhooks` | Svix-shape HMAC webhook verification (`HmacWebhookVerifier`, `WebhookHeaderSchema`, `SVIX_SCHEMA`, `verify_request`). |
61
+ | `mgf.fastapi.security` | `IpAllowlist` FastAPI dependency with proxy-trust opt-in. |
62
+ | `mgf.fastapi.testing` | `run_test_app` async context manager — start uvicorn for a FastAPI app on a free port; yield base URL; clean shutdown. |
63
+ | `mgf.fastapi.exceptions` | `HmacVerificationError(HttpUnauthorized)` and other framework-domain concrete leaves. (HTTP-01 hierarchy roots stay in `mgf.common.exceptions`.) |
64
+
65
+ ## Install
66
+
67
+ ```bash
68
+ pip install mgf-fastapi
69
+ # Or with the test-helper extra (uvicorn + httpx for run_test_app):
70
+ pip install 'mgf-fastapi[testing]'
71
+ ```
72
+
73
+ Pulls in `mgf-common` and `fastapi` automatically.
74
+
75
+ ## Quick start
76
+
77
+ ```python
78
+ from fastapi import FastAPI, Request
79
+ from mgf.fastapi import (
80
+ ExceptionTranslationMiddleware,
81
+ RequestIdMiddleware,
82
+ setup_lifespan,
83
+ )
84
+ from mgf.fastapi.webhooks import HmacWebhookVerifier, verify_request
85
+
86
+ app = FastAPI(lifespan=setup_lifespan(app_name="my-service", app_version="0.1.0"))
87
+ app.add_middleware(ExceptionTranslationMiddleware)
88
+ app.add_middleware(RequestIdMiddleware)
89
+
90
+ webhook = HmacWebhookVerifier(secret=settings.webhook_secret)
91
+
92
+ @app.post("/webhooks/clerk")
93
+ async def clerk_webhook(request: Request) -> dict:
94
+ event_id, ts = await verify_request(request, webhook)
95
+ payload = await request.json()
96
+ # ... process the (now-trusted) payload ...
97
+ return {"ok": True}
98
+ ```
99
+
100
+ ## Documentation
101
+
102
+ - [`docs/recipes/fastapi.md`](docs/recipes/fastapi.md) — full FastAPI service walkthrough.
103
+ - [`docs/recipes/webhooks.md`](docs/recipes/webhooks.md) — HMAC webhook verification.
104
+ - [`docs/cutover/v0.1.0.md`](docs/cutover/v0.1.0.md) — maiden voyage migration story (the v0.28 split).
105
+ - [`PUBLIC_API.md`](PUBLIC_API.md) — full public surface contract.
106
+ - [`CHANGELOG.md`](CHANGELOG.md) — release history.
107
+
108
+ For the federation-wide engineering standards (DESIGN_PRINCIPLES,
109
+ ERROR_HANDLING, SECURITY, etc.) see
110
+ [`mgf-common/docs/standards/`](https://codeberg.org/magogi-admin/mgf_common/src/branch/master/docs/standards/).
111
+ This sibling inherits them by reference; the standards source-of-truth
112
+ lives in mgf-common.
113
+
114
+ ## Status
115
+
116
+ 🚧 **Experimental** — every public name is `experimental` per AP-09.
117
+ Promotion to `stable` happens release-by-release as consumer feedback
118
+ in [`FEEDBACK.md`](FEEDBACK.md) converges. The 0.x window applies.
119
+ Pin tightly: `mgf-fastapi = ">=0.X.0,<0.Y"`.
120
+
121
+ ## Cross-references
122
+
123
+ - **Filing process for sharp edges**: open an entry on
124
+ [`mgf-common/FEEDBACK.md`](https://codeberg.org/magogi-admin/mgf_common/src/branch/master/FEEDBACK.md)
125
+ with `[mgf-fastapi]` prefix, OR file directly on this repo's
126
+ Issues → maintainer mirrors into the canonical FEEDBACK.md.
127
+ - **Federation pattern**:
128
+ [`mgf-common/docs/design/federation.md`](https://codeberg.org/magogi-admin/mgf_common/src/branch/master/docs/design/federation.md).
129
+ - **The split that created this sibling**:
130
+ [`mgf-common/docs/release/federation_roadmap.md`](https://codeberg.org/magogi-admin/mgf_common/src/branch/master/docs/release/federation_roadmap.md).
@@ -0,0 +1,16 @@
1
+ mgf/fastapi/__init__.py,sha256=XJ_Fb4-IhN3cNlUGPbbqKrrMYZjNyJkDPjHyvniJpoE,2479
2
+ mgf/fastapi/_dependencies.py,sha256=cIWmlh4oTrfKlT0udTLVNLw-SPzM2jK4OthbVyCqWGw,2965
3
+ mgf/fastapi/_exceptions.py,sha256=dXQlDXgb4IA-Gs7Zd6Q1tUZc65LixJtGqbXdEFWxQE4,6715
4
+ mgf/fastapi/_lifespan.py,sha256=Yx4rM0QQDnZTj6TZnAMjGvn-TxYUQMwTbFE06V3yksE,2185
5
+ mgf/fastapi/_request_id.py,sha256=_o79Zv_G9bV6w-igA0FqfIqnH6FoNli8r95Ajpvl_v4,2728
6
+ mgf/fastapi/exceptions.py,sha256=fNpStoXjwMd91h4Ol-74CryXc5tVBKBAgLGQ5N56S90,1592
7
+ mgf/fastapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ mgf/fastapi/security.py,sha256=-E8D1GRJ0MzmWrK1mNBdlY78S3kt3dFvYcwrt9tCopc,5354
9
+ mgf/fastapi/testing.py,sha256=OmgFl-Vo5oLFnsmc8mdFEr5nH97uZrJzgTLNgqGC04Q,4634
10
+ mgf/fastapi/webhooks/__init__.py,sha256=-zDAHTIvg8LaYw7o16D5yUsLpNNyTNenlntDRjKFyzA,1567
11
+ mgf/fastapi/webhooks/_request.py,sha256=XySFn5GIzfyzkn6DLAkY_mp7gtQz_6gMiK3QkGftxb8,2005
12
+ mgf/fastapi/webhooks/_verifier.py,sha256=HSf4xwczN22Thj4HGg8L7I5fYaGj8Rpyk-so7QmFskY,7178
13
+ mgf_fastapi-0.1.0.dist-info/METADATA,sha256=VXqWSksQGEk4Ws9cOb6PTFS-cC-zDyBgX6lhDoloyqM,6037
14
+ mgf_fastapi-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
+ mgf_fastapi-0.1.0.dist-info/licenses/LICENSE,sha256=akPl7tlbK9cB--mcmU-3T3VYoIpSXQUJd3DDpztyDFc,1099
16
+ mgf_fastapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bassam Alsanie and mgf-common contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.