svc-infra 0.1.600__py3-none-any.whl → 0.1.664__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.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/setup.py +0 -2
- svc_infra/api/fastapi/auth/add.py +0 -4
- svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
- 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/dependencies/ratelimit.py +57 -7
- 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/ratelimit.py +59 -1
- svc_infra/api/fastapi/middleware/ratelimit_store.py +12 -6
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +114 -0
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +3 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +21 -13
- 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 +28 -8
- svc_infra/cli/cmds/__init__.py +8 -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/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/nosql/mongo/README.md +13 -13
- svc_infra/db/sql/repository.py +51 -11
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/docs/acceptance-matrix.md +88 -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/adr/0012-generic-file-storage.md +498 -0
- svc_infra/docs/api.md +186 -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/storage.md +982 -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/webhook_delivery.py +14 -2
- svc_infra/jobs/queue.py +9 -1
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/worker.py +17 -1
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +6 -2
- svc_infra/security/models.py +27 -7
- svc_infra/security/oauth_models.py +59 -0
- svc_infra/security/permissions.py +1 -0
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +250 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +331 -0
- svc_infra/storage/backends/memory.py +214 -0
- svc_infra/storage/backends/s3.py +329 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +182 -0
- svc_infra/storage/settings.py +192 -0
- svc_infra/webhooks/service.py +10 -2
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/METADATA +45 -14
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/RECORD +140 -52
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/entry_points.txt +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Annotated, Any, Optional, Sequence, Type, TypeVar
|
|
1
|
+
from typing import Annotated, Any, Optional, Sequence, Type, TypeVar
|
|
2
2
|
|
|
3
3
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
|
4
4
|
from pydantic import BaseModel
|
|
@@ -15,7 +15,9 @@ from svc_infra.api.fastapi.db.http import (
|
|
|
15
15
|
)
|
|
16
16
|
from svc_infra.api.fastapi.dual.public import public_router
|
|
17
17
|
from svc_infra.db.sql.service import SqlService
|
|
18
|
+
from svc_infra.db.sql.tenant import TenantSqlService
|
|
18
19
|
|
|
20
|
+
from ...tenancy.context import TenantId
|
|
19
21
|
from .session import SqlSessionDep
|
|
20
22
|
|
|
21
23
|
CreateModel = TypeVar("CreateModel", bound=BaseModel)
|
|
@@ -44,6 +46,18 @@ def make_crud_router_plus_sql(
|
|
|
44
46
|
redirect_slashes=False,
|
|
45
47
|
)
|
|
46
48
|
|
|
49
|
+
def _coerce_id(v: Any) -> Any:
|
|
50
|
+
"""Best-effort coercion of path ids: cast digit-only strings to int.
|
|
51
|
+
|
|
52
|
+
Keeps original type otherwise to avoid breaking non-integer IDs.
|
|
53
|
+
"""
|
|
54
|
+
if isinstance(v, str) and v.isdigit():
|
|
55
|
+
try:
|
|
56
|
+
return int(v)
|
|
57
|
+
except Exception:
|
|
58
|
+
return v
|
|
59
|
+
return v
|
|
60
|
+
|
|
47
61
|
def _parse_ordering_to_fields(order_spec: Optional[str]) -> list[str]:
|
|
48
62
|
if not order_spec:
|
|
49
63
|
return []
|
|
@@ -59,7 +73,7 @@ def make_crud_router_plus_sql(
|
|
|
59
73
|
# -------- LIST --------
|
|
60
74
|
@router.get(
|
|
61
75
|
"",
|
|
62
|
-
response_model=
|
|
76
|
+
response_model=Page[read_schema],
|
|
63
77
|
description=f"List items of type {model.__name__}",
|
|
64
78
|
)
|
|
65
79
|
async def list_items(
|
|
@@ -85,18 +99,16 @@ def make_crud_router_plus_sql(
|
|
|
85
99
|
else:
|
|
86
100
|
items = await service.list(session, limit=lp.limit, offset=lp.offset, order_by=order_by)
|
|
87
101
|
total = await service.count(session)
|
|
88
|
-
return Page[
|
|
89
|
-
total=total, items=items, limit=lp.limit, offset=lp.offset
|
|
90
|
-
)
|
|
102
|
+
return Page[Any].from_items(total=total, items=items, limit=lp.limit, offset=lp.offset)
|
|
91
103
|
|
|
92
104
|
# -------- GET by id --------
|
|
93
105
|
@router.get(
|
|
94
106
|
"/{item_id}",
|
|
95
|
-
response_model=
|
|
107
|
+
response_model=read_schema,
|
|
96
108
|
description=f"Get item of type {model.__name__}",
|
|
97
109
|
)
|
|
98
110
|
async def get_item(item_id: Any, session: SqlSessionDep): # type: ignore[name-defined]
|
|
99
|
-
row = await service.get(session, item_id)
|
|
111
|
+
row = await service.get(session, _coerce_id(item_id))
|
|
100
112
|
if not row:
|
|
101
113
|
raise HTTPException(404, "Not found")
|
|
102
114
|
return row
|
|
@@ -104,7 +116,7 @@ def make_crud_router_plus_sql(
|
|
|
104
116
|
# -------- CREATE --------
|
|
105
117
|
@router.post(
|
|
106
118
|
"",
|
|
107
|
-
response_model=
|
|
119
|
+
response_model=read_schema,
|
|
108
120
|
status_code=201,
|
|
109
121
|
description=f"Create item of type {model.__name__}",
|
|
110
122
|
)
|
|
@@ -112,13 +124,18 @@ def make_crud_router_plus_sql(
|
|
|
112
124
|
session: SqlSessionDep, # type: ignore[name-defined]
|
|
113
125
|
payload: create_schema = Body(...),
|
|
114
126
|
):
|
|
115
|
-
|
|
127
|
+
if isinstance(payload, BaseModel):
|
|
128
|
+
data = payload.model_dump(exclude_unset=True)
|
|
129
|
+
elif isinstance(payload, dict):
|
|
130
|
+
data = payload
|
|
131
|
+
else:
|
|
132
|
+
raise HTTPException(422, "invalid_payload")
|
|
116
133
|
return await service.create(session, data)
|
|
117
134
|
|
|
118
135
|
# -------- UPDATE --------
|
|
119
136
|
@router.patch(
|
|
120
137
|
"/{item_id}",
|
|
121
|
-
response_model=
|
|
138
|
+
response_model=read_schema,
|
|
122
139
|
description=f"Update item of type {model.__name__}",
|
|
123
140
|
)
|
|
124
141
|
async def update_item(
|
|
@@ -126,8 +143,13 @@ def make_crud_router_plus_sql(
|
|
|
126
143
|
session: SqlSessionDep, # type: ignore[name-defined]
|
|
127
144
|
payload: update_schema = Body(...),
|
|
128
145
|
):
|
|
129
|
-
|
|
130
|
-
|
|
146
|
+
if isinstance(payload, BaseModel):
|
|
147
|
+
data = payload.model_dump(exclude_unset=True)
|
|
148
|
+
elif isinstance(payload, dict):
|
|
149
|
+
data = payload
|
|
150
|
+
else:
|
|
151
|
+
raise HTTPException(422, "invalid_payload")
|
|
152
|
+
row = await service.update(session, _coerce_id(item_id), data)
|
|
131
153
|
if not row:
|
|
132
154
|
raise HTTPException(404, "Not found")
|
|
133
155
|
return row
|
|
@@ -137,7 +159,147 @@ def make_crud_router_plus_sql(
|
|
|
137
159
|
"/{item_id}", status_code=204, description=f"Delete item of type {model.__name__}"
|
|
138
160
|
)
|
|
139
161
|
async def delete_item(item_id: Any, session: SqlSessionDep): # type: ignore[name-defined]
|
|
140
|
-
ok = await service.delete(session, item_id)
|
|
162
|
+
ok = await service.delete(session, _coerce_id(item_id))
|
|
163
|
+
if not ok:
|
|
164
|
+
raise HTTPException(404, "Not found")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
return router
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def make_tenant_crud_router_plus_sql(
|
|
171
|
+
*,
|
|
172
|
+
model: type[Any],
|
|
173
|
+
service_factory: callable, # factory that returns a SqlService (will be wrapped)
|
|
174
|
+
read_schema: Type[ReadModel],
|
|
175
|
+
create_schema: Type[CreateModel],
|
|
176
|
+
update_schema: Type[UpdateModel],
|
|
177
|
+
prefix: str,
|
|
178
|
+
tenant_field: str = "tenant_id",
|
|
179
|
+
tags: list[str] | None = None,
|
|
180
|
+
search_fields: Optional[Sequence[str]] = None,
|
|
181
|
+
default_ordering: Optional[str] = None,
|
|
182
|
+
allowed_order_fields: Optional[list[str]] = None,
|
|
183
|
+
mount_under_db_prefix: bool = True,
|
|
184
|
+
) -> APIRouter:
|
|
185
|
+
"""Like make_crud_router_plus_sql, but requires TenantId and scopes all operations."""
|
|
186
|
+
router_prefix = ("/_sql" + prefix) if mount_under_db_prefix else prefix
|
|
187
|
+
router = public_router(
|
|
188
|
+
prefix=router_prefix,
|
|
189
|
+
tags=tags or [prefix.strip("/")],
|
|
190
|
+
redirect_slashes=False,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Evaluate the base service once to preserve in-memory state across requests in tests/local.
|
|
194
|
+
# Consumers may pass either an instance or a zero-arg factory function.
|
|
195
|
+
try:
|
|
196
|
+
_base_instance = service_factory() if callable(service_factory) else service_factory # type: ignore[misc]
|
|
197
|
+
except TypeError:
|
|
198
|
+
# If the callable requires args, assume it's already an instance
|
|
199
|
+
_base_instance = service_factory # type: ignore[assignment]
|
|
200
|
+
|
|
201
|
+
def _coerce_id(v: Any) -> Any:
|
|
202
|
+
"""Best-effort coercion of path ids: cast digit-only strings to int.
|
|
203
|
+
Keeps original type otherwise.
|
|
204
|
+
"""
|
|
205
|
+
if isinstance(v, str) and v.isdigit():
|
|
206
|
+
try:
|
|
207
|
+
return int(v)
|
|
208
|
+
except Exception:
|
|
209
|
+
return v
|
|
210
|
+
return v
|
|
211
|
+
|
|
212
|
+
def _parse_ordering_to_fields(order_spec: Optional[str]) -> list[str]:
|
|
213
|
+
if not order_spec:
|
|
214
|
+
return []
|
|
215
|
+
pieces = [p.strip() for p in order_spec.split(",") if p.strip()]
|
|
216
|
+
fields: list[str] = []
|
|
217
|
+
for p in pieces:
|
|
218
|
+
name = p[1:] if p.startswith("-") else p
|
|
219
|
+
if allowed_order_fields and name not in (allowed_order_fields or []):
|
|
220
|
+
continue
|
|
221
|
+
fields.append(p)
|
|
222
|
+
return fields
|
|
223
|
+
|
|
224
|
+
# create per-request service with tenant scoping
|
|
225
|
+
async def _svc(session: SqlSessionDep, tenant_id: TenantId): # type: ignore[name-defined]
|
|
226
|
+
repo_or_service = getattr(_base_instance, "repo", _base_instance)
|
|
227
|
+
svc: Any = TenantSqlService(repo_or_service, tenant_id=tenant_id, tenant_field=tenant_field) # type: ignore[arg-type]
|
|
228
|
+
return svc # type: ignore[return-value]
|
|
229
|
+
|
|
230
|
+
@router.get("", response_model=Page[read_schema])
|
|
231
|
+
async def list_items(
|
|
232
|
+
lp: Annotated[LimitOffsetParams, Depends(dep_limit_offset)],
|
|
233
|
+
op: Annotated[OrderParams, Depends(dep_order)],
|
|
234
|
+
sp: Annotated[SearchParams, Depends(dep_search)],
|
|
235
|
+
session: SqlSessionDep, # type: ignore[name-defined]
|
|
236
|
+
tenant_id: TenantId,
|
|
237
|
+
):
|
|
238
|
+
svc = await _svc(session, tenant_id)
|
|
239
|
+
order_spec = op.order_by or default_ordering
|
|
240
|
+
order_fields = _parse_ordering_to_fields(order_spec)
|
|
241
|
+
order_by = build_order_by(model, order_fields)
|
|
242
|
+
if sp.q:
|
|
243
|
+
fields = [
|
|
244
|
+
f.strip()
|
|
245
|
+
for f in (sp.fields or (",".join(search_fields or []) or "")).split(",")
|
|
246
|
+
if f.strip()
|
|
247
|
+
]
|
|
248
|
+
items = await svc.search(
|
|
249
|
+
session, q=sp.q, fields=fields, limit=lp.limit, offset=lp.offset, order_by=order_by
|
|
250
|
+
)
|
|
251
|
+
total = await svc.count_filtered(session, q=sp.q, fields=fields)
|
|
252
|
+
else:
|
|
253
|
+
items = await svc.list(session, limit=lp.limit, offset=lp.offset, order_by=order_by)
|
|
254
|
+
total = await svc.count(session)
|
|
255
|
+
return Page[Any].from_items(total=total, items=items, limit=lp.limit, offset=lp.offset)
|
|
256
|
+
|
|
257
|
+
@router.get("/{item_id}", response_model=read_schema)
|
|
258
|
+
async def get_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId): # type: ignore[name-defined]
|
|
259
|
+
svc = await _svc(session, tenant_id)
|
|
260
|
+
obj = await svc.get(session, item_id)
|
|
261
|
+
if not obj:
|
|
262
|
+
raise HTTPException(404, "not_found")
|
|
263
|
+
return obj
|
|
264
|
+
|
|
265
|
+
@router.post("", response_model=read_schema, status_code=201)
|
|
266
|
+
async def create_item(
|
|
267
|
+
session: SqlSessionDep, # type: ignore[name-defined]
|
|
268
|
+
tenant_id: TenantId,
|
|
269
|
+
payload: create_schema = Body(...),
|
|
270
|
+
):
|
|
271
|
+
svc = await _svc(session, tenant_id)
|
|
272
|
+
if isinstance(payload, BaseModel):
|
|
273
|
+
data = payload.model_dump(exclude_unset=True)
|
|
274
|
+
elif isinstance(payload, dict):
|
|
275
|
+
data = payload
|
|
276
|
+
else:
|
|
277
|
+
raise HTTPException(422, "invalid_payload")
|
|
278
|
+
return await svc.create(session, data)
|
|
279
|
+
|
|
280
|
+
@router.patch("/{item_id}", response_model=read_schema)
|
|
281
|
+
async def update_item(
|
|
282
|
+
item_id: Any,
|
|
283
|
+
session: SqlSessionDep, # type: ignore[name-defined]
|
|
284
|
+
tenant_id: TenantId,
|
|
285
|
+
payload: update_schema = Body(...),
|
|
286
|
+
):
|
|
287
|
+
svc = await _svc(session, tenant_id)
|
|
288
|
+
if isinstance(payload, BaseModel):
|
|
289
|
+
data = payload.model_dump(exclude_unset=True)
|
|
290
|
+
elif isinstance(payload, dict):
|
|
291
|
+
data = payload
|
|
292
|
+
else:
|
|
293
|
+
raise HTTPException(422, "invalid_payload")
|
|
294
|
+
updated = await svc.update(session, item_id, data)
|
|
295
|
+
if not updated:
|
|
296
|
+
raise HTTPException(404, "not_found")
|
|
297
|
+
return updated
|
|
298
|
+
|
|
299
|
+
@router.delete("/{item_id}", status_code=204)
|
|
300
|
+
async def delete_item(item_id: Any, session: SqlSessionDep, tenant_id: TenantId): # type: ignore[name-defined]
|
|
301
|
+
svc = await _svc(session, tenant_id)
|
|
302
|
+
ok = await svc.delete(session, _coerce_id(item_id))
|
|
141
303
|
if not ok:
|
|
142
304
|
raise HTTPException(404, "Not found")
|
|
143
305
|
return
|
|
@@ -145,4 +307,4 @@ def make_crud_router_plus_sql(
|
|
|
145
307
|
return router
|
|
146
308
|
|
|
147
309
|
|
|
148
|
-
__all__ = ["make_crud_router_plus_sql"]
|
|
310
|
+
__all__ = ["make_crud_router_plus_sql", "make_tenant_crud_router_plus_sql"]
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
import os
|
|
4
5
|
from typing import Annotated, AsyncIterator, Tuple
|
|
5
6
|
|
|
6
7
|
from fastapi import Depends
|
|
8
|
+
from sqlalchemy import text
|
|
7
9
|
from sqlalchemy.ext.asyncio import (
|
|
8
10
|
AsyncEngine,
|
|
9
11
|
AsyncSession,
|
|
@@ -53,6 +55,20 @@ async def get_session() -> AsyncIterator[AsyncSession]:
|
|
|
53
55
|
if _SessionLocal is None:
|
|
54
56
|
raise RuntimeError("Database not initialized. Call add_sql_db(app, ...) first.")
|
|
55
57
|
async with _SessionLocal() as session:
|
|
58
|
+
# Optional: set a per-transaction statement timeout for Postgres if configured
|
|
59
|
+
raw_ms = os.getenv("DB_STATEMENT_TIMEOUT_MS")
|
|
60
|
+
if raw_ms:
|
|
61
|
+
try:
|
|
62
|
+
ms = int(raw_ms)
|
|
63
|
+
if ms > 0:
|
|
64
|
+
try:
|
|
65
|
+
# SET LOCAL applies for the duration of the current transaction only
|
|
66
|
+
await session.execute(text("SET LOCAL statement_timeout = :ms"), {"ms": ms})
|
|
67
|
+
except Exception:
|
|
68
|
+
# Non-PG dialects (e.g., SQLite) will error; ignore silently
|
|
69
|
+
pass
|
|
70
|
+
except ValueError:
|
|
71
|
+
pass
|
|
56
72
|
try:
|
|
57
73
|
yield session
|
|
58
74
|
await session.commit()
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import time
|
|
4
|
-
from typing import Callable
|
|
4
|
+
from typing import Callable, Optional
|
|
5
5
|
|
|
6
6
|
from fastapi import HTTPException
|
|
7
7
|
from starlette.requests import Request
|
|
8
8
|
|
|
9
9
|
from svc_infra.api.fastapi.middleware.ratelimit_store import InMemoryRateLimitStore, RateLimitStore
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from svc_infra.api.fastapi.tenancy.context import resolve_tenant_id as _resolve_tenant_id
|
|
13
|
+
except Exception: # pragma: no cover - minimal builds
|
|
14
|
+
_resolve_tenant_id = None # type: ignore
|
|
10
15
|
from svc_infra.obs.metrics import emit_rate_limited
|
|
11
16
|
|
|
12
17
|
|
|
@@ -17,20 +22,44 @@ class RateLimiter:
|
|
|
17
22
|
limit: int,
|
|
18
23
|
window: int = 60,
|
|
19
24
|
key_fn: Callable = lambda r: "global",
|
|
25
|
+
limit_resolver: Optional[Callable[[Request, Optional[str]], Optional[int]]] = None,
|
|
26
|
+
scope_by_tenant: bool = False,
|
|
20
27
|
store: RateLimitStore | None = None,
|
|
21
28
|
):
|
|
22
29
|
self.limit = limit
|
|
23
30
|
self.window = window
|
|
24
31
|
self.key_fn = key_fn
|
|
32
|
+
self._limit_resolver = limit_resolver
|
|
33
|
+
self.scope_by_tenant = scope_by_tenant
|
|
25
34
|
self.store = store or InMemoryRateLimitStore(limit=limit)
|
|
26
35
|
|
|
27
36
|
async def __call__(self, request: Request):
|
|
37
|
+
# Try resolving tenant when asked
|
|
38
|
+
tenant_id = None
|
|
39
|
+
if self.scope_by_tenant or self._limit_resolver:
|
|
40
|
+
try:
|
|
41
|
+
if _resolve_tenant_id is not None:
|
|
42
|
+
tenant_id = await _resolve_tenant_id(request)
|
|
43
|
+
except Exception:
|
|
44
|
+
tenant_id = None
|
|
45
|
+
|
|
28
46
|
key = self.key_fn(request)
|
|
29
|
-
|
|
30
|
-
|
|
47
|
+
if self.scope_by_tenant and tenant_id:
|
|
48
|
+
key = f"{key}:tenant:{tenant_id}"
|
|
49
|
+
|
|
50
|
+
eff_limit = self.limit
|
|
51
|
+
if self._limit_resolver:
|
|
52
|
+
try:
|
|
53
|
+
v = self._limit_resolver(request, tenant_id)
|
|
54
|
+
eff_limit = int(v) if v is not None else self.limit
|
|
55
|
+
except Exception:
|
|
56
|
+
eff_limit = self.limit
|
|
57
|
+
|
|
58
|
+
count, store_limit, reset = self.store.incr(str(key), self.window)
|
|
59
|
+
if count > eff_limit:
|
|
31
60
|
retry = max(0, reset - int(time.time()))
|
|
32
61
|
try:
|
|
33
|
-
emit_rate_limited(str(key),
|
|
62
|
+
emit_rate_limited(str(key), eff_limit, retry)
|
|
34
63
|
except Exception:
|
|
35
64
|
pass
|
|
36
65
|
raise HTTPException(
|
|
@@ -46,17 +75,38 @@ def rate_limiter(
|
|
|
46
75
|
limit: int,
|
|
47
76
|
window: int = 60,
|
|
48
77
|
key_fn: Callable = lambda r: "global",
|
|
78
|
+
limit_resolver: Optional[Callable[[Request, Optional[str]], Optional[int]]] = None,
|
|
79
|
+
scope_by_tenant: bool = False,
|
|
49
80
|
store: RateLimitStore | None = None,
|
|
50
81
|
):
|
|
51
82
|
store_ = store or InMemoryRateLimitStore(limit=limit)
|
|
52
83
|
|
|
53
84
|
async def dep(request: Request):
|
|
85
|
+
tenant_id = None
|
|
86
|
+
if scope_by_tenant or limit_resolver:
|
|
87
|
+
try:
|
|
88
|
+
if _resolve_tenant_id is not None:
|
|
89
|
+
tenant_id = await _resolve_tenant_id(request)
|
|
90
|
+
except Exception:
|
|
91
|
+
tenant_id = None
|
|
92
|
+
|
|
54
93
|
key = key_fn(request)
|
|
55
|
-
|
|
56
|
-
|
|
94
|
+
if scope_by_tenant and tenant_id:
|
|
95
|
+
key = f"{key}:tenant:{tenant_id}"
|
|
96
|
+
|
|
97
|
+
eff_limit = limit
|
|
98
|
+
if limit_resolver:
|
|
99
|
+
try:
|
|
100
|
+
v = limit_resolver(request, tenant_id)
|
|
101
|
+
eff_limit = int(v) if v is not None else limit
|
|
102
|
+
except Exception:
|
|
103
|
+
eff_limit = limit
|
|
104
|
+
|
|
105
|
+
count, _store_limit, reset = store_.incr(str(key), window)
|
|
106
|
+
if count > eff_limit:
|
|
57
107
|
retry = max(0, reset - int(time.time()))
|
|
58
108
|
try:
|
|
59
|
-
emit_rate_limited(str(key),
|
|
109
|
+
emit_rate_limited(str(key), eff_limit, retry)
|
|
60
110
|
except Exception:
|
|
61
111
|
pass
|
|
62
112
|
raise HTTPException(
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, Request
|
|
8
|
+
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
|
9
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
10
|
+
|
|
11
|
+
from .landing import CardSpec, DocTargets, render_index_html
|
|
12
|
+
from .scoped import DOC_SCOPES
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def add_docs(
|
|
16
|
+
app: FastAPI,
|
|
17
|
+
*,
|
|
18
|
+
redoc_url: str = "/redoc",
|
|
19
|
+
swagger_url: str = "/docs",
|
|
20
|
+
openapi_url: str = "/openapi.json",
|
|
21
|
+
export_openapi_to: Optional[str] = None,
|
|
22
|
+
# Landing page options
|
|
23
|
+
landing_url: str = "/",
|
|
24
|
+
include_landing: bool = True,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Enable docs endpoints and optionally export OpenAPI schema to disk on startup.
|
|
27
|
+
|
|
28
|
+
We mount docs and OpenAPI routes explicitly so this works even when configured post-init.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# OpenAPI JSON route
|
|
32
|
+
async def openapi_handler() -> JSONResponse: # noqa: ANN201
|
|
33
|
+
return JSONResponse(app.openapi())
|
|
34
|
+
|
|
35
|
+
app.add_api_route(openapi_url, openapi_handler, methods=["GET"], include_in_schema=False)
|
|
36
|
+
|
|
37
|
+
# Swagger UI route
|
|
38
|
+
async def swagger_ui(request: Request) -> HTMLResponse: # noqa: ANN201
|
|
39
|
+
resp = get_swagger_ui_html(openapi_url=openapi_url, title="API Docs")
|
|
40
|
+
theme = request.query_params.get("theme")
|
|
41
|
+
if theme == "dark":
|
|
42
|
+
return _with_dark_mode(resp)
|
|
43
|
+
return resp
|
|
44
|
+
|
|
45
|
+
app.add_api_route(swagger_url, swagger_ui, methods=["GET"], include_in_schema=False)
|
|
46
|
+
|
|
47
|
+
# Redoc route
|
|
48
|
+
async def redoc_ui(request: Request) -> HTMLResponse: # noqa: ANN201
|
|
49
|
+
resp = get_redoc_html(openapi_url=openapi_url, title="API ReDoc")
|
|
50
|
+
theme = request.query_params.get("theme")
|
|
51
|
+
if theme == "dark":
|
|
52
|
+
return _with_dark_mode(resp)
|
|
53
|
+
return resp
|
|
54
|
+
|
|
55
|
+
app.add_api_route(redoc_url, redoc_ui, methods=["GET"], include_in_schema=False)
|
|
56
|
+
|
|
57
|
+
# Optional export to disk on startup
|
|
58
|
+
if export_openapi_to:
|
|
59
|
+
export_path = Path(export_openapi_to)
|
|
60
|
+
|
|
61
|
+
async def _export_docs() -> None:
|
|
62
|
+
# Startup export
|
|
63
|
+
spec = app.openapi()
|
|
64
|
+
export_path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
export_path.write_text(json.dumps(spec, indent=2))
|
|
66
|
+
|
|
67
|
+
app.add_event_handler("startup", _export_docs)
|
|
68
|
+
|
|
69
|
+
# Optional landing page with the same look/feel as setup_service_api
|
|
70
|
+
if include_landing:
|
|
71
|
+
# Avoid path collision; if landing_url is already taken for GET, fallback to "/_docs"
|
|
72
|
+
existing_paths = {
|
|
73
|
+
(getattr(r, "path", None) or getattr(r, "path_format", None))
|
|
74
|
+
for r in getattr(app, "routes", [])
|
|
75
|
+
if getattr(r, "methods", None) and "GET" in r.methods
|
|
76
|
+
}
|
|
77
|
+
landing_path = landing_url or "/"
|
|
78
|
+
if landing_path in existing_paths:
|
|
79
|
+
landing_path = "/_docs"
|
|
80
|
+
|
|
81
|
+
async def _landing() -> HTMLResponse: # noqa: ANN201
|
|
82
|
+
cards: list[CardSpec] = []
|
|
83
|
+
# Root docs card using the provided paths
|
|
84
|
+
cards.append(
|
|
85
|
+
CardSpec(
|
|
86
|
+
tag="",
|
|
87
|
+
docs=DocTargets(swagger=swagger_url, redoc=redoc_url, openapi_json=openapi_url),
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
# Scoped docs (if any were registered via add_prefixed_docs)
|
|
91
|
+
for scope, swagger, redoc, openapi_json, _title in DOC_SCOPES:
|
|
92
|
+
cards.append(
|
|
93
|
+
CardSpec(
|
|
94
|
+
tag=scope.strip("/"),
|
|
95
|
+
docs=DocTargets(swagger=swagger, redoc=redoc, openapi_json=openapi_json),
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
html = render_index_html(
|
|
99
|
+
service_name=app.title or "API", release=app.version or "", cards=cards
|
|
100
|
+
)
|
|
101
|
+
return HTMLResponse(html)
|
|
102
|
+
|
|
103
|
+
app.add_api_route(landing_path, _landing, methods=["GET"], include_in_schema=False)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _with_dark_mode(resp: HTMLResponse) -> HTMLResponse:
|
|
107
|
+
"""Return a copy of the HTMLResponse with a minimal dark-theme CSS injected.
|
|
108
|
+
|
|
109
|
+
We avoid depending on custom Swagger/ReDoc builds; this works by inlining a small CSS
|
|
110
|
+
block and toggling a `.dark` class on the body element.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
body = resp.body.decode("utf-8", errors="ignore")
|
|
114
|
+
except Exception: # pragma: no cover - very unlikely
|
|
115
|
+
return resp
|
|
116
|
+
|
|
117
|
+
css = _DARK_CSS
|
|
118
|
+
if "</head>" in body:
|
|
119
|
+
body = body.replace("</head>", f"<style>\n{css}\n</style></head>", 1)
|
|
120
|
+
# add class to body to allow stronger selectors
|
|
121
|
+
body = body.replace("<body>", '<body class="dark">', 1)
|
|
122
|
+
return HTMLResponse(content=body, status_code=resp.status_code, headers=dict(resp.headers))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_DARK_CSS = """
|
|
126
|
+
/* Minimal dark mode override for Swagger/ReDoc */
|
|
127
|
+
@media (prefers-color-scheme: dark) { :root { color-scheme: dark; } }
|
|
128
|
+
html.dark, body.dark { background: #0b0e14; color: #e0e6f1; }
|
|
129
|
+
#swagger, .redoc-wrap { background: transparent; }
|
|
130
|
+
a { color: #62aef7; }
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def add_sdk_generation_stub(
|
|
135
|
+
app: FastAPI,
|
|
136
|
+
*,
|
|
137
|
+
on_generate: Optional[callable] = None,
|
|
138
|
+
openapi_path: str = "/openapi.json",
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Hook to add an SDK generation stub.
|
|
141
|
+
|
|
142
|
+
Provide `on_generate()` to run generation (e.g., openapi-generator). This is a stub only; we
|
|
143
|
+
don't ship a hard dependency. If `on_generate` is provided, we expose `/_docs/generate-sdk`.
|
|
144
|
+
"""
|
|
145
|
+
from svc_infra.api.fastapi.dual.public import public_router
|
|
146
|
+
|
|
147
|
+
if not on_generate:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
router = public_router(prefix="/_docs", include_in_schema=False)
|
|
151
|
+
|
|
152
|
+
@router.post("/generate-sdk")
|
|
153
|
+
async def _generate() -> dict: # noqa: ANN201
|
|
154
|
+
on_generate()
|
|
155
|
+
return {"status": "ok"}
|
|
156
|
+
|
|
157
|
+
app.include_router(router)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
__all__ = ["add_docs", "add_sdk_generation_stub"]
|
|
@@ -115,7 +115,7 @@ def render_index_html(*, service_name: str, release: str, cards: Iterable[CardSp
|
|
|
115
115
|
<section class="grid">
|
|
116
116
|
{grid}
|
|
117
117
|
</section>
|
|
118
|
-
<footer>Tip: each card exposes Swagger, ReDoc, and a
|
|
118
|
+
<footer>Tip: each card exposes Swagger, ReDoc, and a JSON view.</footer>
|
|
119
119
|
</div>
|
|
120
120
|
</body>
|
|
121
121
|
</html>
|
|
@@ -65,11 +65,18 @@ def _close_over_component_refs(
|
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
def _prune_to_paths(
|
|
68
|
-
full_schema: Dict,
|
|
68
|
+
full_schema: Dict,
|
|
69
|
+
keep_paths: Dict[str, dict],
|
|
70
|
+
title_suffix: Optional[str],
|
|
71
|
+
server_prefix: Optional[str] = None,
|
|
69
72
|
) -> Dict:
|
|
70
73
|
schema = copy.deepcopy(full_schema)
|
|
71
74
|
schema["paths"] = keep_paths
|
|
72
75
|
|
|
76
|
+
# Set server URL for scoped docs
|
|
77
|
+
if server_prefix is not None:
|
|
78
|
+
schema["servers"] = [{"url": server_prefix}]
|
|
79
|
+
|
|
73
80
|
used_tags: Set[str] = set()
|
|
74
81
|
direct_refs: Set[Tuple[str, str]] = set()
|
|
75
82
|
used_security_schemes: Set[str] = set()
|
|
@@ -124,7 +131,26 @@ def _build_filtered_schema(
|
|
|
124
131
|
keep_paths = {
|
|
125
132
|
p: v for p, v in paths.items() if _path_included(p, include_prefixes, exclude_prefixes)
|
|
126
133
|
}
|
|
127
|
-
|
|
134
|
+
|
|
135
|
+
# Determine the server prefix for scoped docs
|
|
136
|
+
server_prefix = None
|
|
137
|
+
if include_prefixes and len(include_prefixes) == 1:
|
|
138
|
+
# Single include prefix = scoped docs
|
|
139
|
+
server_prefix = include_prefixes[0].rstrip("/") or "/"
|
|
140
|
+
|
|
141
|
+
# Strip prefix from paths to make them relative to the server
|
|
142
|
+
stripped_paths = {}
|
|
143
|
+
for path, spec in keep_paths.items():
|
|
144
|
+
if path.startswith(server_prefix) and path != server_prefix:
|
|
145
|
+
# Remove prefix, keeping the leading slash
|
|
146
|
+
relative_path = path[len(server_prefix) :]
|
|
147
|
+
stripped_paths[relative_path] = spec
|
|
148
|
+
else:
|
|
149
|
+
# Path equals prefix or doesn't start with it
|
|
150
|
+
stripped_paths[path] = spec
|
|
151
|
+
keep_paths = stripped_paths
|
|
152
|
+
|
|
153
|
+
return _prune_to_paths(full_schema, keep_paths, title_suffix, server_prefix=server_prefix)
|
|
128
154
|
|
|
129
155
|
|
|
130
156
|
def _ensure_original_openapi_saved(app: FastAPI) -> None:
|
|
@@ -175,11 +201,23 @@ def add_prefixed_docs(
|
|
|
175
201
|
auto_exclude_from_root: bool = True,
|
|
176
202
|
visible_envs: Optional[Iterable[Environment | str]] = (LOCAL_ENV, DEV_ENV),
|
|
177
203
|
) -> None:
|
|
204
|
+
scope = prefix.rstrip("/") or "/"
|
|
205
|
+
|
|
206
|
+
# Always exclude from root if requested, regardless of environment
|
|
207
|
+
if auto_exclude_from_root:
|
|
208
|
+
_ensure_original_openapi_saved(app)
|
|
209
|
+
# Add to exclusion list for root docs
|
|
210
|
+
if not hasattr(app.state, "_scoped_root_exclusions"):
|
|
211
|
+
app.state._scoped_root_exclusions = []
|
|
212
|
+
if scope not in app.state._scoped_root_exclusions:
|
|
213
|
+
app.state._scoped_root_exclusions.append(scope)
|
|
214
|
+
_install_root_filter(app, app.state._scoped_root_exclusions)
|
|
215
|
+
|
|
216
|
+
# Only create scoped docs in allowed environments
|
|
178
217
|
allow = _normalize_envs(visible_envs)
|
|
179
218
|
if allow is not None and CURRENT_ENVIRONMENT not in allow:
|
|
180
219
|
return
|
|
181
220
|
|
|
182
|
-
scope = prefix.rstrip("/") or "/"
|
|
183
221
|
openapi_path = f"{scope}/openapi.json"
|
|
184
222
|
swagger_path = f"{scope}/docs"
|
|
185
223
|
redoc_path = f"{scope}/redoc"
|
|
@@ -211,9 +249,6 @@ def add_prefixed_docs(
|
|
|
211
249
|
|
|
212
250
|
DOC_SCOPES.append((scope, swagger_path, redoc_path, openapi_path, title))
|
|
213
251
|
|
|
214
|
-
if auto_exclude_from_root:
|
|
215
|
-
_ensure_root_excludes_registered_scopes(app)
|
|
216
|
-
|
|
217
252
|
|
|
218
253
|
def replace_root_openapi_with_exclusions(app: FastAPI, *, exclude_prefixes: List[str]) -> None:
|
|
219
254
|
_install_root_filter(app, exclude_prefixes)
|