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.

Files changed (140) hide show
  1. svc_infra/api/fastapi/admin/__init__.py +3 -0
  2. svc_infra/api/fastapi/admin/add.py +231 -0
  3. svc_infra/api/fastapi/apf_payments/setup.py +0 -2
  4. svc_infra/api/fastapi/auth/add.py +0 -4
  5. svc_infra/api/fastapi/auth/routers/oauth_router.py +19 -4
  6. svc_infra/api/fastapi/billing/router.py +64 -0
  7. svc_infra/api/fastapi/billing/setup.py +19 -0
  8. svc_infra/api/fastapi/cache/add.py +9 -5
  9. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  10. svc_infra/api/fastapi/db/sql/add.py +40 -18
  11. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  12. svc_infra/api/fastapi/db/sql/session.py +16 -0
  13. svc_infra/api/fastapi/dependencies/ratelimit.py +57 -7
  14. svc_infra/api/fastapi/docs/add.py +160 -0
  15. svc_infra/api/fastapi/docs/landing.py +1 -1
  16. svc_infra/api/fastapi/docs/scoped.py +41 -6
  17. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  18. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  19. svc_infra/api/fastapi/middleware/ratelimit.py +59 -1
  20. svc_infra/api/fastapi/middleware/ratelimit_store.py +12 -6
  21. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  22. svc_infra/api/fastapi/openapi/mutators.py +114 -0
  23. svc_infra/api/fastapi/ops/add.py +73 -0
  24. svc_infra/api/fastapi/pagination.py +3 -1
  25. svc_infra/api/fastapi/routers/ping.py +1 -0
  26. svc_infra/api/fastapi/setup.py +21 -13
  27. svc_infra/api/fastapi/tenancy/add.py +19 -0
  28. svc_infra/api/fastapi/tenancy/context.py +112 -0
  29. svc_infra/api/fastapi/versioned.py +101 -0
  30. svc_infra/app/README.md +5 -5
  31. svc_infra/billing/__init__.py +23 -0
  32. svc_infra/billing/async_service.py +147 -0
  33. svc_infra/billing/jobs.py +230 -0
  34. svc_infra/billing/models.py +131 -0
  35. svc_infra/billing/quotas.py +101 -0
  36. svc_infra/billing/schemas.py +33 -0
  37. svc_infra/billing/service.py +115 -0
  38. svc_infra/bundled_docs/README.md +5 -0
  39. svc_infra/bundled_docs/__init__.py +1 -0
  40. svc_infra/bundled_docs/getting-started.md +6 -0
  41. svc_infra/cache/__init__.py +4 -0
  42. svc_infra/cache/add.py +158 -0
  43. svc_infra/cache/backend.py +5 -2
  44. svc_infra/cache/decorators.py +19 -1
  45. svc_infra/cache/keys.py +24 -4
  46. svc_infra/cli/__init__.py +28 -8
  47. svc_infra/cli/cmds/__init__.py +8 -0
  48. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  49. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  50. svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
  51. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  52. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  53. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  54. svc_infra/cli/cmds/dx/__init__.py +12 -0
  55. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  56. svc_infra/cli/cmds/help.py +4 -0
  57. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  58. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  59. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  60. svc_infra/data/add.py +61 -0
  61. svc_infra/data/backup.py +53 -0
  62. svc_infra/data/erasure.py +45 -0
  63. svc_infra/data/fixtures.py +40 -0
  64. svc_infra/data/retention.py +55 -0
  65. svc_infra/db/nosql/mongo/README.md +13 -13
  66. svc_infra/db/sql/repository.py +51 -11
  67. svc_infra/db/sql/resource.py +5 -0
  68. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  69. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  70. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  71. svc_infra/db/sql/tenant.py +79 -0
  72. svc_infra/db/sql/utils.py +18 -4
  73. svc_infra/docs/acceptance-matrix.md +88 -0
  74. svc_infra/docs/acceptance.md +44 -0
  75. svc_infra/docs/admin.md +425 -0
  76. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  77. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  78. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  79. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  80. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  81. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  82. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  83. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  84. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  85. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  86. svc_infra/docs/adr/0012-generic-file-storage.md +498 -0
  87. svc_infra/docs/api.md +186 -0
  88. svc_infra/docs/auth.md +11 -0
  89. svc_infra/docs/billing.md +190 -0
  90. svc_infra/docs/cache.md +76 -0
  91. svc_infra/docs/cli.md +74 -0
  92. svc_infra/docs/contributing.md +34 -0
  93. svc_infra/docs/data-lifecycle.md +52 -0
  94. svc_infra/docs/database.md +14 -0
  95. svc_infra/docs/docs-and-sdks.md +62 -0
  96. svc_infra/docs/environment.md +114 -0
  97. svc_infra/docs/getting-started.md +63 -0
  98. svc_infra/docs/idempotency.md +111 -0
  99. svc_infra/docs/jobs.md +67 -0
  100. svc_infra/docs/observability.md +16 -0
  101. svc_infra/docs/ops.md +37 -0
  102. svc_infra/docs/rate-limiting.md +125 -0
  103. svc_infra/docs/repo-review.md +48 -0
  104. svc_infra/docs/security.md +176 -0
  105. svc_infra/docs/storage.md +982 -0
  106. svc_infra/docs/tenancy.md +35 -0
  107. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  108. svc_infra/docs/versioned-integrations.md +146 -0
  109. svc_infra/docs/webhooks.md +112 -0
  110. svc_infra/dx/add.py +63 -0
  111. svc_infra/dx/changelog.py +74 -0
  112. svc_infra/dx/checks.py +67 -0
  113. svc_infra/http/__init__.py +13 -0
  114. svc_infra/http/client.py +72 -0
  115. svc_infra/jobs/builtins/webhook_delivery.py +14 -2
  116. svc_infra/jobs/queue.py +9 -1
  117. svc_infra/jobs/runner.py +75 -0
  118. svc_infra/jobs/worker.py +17 -1
  119. svc_infra/mcp/svc_infra_mcp.py +85 -28
  120. svc_infra/obs/add.py +54 -7
  121. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  122. svc_infra/security/headers.py +15 -2
  123. svc_infra/security/hibp.py +6 -2
  124. svc_infra/security/models.py +27 -7
  125. svc_infra/security/oauth_models.py +59 -0
  126. svc_infra/security/permissions.py +1 -0
  127. svc_infra/storage/__init__.py +93 -0
  128. svc_infra/storage/add.py +250 -0
  129. svc_infra/storage/backends/__init__.py +11 -0
  130. svc_infra/storage/backends/local.py +331 -0
  131. svc_infra/storage/backends/memory.py +214 -0
  132. svc_infra/storage/backends/s3.py +329 -0
  133. svc_infra/storage/base.py +239 -0
  134. svc_infra/storage/easy.py +182 -0
  135. svc_infra/storage/settings.py +192 -0
  136. svc_infra/webhooks/service.py +10 -2
  137. {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/METADATA +45 -14
  138. {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/RECORD +140 -52
  139. {svc_infra-0.1.600.dist-info → svc_infra-0.1.664.dist-info}/WHEEL +0 -0
  140. {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, cast
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=cast(Any, Page[read_schema]),
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[read_schema].from_items(
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=cast(Any, read_schema),
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=cast(Any, read_schema),
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
- data = cast(BaseModel, payload).model_dump(exclude_unset=True)
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=cast(Any, read_schema),
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
- data = cast(BaseModel, payload).model_dump(exclude_unset=True)
130
- row = await service.update(session, item_id, data)
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
- count, limit, reset = self.store.incr(str(key), self.window)
30
- if count > limit:
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), limit, retry)
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
- count, lim, reset = store_.incr(str(key), window)
56
- if count > lim:
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), lim, retry)
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 pretty JSON view.</footer>
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, keep_paths: Dict[str, dict], title_suffix: Optional[str]
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
- return _prune_to_paths(full_schema, keep_paths, title_suffix)
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)