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.
- mgf/fastapi/__init__.py +73 -0
- mgf/fastapi/_dependencies.py +93 -0
- mgf/fastapi/_exceptions.py +200 -0
- mgf/fastapi/_lifespan.py +72 -0
- mgf/fastapi/_request_id.py +87 -0
- mgf/fastapi/exceptions.py +43 -0
- mgf/fastapi/py.typed +0 -0
- mgf/fastapi/security.py +137 -0
- mgf/fastapi/testing.py +130 -0
- mgf/fastapi/webhooks/__init__.py +43 -0
- mgf/fastapi/webhooks/_request.py +57 -0
- mgf/fastapi/webhooks/_verifier.py +188 -0
- mgf_fastapi-0.1.0.dist-info/METADATA +130 -0
- mgf_fastapi-0.1.0.dist-info/RECORD +16 -0
- mgf_fastapi-0.1.0.dist-info/WHEEL +4 -0
- mgf_fastapi-0.1.0.dist-info/licenses/LICENSE +21 -0
mgf/fastapi/__init__.py
ADDED
|
@@ -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
|
+
]
|
mgf/fastapi/_lifespan.py
ADDED
|
@@ -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
|
mgf/fastapi/security.py
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/mgf-fastapi/)
|
|
47
|
+
[](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,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.
|