svc-infra 0.1.562__py3-none-any.whl → 0.1.654__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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +142 -4
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +178 -12
- svc_infra/apf_payments/provider/stripe.py +757 -48
- svc_infra/apf_payments/schemas.py +163 -1
- svc_infra/apf_payments/service.py +582 -42
- svc_infra/apf_payments/settings.py +22 -2
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/router.py +792 -73
- svc_infra/api/fastapi/apf_payments/setup.py +13 -4
- svc_infra/api/fastapi/auth/add.py +10 -4
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +74 -34
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +13 -1
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +41 -6
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +82 -42
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +84 -11
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +244 -38
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +133 -32
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +23 -14
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +13 -8
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -5
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/RECORD +174 -56
- svc_infra-0.1.562.dist-info/METADATA +0 -79
- {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
|
@@ -44,8 +44,8 @@ def _encode_cursor(payload: dict) -> str:
|
|
|
44
44
|
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
# public; handy if you need to decode an incoming cursor
|
|
48
47
|
def decode_cursor(token: Optional[str]) -> dict:
|
|
48
|
+
"""Public: decode an incoming cursor token for debugging/ops."""
|
|
49
49
|
if not token:
|
|
50
50
|
return {}
|
|
51
51
|
s = token + "=" * (-len(token) % 4)
|
|
@@ -55,15 +55,14 @@ def decode_cursor(token: Optional[str]) -> dict:
|
|
|
55
55
|
|
|
56
56
|
# ---------- Context ----------
|
|
57
57
|
class PaginationContext(Generic[T]):
|
|
58
|
-
# mode config
|
|
59
58
|
envelope: bool
|
|
60
59
|
allow_cursor: bool
|
|
61
60
|
allow_page: bool
|
|
62
61
|
|
|
63
|
-
# values
|
|
64
62
|
cursor_params: CursorParams | None
|
|
65
63
|
page_params: PageParams | None
|
|
66
64
|
filters: FilterParams | None
|
|
65
|
+
limit_override: int | None
|
|
67
66
|
|
|
68
67
|
def __init__(
|
|
69
68
|
self,
|
|
@@ -90,7 +89,9 @@ class PaginationContext(Generic[T]):
|
|
|
90
89
|
|
|
91
90
|
@property
|
|
92
91
|
def limit(self) -> int:
|
|
93
|
-
|
|
92
|
+
# For cursor-based pagination, always honor the requested limit, even on the first page
|
|
93
|
+
# (cursor may be None for the first page).
|
|
94
|
+
if self.allow_cursor and self.cursor_params:
|
|
94
95
|
return self.cursor_params.limit
|
|
95
96
|
if self.allow_page and self.page_params:
|
|
96
97
|
return self.limit_override or self.page_params.page_size
|
|
@@ -117,7 +118,6 @@ class PaginationContext(Generic[T]):
|
|
|
117
118
|
return Paginated[T](items=items, next_cursor=next_cursor, total=total)
|
|
118
119
|
return items
|
|
119
120
|
|
|
120
|
-
# convenience: derive a naive next_cursor from the last item
|
|
121
121
|
def next_cursor_from_last(
|
|
122
122
|
self, items: Sequence[T], *, key: Callable[[T], str | int]
|
|
123
123
|
) -> Optional[str]:
|
|
@@ -135,20 +135,20 @@ _pagination_ctx: contextvars.ContextVar[PaginationContext] = contextvars.Context
|
|
|
135
135
|
def use_pagination() -> PaginationContext:
|
|
136
136
|
ctx = _pagination_ctx.get()
|
|
137
137
|
if ctx is None:
|
|
138
|
-
# Safe defaults;
|
|
138
|
+
# Safe defaults; if a route forgot to install the injector
|
|
139
139
|
ctx = PaginationContext(
|
|
140
140
|
envelope=False,
|
|
141
141
|
allow_cursor=True,
|
|
142
|
-
allow_page=
|
|
142
|
+
allow_page=False,
|
|
143
143
|
cursor_params=CursorParams(),
|
|
144
|
-
page_params=
|
|
145
|
-
filters=
|
|
144
|
+
page_params=None,
|
|
145
|
+
filters=None,
|
|
146
146
|
)
|
|
147
147
|
return ctx
|
|
148
148
|
|
|
149
149
|
|
|
150
|
+
# ---------- Utilities ----------
|
|
150
151
|
def text_filter(items: Iterable[T], q: Optional[str], *getters: Callable[[T], str]) -> list[T]:
|
|
151
|
-
"""Simple contains filter across one or more string fields."""
|
|
152
152
|
if not q:
|
|
153
153
|
return list(items)
|
|
154
154
|
ql = q.lower()
|
|
@@ -170,13 +170,10 @@ def sort_by(
|
|
|
170
170
|
key: Callable[[T], Any],
|
|
171
171
|
desc: bool = False,
|
|
172
172
|
) -> list[T]:
|
|
173
|
-
"""Stable sort with a key func."""
|
|
174
173
|
return sorted(list(items), key=key, reverse=desc)
|
|
175
174
|
|
|
176
175
|
|
|
177
176
|
def cursor_window(items, *, cursor, limit, key, descending: bool, offset: int = 0):
|
|
178
|
-
# items must already be filtered/sorted
|
|
179
|
-
|
|
180
177
|
# compute start_index
|
|
181
178
|
if cursor:
|
|
182
179
|
payload = decode_cursor(cursor)
|
|
@@ -195,14 +192,14 @@ def cursor_window(items, *, cursor, limit, key, descending: bool, offset: int =
|
|
|
195
192
|
window = slice_[:limit]
|
|
196
193
|
|
|
197
194
|
next_cur = None
|
|
198
|
-
if has_more:
|
|
195
|
+
if has_more and window:
|
|
199
196
|
last_key = key(window[-1])
|
|
200
197
|
next_cur = _encode_cursor({"after": last_key})
|
|
201
198
|
|
|
202
199
|
return window, next_cur
|
|
203
200
|
|
|
204
201
|
|
|
205
|
-
# ---------- Dependency
|
|
202
|
+
# ---------- Dependency factories ----------
|
|
206
203
|
def make_pagination_injector(
|
|
207
204
|
*,
|
|
208
205
|
envelope: bool,
|
|
@@ -210,7 +207,97 @@ def make_pagination_injector(
|
|
|
210
207
|
allow_page: bool,
|
|
211
208
|
default_limit: int = 50,
|
|
212
209
|
max_limit: int = 200,
|
|
210
|
+
include_filters: bool = False,
|
|
213
211
|
):
|
|
212
|
+
"""
|
|
213
|
+
Returns a dependency with a signature that only includes the relevant query params.
|
|
214
|
+
This keeps the generated OpenAPI in sync with actual behavior.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
# Cursor-only (common case)
|
|
218
|
+
if allow_cursor and not allow_page and not include_filters:
|
|
219
|
+
|
|
220
|
+
async def _inject(
|
|
221
|
+
request: Request,
|
|
222
|
+
cursor: str | None = Query(None),
|
|
223
|
+
limit: int = Query(default_limit, ge=1, le=max_limit),
|
|
224
|
+
):
|
|
225
|
+
cur = CursorParams(cursor=cursor, limit=limit)
|
|
226
|
+
_pagination_ctx.set(
|
|
227
|
+
PaginationContext(
|
|
228
|
+
envelope=envelope,
|
|
229
|
+
allow_cursor=True,
|
|
230
|
+
allow_page=False,
|
|
231
|
+
cursor_params=cur,
|
|
232
|
+
page_params=None,
|
|
233
|
+
filters=None,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
return _inject
|
|
239
|
+
|
|
240
|
+
# Cursor + filters
|
|
241
|
+
if allow_cursor and not allow_page and include_filters:
|
|
242
|
+
|
|
243
|
+
async def _inject(
|
|
244
|
+
request: Request,
|
|
245
|
+
cursor: str | None = Query(None),
|
|
246
|
+
limit: int = Query(default_limit, ge=1, le=max_limit),
|
|
247
|
+
q: str | None = Query(None),
|
|
248
|
+
sort: str | None = Query(None),
|
|
249
|
+
created_after: str | None = Query(None),
|
|
250
|
+
created_before: str | None = Query(None),
|
|
251
|
+
updated_after: str | None = Query(None),
|
|
252
|
+
updated_before: str | None = Query(None),
|
|
253
|
+
):
|
|
254
|
+
cur = CursorParams(cursor=cursor, limit=limit)
|
|
255
|
+
flt = FilterParams(
|
|
256
|
+
q=q,
|
|
257
|
+
sort=sort,
|
|
258
|
+
created_after=created_after,
|
|
259
|
+
created_before=created_before,
|
|
260
|
+
updated_after=updated_after,
|
|
261
|
+
updated_before=updated_before,
|
|
262
|
+
)
|
|
263
|
+
_pagination_ctx.set(
|
|
264
|
+
PaginationContext(
|
|
265
|
+
envelope=envelope,
|
|
266
|
+
allow_cursor=True,
|
|
267
|
+
allow_page=False,
|
|
268
|
+
cursor_params=cur,
|
|
269
|
+
page_params=None,
|
|
270
|
+
filters=flt,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
return _inject
|
|
276
|
+
|
|
277
|
+
# Page-only
|
|
278
|
+
if not allow_cursor and allow_page:
|
|
279
|
+
|
|
280
|
+
async def _inject(
|
|
281
|
+
request: Request,
|
|
282
|
+
page: int = Query(1, ge=1),
|
|
283
|
+
page_size: int = Query(default_limit, ge=1, le=max_limit),
|
|
284
|
+
):
|
|
285
|
+
pag = PageParams(page=page, page_size=page_size)
|
|
286
|
+
_pagination_ctx.set(
|
|
287
|
+
PaginationContext(
|
|
288
|
+
envelope=envelope,
|
|
289
|
+
allow_cursor=False,
|
|
290
|
+
allow_page=True,
|
|
291
|
+
cursor_params=None,
|
|
292
|
+
page_params=pag,
|
|
293
|
+
filters=None,
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
return _inject
|
|
299
|
+
|
|
300
|
+
# Both cursor + page (rare; exposes all)
|
|
214
301
|
async def _inject(
|
|
215
302
|
request: Request,
|
|
216
303
|
cursor: str | None = Query(None),
|
|
@@ -226,23 +313,16 @@ def make_pagination_injector(
|
|
|
226
313
|
):
|
|
227
314
|
cur = CursorParams(cursor=cursor, limit=limit) if allow_cursor else None
|
|
228
315
|
pag = PageParams(page=page, page_size=page_size) if allow_page else None
|
|
229
|
-
flt =
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
# detect if 'limit' was explicitly provided
|
|
239
|
-
limit_override = (
|
|
240
|
-
limit
|
|
241
|
-
if (
|
|
242
|
-
"limit" in request.query_params
|
|
243
|
-
and "page_size" not in request.query_params
|
|
244
|
-
and cursor is None
|
|
316
|
+
flt = (
|
|
317
|
+
FilterParams(
|
|
318
|
+
q=q,
|
|
319
|
+
sort=sort,
|
|
320
|
+
created_after=created_after,
|
|
321
|
+
created_before=created_before,
|
|
322
|
+
updated_after=updated_after,
|
|
323
|
+
updated_before=updated_before,
|
|
245
324
|
)
|
|
325
|
+
if include_filters
|
|
246
326
|
else None
|
|
247
327
|
)
|
|
248
328
|
|
|
@@ -254,9 +334,30 @@ def make_pagination_injector(
|
|
|
254
334
|
cursor_params=cur,
|
|
255
335
|
page_params=pag,
|
|
256
336
|
filters=flt,
|
|
257
|
-
limit_override=limit_override,
|
|
258
337
|
)
|
|
259
338
|
)
|
|
260
339
|
return None
|
|
261
340
|
|
|
262
341
|
return _inject
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ----- Convenience helpers for routers -----
|
|
345
|
+
def cursor_pager(
|
|
346
|
+
default_limit: int = 50,
|
|
347
|
+
max_limit: int = 200,
|
|
348
|
+
*,
|
|
349
|
+
envelope: bool = True,
|
|
350
|
+
include_filters: bool = False,
|
|
351
|
+
):
|
|
352
|
+
"""
|
|
353
|
+
The one-liner most routes should use.
|
|
354
|
+
Produces OpenAPI with only: `cursor` and `limit` (plus filters if requested).
|
|
355
|
+
"""
|
|
356
|
+
return make_pagination_injector(
|
|
357
|
+
envelope=envelope,
|
|
358
|
+
allow_cursor=True,
|
|
359
|
+
allow_page=False,
|
|
360
|
+
default_limit=default_limit,
|
|
361
|
+
max_limit=max_limit,
|
|
362
|
+
include_filters=include_filters,
|
|
363
|
+
)
|
|
@@ -14,6 +14,7 @@ router = public_router(tags=["Health Check"])
|
|
|
14
14
|
PING_PATH,
|
|
15
15
|
status_code=status.HTTP_200_OK,
|
|
16
16
|
description="Operation to check if the service is up and running",
|
|
17
|
+
operation_id="health_ping_get",
|
|
17
18
|
)
|
|
18
19
|
def ping():
|
|
19
20
|
logging.info("Health check: /ping endpoint accessed. Service is responsive.")
|
svc_infra/api/fastapi/setup.py
CHANGED
|
@@ -14,9 +14,14 @@ from svc_infra.api.fastapi.docs.landing import CardSpec, DocTargets, render_inde
|
|
|
14
14
|
from svc_infra.api.fastapi.docs.scoped import DOC_SCOPES
|
|
15
15
|
from svc_infra.api.fastapi.middleware.errors.catchall import CatchAllExceptionMiddleware
|
|
16
16
|
from svc_infra.api.fastapi.middleware.errors.handlers import register_error_handlers
|
|
17
|
+
from svc_infra.api.fastapi.middleware.graceful_shutdown import install_graceful_shutdown
|
|
17
18
|
from svc_infra.api.fastapi.middleware.idempotency import IdempotencyMiddleware
|
|
18
19
|
from svc_infra.api.fastapi.middleware.ratelimit import SimpleRateLimitMiddleware
|
|
19
20
|
from svc_infra.api.fastapi.middleware.request_id import RequestIdMiddleware
|
|
21
|
+
from svc_infra.api.fastapi.middleware.timeout import (
|
|
22
|
+
BodyReadTimeoutMiddleware,
|
|
23
|
+
HandlerTimeoutMiddleware,
|
|
24
|
+
)
|
|
20
25
|
from svc_infra.api.fastapi.openapi.models import APIVersionSpec, ServiceInfo
|
|
21
26
|
from svc_infra.api.fastapi.openapi.mutators import setup_mutators
|
|
22
27
|
from svc_infra.api.fastapi.openapi.pipeline import apply_mutators
|
|
@@ -61,7 +66,8 @@ def _setup_cors(app: FastAPI, public_cors_origins: list[str] | str | None = None
|
|
|
61
66
|
elif isinstance(public_cors_origins, str):
|
|
62
67
|
origins = [o.strip() for o in public_cors_origins.split(",") if o and o.strip()]
|
|
63
68
|
else:
|
|
64
|
-
|
|
69
|
+
# Strict by default: no CORS unless explicitly configured via env or parameter.
|
|
70
|
+
fallback = os.getenv("CORS_ALLOW_ORIGINS", "")
|
|
65
71
|
origins = [o.strip() for o in fallback.split(",") if o and o.strip()]
|
|
66
72
|
|
|
67
73
|
if not origins:
|
|
@@ -78,11 +84,16 @@ def _setup_cors(app: FastAPI, public_cors_origins: list[str] | str | None = None
|
|
|
78
84
|
|
|
79
85
|
def _setup_middlewares(app: FastAPI):
|
|
80
86
|
app.add_middleware(RequestIdMiddleware)
|
|
87
|
+
# Timeouts: enforce body read timeout first, then total handler timeout
|
|
88
|
+
app.add_middleware(BodyReadTimeoutMiddleware)
|
|
89
|
+
app.add_middleware(HandlerTimeoutMiddleware)
|
|
81
90
|
app.add_middleware(CatchAllExceptionMiddleware)
|
|
82
91
|
app.add_middleware(IdempotencyMiddleware)
|
|
83
92
|
app.add_middleware(SimpleRateLimitMiddleware)
|
|
84
93
|
register_error_handlers(app)
|
|
85
94
|
_add_route_logger(app)
|
|
95
|
+
# Graceful shutdown: track in-flight and wait on shutdown
|
|
96
|
+
install_graceful_shutdown(app)
|
|
86
97
|
|
|
87
98
|
|
|
88
99
|
def _coerce_list(value: str | Iterable[str] | None) -> list[str]:
|
|
@@ -147,8 +158,7 @@ def _build_parent_app(
|
|
|
147
158
|
root_server_url: str | None = None,
|
|
148
159
|
root_include_api_key: bool = False,
|
|
149
160
|
) -> FastAPI:
|
|
150
|
-
|
|
151
|
-
|
|
161
|
+
# Root docs are now enabled in all environments to match root card visibility
|
|
152
162
|
parent = FastAPI(
|
|
153
163
|
title=service.name,
|
|
154
164
|
version=service.release,
|
|
@@ -156,9 +166,9 @@ def _build_parent_app(
|
|
|
156
166
|
license_info=_dump_or_none(service.license),
|
|
157
167
|
terms_of_service=service.terms_of_service,
|
|
158
168
|
description=service.description,
|
|
159
|
-
docs_url=
|
|
160
|
-
redoc_url=
|
|
161
|
-
openapi_url=
|
|
169
|
+
docs_url="/docs",
|
|
170
|
+
redoc_url="/redoc",
|
|
171
|
+
openapi_url="/openapi.json",
|
|
162
172
|
)
|
|
163
173
|
|
|
164
174
|
_setup_cors(parent, public_cors_origins)
|
|
@@ -231,19 +241,18 @@ def setup_service_api(
|
|
|
231
241
|
mount_path = f"/{spec.tag.strip('/')}"
|
|
232
242
|
parent.mount(mount_path, child, name=spec.tag.strip("/"))
|
|
233
243
|
|
|
234
|
-
@parent.get("/", include_in_schema=False)
|
|
244
|
+
@parent.get("/", include_in_schema=False, response_class=HTMLResponse)
|
|
235
245
|
def index():
|
|
236
246
|
cards: list[CardSpec] = []
|
|
237
247
|
is_local_dev = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
|
|
238
248
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
|
|
245
|
-
)
|
|
249
|
+
# Root card - always show in all environments
|
|
250
|
+
cards.append(
|
|
251
|
+
CardSpec(
|
|
252
|
+
tag="",
|
|
253
|
+
docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
|
|
246
254
|
)
|
|
255
|
+
)
|
|
247
256
|
|
|
248
257
|
# Version cards
|
|
249
258
|
for spec in versions:
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
|
|
7
|
+
from .context import set_tenant_resolver
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_tenancy(app: FastAPI, *, resolver: Optional[Callable[..., Any]] = None) -> None:
|
|
11
|
+
"""Wire tenancy resolver for the application.
|
|
12
|
+
|
|
13
|
+
Provide a resolver(request, identity, header) -> Optional[str] to override
|
|
14
|
+
the default resolution. Pass None to clear a previous override.
|
|
15
|
+
"""
|
|
16
|
+
set_tenant_resolver(resolver)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["add_tenancy"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, HTTPException, Request
|
|
6
|
+
|
|
7
|
+
try: # optional import; auth may not be used by all consumers
|
|
8
|
+
from svc_infra.api.fastapi.auth.security import OptionalIdentity
|
|
9
|
+
except Exception: # pragma: no cover - fallback for minimal builds
|
|
10
|
+
OptionalIdentity = None # type: ignore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_tenant_resolver: Optional[Callable[..., Any]] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_tenant_resolver(
|
|
17
|
+
fn: Optional[Callable[..., Any]],
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Set or clear a global override hook for tenant resolution.
|
|
20
|
+
|
|
21
|
+
The function receives (request, identity, tenant_header) and should return a tenant id
|
|
22
|
+
string or None to fall back to default logic.
|
|
23
|
+
"""
|
|
24
|
+
global _tenant_resolver
|
|
25
|
+
_tenant_resolver = fn
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _maybe_await(x):
|
|
29
|
+
if callable(getattr(x, "__await__", None)):
|
|
30
|
+
return await x # type: ignore[misc]
|
|
31
|
+
return x
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def resolve_tenant_id(
|
|
35
|
+
request: Request,
|
|
36
|
+
tenant_header: Optional[str] = None,
|
|
37
|
+
identity: Any = Depends(OptionalIdentity) if OptionalIdentity else None, # type: ignore[arg-type]
|
|
38
|
+
) -> Optional[str]:
|
|
39
|
+
"""Resolve tenant id from override, identity, header, or request.state.
|
|
40
|
+
|
|
41
|
+
Order:
|
|
42
|
+
1) Global override hook (set_tenant_resolver)
|
|
43
|
+
2) Auth identity: user.tenant_id then api_key.tenant_id (if available)
|
|
44
|
+
3) X-Tenant-Id header
|
|
45
|
+
4) request.state.tenant_id
|
|
46
|
+
"""
|
|
47
|
+
# read header value if not provided directly (supports direct calls without DI)
|
|
48
|
+
if tenant_header is None:
|
|
49
|
+
try:
|
|
50
|
+
tenant_header = request.headers.get("X-Tenant-Id") # type: ignore[assignment]
|
|
51
|
+
except Exception:
|
|
52
|
+
tenant_header = None
|
|
53
|
+
|
|
54
|
+
# 1) global override
|
|
55
|
+
if _tenant_resolver is not None:
|
|
56
|
+
try:
|
|
57
|
+
v = _tenant_resolver(request, identity, tenant_header)
|
|
58
|
+
v2 = await _maybe_await(v)
|
|
59
|
+
if v2:
|
|
60
|
+
return str(v2)
|
|
61
|
+
except Exception:
|
|
62
|
+
# fall through to defaults
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
# 2) from identity
|
|
66
|
+
try:
|
|
67
|
+
if identity and getattr(identity, "user", None) is not None:
|
|
68
|
+
tid = getattr(identity.user, "tenant_id", None)
|
|
69
|
+
if tid:
|
|
70
|
+
return str(tid)
|
|
71
|
+
if identity and getattr(identity, "api_key", None) is not None:
|
|
72
|
+
tid = getattr(identity.api_key, "tenant_id", None)
|
|
73
|
+
if tid:
|
|
74
|
+
return str(tid)
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
# 3) from header
|
|
79
|
+
if tenant_header and isinstance(tenant_header, str) and tenant_header.strip():
|
|
80
|
+
return tenant_header.strip()
|
|
81
|
+
|
|
82
|
+
# 4) request.state
|
|
83
|
+
try:
|
|
84
|
+
st_tid = getattr(getattr(request, "state", object()), "tenant_id", None)
|
|
85
|
+
if st_tid:
|
|
86
|
+
return str(st_tid)
|
|
87
|
+
except Exception:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def require_tenant_id(
|
|
94
|
+
tenant_id: Optional[str] = Depends(resolve_tenant_id),
|
|
95
|
+
) -> str:
|
|
96
|
+
if not tenant_id:
|
|
97
|
+
raise HTTPException(status_code=400, detail="tenant_context_missing")
|
|
98
|
+
return tenant_id
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# DX aliases
|
|
102
|
+
TenantId = Annotated[str, Depends(require_tenant_id)]
|
|
103
|
+
OptionalTenantId = Annotated[Optional[str], Depends(resolve_tenant_id)]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = [
|
|
107
|
+
"TenantId",
|
|
108
|
+
"OptionalTenantId",
|
|
109
|
+
"resolve_tenant_id",
|
|
110
|
+
"require_tenant_id",
|
|
111
|
+
"set_tenant_resolver",
|
|
112
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for capturing routers from add_* functions for versioned routing.
|
|
3
|
+
|
|
4
|
+
This module provides helpers to use integration functions (add_banking, add_payments, etc.)
|
|
5
|
+
under versioned routing without creating separate documentation cards.
|
|
6
|
+
|
|
7
|
+
See: svc-infra/docs/versioned-integrations.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Callable, TypeVar
|
|
13
|
+
from unittest.mock import patch
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, FastAPI
|
|
16
|
+
|
|
17
|
+
__all__ = ["extract_router"]
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def extract_router(
|
|
23
|
+
add_function: Callable[..., T],
|
|
24
|
+
*,
|
|
25
|
+
prefix: str,
|
|
26
|
+
**kwargs: Any,
|
|
27
|
+
) -> tuple[APIRouter, T]:
|
|
28
|
+
"""
|
|
29
|
+
Capture the router from an add_* function for versioned mounting.
|
|
30
|
+
|
|
31
|
+
This allows you to use integration functions like add_banking(), add_payments(),
|
|
32
|
+
etc. under versioned routing (e.g., /v0/banking) without creating separate
|
|
33
|
+
documentation cards.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
add_function: The add_* function to capture from (e.g., add_banking)
|
|
37
|
+
prefix: URL prefix for the routes (e.g., "/banking")
|
|
38
|
+
**kwargs: Arguments to pass to the add_function
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Tuple of (router, return_value) where:
|
|
42
|
+
- router: The captured APIRouter with all routes
|
|
43
|
+
- return_value: The original return value from add_function (e.g., provider instance)
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
```python
|
|
47
|
+
# In routers/v0/banking.py
|
|
48
|
+
from svc_infra.api.fastapi.versioned import extract_router
|
|
49
|
+
from fin_infra.banking import add_banking
|
|
50
|
+
|
|
51
|
+
router, banking_provider = extract_router(
|
|
52
|
+
add_banking,
|
|
53
|
+
prefix="/banking",
|
|
54
|
+
provider="plaid",
|
|
55
|
+
cache_ttl=60,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# svc-infra auto-discovers 'router' and mounts at /v0/banking
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Pattern:
|
|
62
|
+
1. Creates a mock FastAPI app
|
|
63
|
+
2. Intercepts include_router to capture the router
|
|
64
|
+
3. Patches add_prefixed_docs to prevent separate card creation
|
|
65
|
+
4. Calls the add_function which creates all routes
|
|
66
|
+
5. Returns the captured router for auto-discovery
|
|
67
|
+
|
|
68
|
+
See Also:
|
|
69
|
+
- docs/versioned-integrations.md: Full pattern documentation
|
|
70
|
+
- api/fastapi/dual/public.py: Similar pattern for dual routers
|
|
71
|
+
"""
|
|
72
|
+
# Create mock app to capture router
|
|
73
|
+
mock_app = FastAPI()
|
|
74
|
+
captured_router: APIRouter | None = None
|
|
75
|
+
|
|
76
|
+
def _capture_router(router: APIRouter, **_kwargs: Any) -> None:
|
|
77
|
+
"""Intercept include_router to capture instead of mount."""
|
|
78
|
+
nonlocal captured_router
|
|
79
|
+
captured_router = router
|
|
80
|
+
|
|
81
|
+
mock_app.include_router = _capture_router # type: ignore[assignment]
|
|
82
|
+
|
|
83
|
+
# Patch add_prefixed_docs to prevent separate card (no-op if function doesn't call it)
|
|
84
|
+
def _noop_docs(*args: Any, **kwargs: Any) -> None:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
# Call add_function with patches active
|
|
88
|
+
with patch("svc_infra.api.fastapi.docs.scoped.add_prefixed_docs", _noop_docs):
|
|
89
|
+
result = add_function(
|
|
90
|
+
mock_app,
|
|
91
|
+
prefix=prefix,
|
|
92
|
+
**kwargs,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if captured_router is None:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"Failed to capture router from {add_function.__name__}. "
|
|
98
|
+
f"The function may not call app.include_router()."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return captured_router, result
|
svc_infra/app/README.md
CHANGED
|
@@ -14,9 +14,8 @@ This README shows:
|
|
|
14
14
|
|
|
15
15
|
```python
|
|
16
16
|
# main.py (or wherever your app starts)
|
|
17
|
-
from svc_infra.
|
|
17
|
+
from svc_infra.app.logging import setup_logging, LogLevelOptions
|
|
18
18
|
from svc_infra.app.env import pick
|
|
19
|
-
from svc_infra.logging.logging import LogLevelOptions
|
|
20
19
|
```
|
|
21
20
|
|
|
22
21
|
---
|
|
@@ -39,7 +38,8 @@ What you get by default:
|
|
|
39
38
|
Set via code:
|
|
40
39
|
|
|
41
40
|
```python
|
|
42
|
-
from svc_infra.logging.
|
|
41
|
+
from svc_infra.app.logging.formats import LogFormatOptions
|
|
42
|
+
from svc_infra.app.logging import LogLevelOptions
|
|
43
43
|
|
|
44
44
|
setup_logging(
|
|
45
45
|
level=LogLevelOptions.INFO, # or "INFO"
|
|
@@ -119,7 +119,7 @@ Old (pre-filter) example:
|
|
|
119
119
|
|
|
120
120
|
```python
|
|
121
121
|
from svc_infra.app.env import pick
|
|
122
|
-
from svc_infra.
|
|
122
|
+
from svc_infra.app.logging import setup_logging, LogLevelOptions
|
|
123
123
|
|
|
124
124
|
setup_logging(
|
|
125
125
|
level=pick(
|
|
@@ -183,7 +183,7 @@ LOG_DROP_PATHS=/metrics,/health,/healthz
|
|
|
183
183
|
## 7) One-liner quickstart
|
|
184
184
|
|
|
185
185
|
```python
|
|
186
|
-
from svc_infra.logging import setup_logging
|
|
186
|
+
from svc_infra.app.logging import setup_logging
|
|
187
187
|
setup_logging() # done: sensible defaults + filters in prod/test
|
|
188
188
|
```
|
|
189
189
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .models import (
|
|
2
|
+
Invoice,
|
|
3
|
+
InvoiceLine,
|
|
4
|
+
Plan,
|
|
5
|
+
PlanEntitlement,
|
|
6
|
+
Price,
|
|
7
|
+
Subscription,
|
|
8
|
+
UsageAggregate,
|
|
9
|
+
UsageEvent,
|
|
10
|
+
)
|
|
11
|
+
from .service import BillingService
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"UsageEvent",
|
|
15
|
+
"UsageAggregate",
|
|
16
|
+
"Plan",
|
|
17
|
+
"PlanEntitlement",
|
|
18
|
+
"Subscription",
|
|
19
|
+
"Price",
|
|
20
|
+
"Invoice",
|
|
21
|
+
"InvoiceLine",
|
|
22
|
+
"BillingService",
|
|
23
|
+
]
|