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.
Files changed (175) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/models.py +142 -4
  3. svc_infra/apf_payments/provider/__init__.py +4 -0
  4. svc_infra/apf_payments/provider/aiydan.py +797 -0
  5. svc_infra/apf_payments/provider/base.py +178 -12
  6. svc_infra/apf_payments/provider/stripe.py +757 -48
  7. svc_infra/apf_payments/schemas.py +163 -1
  8. svc_infra/apf_payments/service.py +582 -42
  9. svc_infra/apf_payments/settings.py +22 -2
  10. svc_infra/api/fastapi/admin/__init__.py +3 -0
  11. svc_infra/api/fastapi/admin/add.py +231 -0
  12. svc_infra/api/fastapi/apf_payments/router.py +792 -73
  13. svc_infra/api/fastapi/apf_payments/setup.py +13 -4
  14. svc_infra/api/fastapi/auth/add.py +10 -4
  15. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  16. svc_infra/api/fastapi/auth/routers/oauth_router.py +74 -34
  17. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  18. svc_infra/api/fastapi/auth/settings.py +2 -0
  19. svc_infra/api/fastapi/billing/router.py +64 -0
  20. svc_infra/api/fastapi/billing/setup.py +19 -0
  21. svc_infra/api/fastapi/cache/add.py +9 -5
  22. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  23. svc_infra/api/fastapi/db/sql/add.py +40 -18
  24. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  25. svc_infra/api/fastapi/db/sql/session.py +16 -0
  26. svc_infra/api/fastapi/db/sql/users.py +13 -1
  27. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  28. svc_infra/api/fastapi/docs/add.py +160 -0
  29. svc_infra/api/fastapi/docs/landing.py +1 -1
  30. svc_infra/api/fastapi/docs/scoped.py +41 -6
  31. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  32. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  33. svc_infra/api/fastapi/middleware/idempotency.py +82 -42
  34. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  35. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  36. svc_infra/api/fastapi/middleware/ratelimit.py +84 -11
  37. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  38. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  39. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  40. svc_infra/api/fastapi/openapi/mutators.py +244 -38
  41. svc_infra/api/fastapi/ops/add.py +73 -0
  42. svc_infra/api/fastapi/pagination.py +133 -32
  43. svc_infra/api/fastapi/routers/ping.py +1 -0
  44. svc_infra/api/fastapi/setup.py +23 -14
  45. svc_infra/api/fastapi/tenancy/add.py +19 -0
  46. svc_infra/api/fastapi/tenancy/context.py +112 -0
  47. svc_infra/api/fastapi/versioned.py +101 -0
  48. svc_infra/app/README.md +5 -5
  49. svc_infra/billing/__init__.py +23 -0
  50. svc_infra/billing/async_service.py +147 -0
  51. svc_infra/billing/jobs.py +230 -0
  52. svc_infra/billing/models.py +131 -0
  53. svc_infra/billing/quotas.py +101 -0
  54. svc_infra/billing/schemas.py +33 -0
  55. svc_infra/billing/service.py +115 -0
  56. svc_infra/bundled_docs/README.md +5 -0
  57. svc_infra/bundled_docs/__init__.py +1 -0
  58. svc_infra/bundled_docs/getting-started.md +6 -0
  59. svc_infra/cache/__init__.py +4 -0
  60. svc_infra/cache/add.py +158 -0
  61. svc_infra/cache/backend.py +5 -2
  62. svc_infra/cache/decorators.py +19 -1
  63. svc_infra/cache/keys.py +24 -4
  64. svc_infra/cli/__init__.py +32 -8
  65. svc_infra/cli/__main__.py +4 -0
  66. svc_infra/cli/cmds/__init__.py +10 -0
  67. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  68. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  69. svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
  70. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  71. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  72. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  73. svc_infra/cli/cmds/dx/__init__.py +12 -0
  74. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  75. svc_infra/cli/cmds/help.py +4 -0
  76. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  77. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  78. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  79. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  80. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  81. svc_infra/data/add.py +61 -0
  82. svc_infra/data/backup.py +53 -0
  83. svc_infra/data/erasure.py +45 -0
  84. svc_infra/data/fixtures.py +40 -0
  85. svc_infra/data/retention.py +55 -0
  86. svc_infra/db/inbox.py +67 -0
  87. svc_infra/db/nosql/mongo/README.md +13 -13
  88. svc_infra/db/outbox.py +104 -0
  89. svc_infra/db/sql/repository.py +52 -12
  90. svc_infra/db/sql/resource.py +5 -0
  91. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  92. svc_infra/db/sql/templates/setup/env_async.py.tmpl +13 -8
  93. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -5
  94. svc_infra/db/sql/tenant.py +79 -0
  95. svc_infra/db/sql/utils.py +18 -4
  96. svc_infra/db/sql/versioning.py +14 -0
  97. svc_infra/docs/acceptance-matrix.md +71 -0
  98. svc_infra/docs/acceptance.md +44 -0
  99. svc_infra/docs/admin.md +425 -0
  100. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  101. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  102. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  103. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  104. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  105. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  106. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  107. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  108. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  109. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  110. svc_infra/docs/api.md +59 -0
  111. svc_infra/docs/auth.md +11 -0
  112. svc_infra/docs/billing.md +190 -0
  113. svc_infra/docs/cache.md +76 -0
  114. svc_infra/docs/cli.md +74 -0
  115. svc_infra/docs/contributing.md +34 -0
  116. svc_infra/docs/data-lifecycle.md +52 -0
  117. svc_infra/docs/database.md +14 -0
  118. svc_infra/docs/docs-and-sdks.md +62 -0
  119. svc_infra/docs/environment.md +114 -0
  120. svc_infra/docs/getting-started.md +63 -0
  121. svc_infra/docs/idempotency.md +111 -0
  122. svc_infra/docs/jobs.md +67 -0
  123. svc_infra/docs/observability.md +16 -0
  124. svc_infra/docs/ops.md +37 -0
  125. svc_infra/docs/rate-limiting.md +125 -0
  126. svc_infra/docs/repo-review.md +48 -0
  127. svc_infra/docs/security.md +176 -0
  128. svc_infra/docs/tenancy.md +35 -0
  129. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  130. svc_infra/docs/versioned-integrations.md +146 -0
  131. svc_infra/docs/webhooks.md +112 -0
  132. svc_infra/dx/add.py +63 -0
  133. svc_infra/dx/changelog.py +74 -0
  134. svc_infra/dx/checks.py +67 -0
  135. svc_infra/http/__init__.py +13 -0
  136. svc_infra/http/client.py +72 -0
  137. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  138. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  139. svc_infra/jobs/easy.py +32 -0
  140. svc_infra/jobs/loader.py +45 -0
  141. svc_infra/jobs/queue.py +81 -0
  142. svc_infra/jobs/redis_queue.py +191 -0
  143. svc_infra/jobs/runner.py +75 -0
  144. svc_infra/jobs/scheduler.py +41 -0
  145. svc_infra/jobs/worker.py +40 -0
  146. svc_infra/mcp/svc_infra_mcp.py +85 -28
  147. svc_infra/obs/README.md +2 -0
  148. svc_infra/obs/add.py +54 -7
  149. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  150. svc_infra/obs/metrics/__init__.py +53 -0
  151. svc_infra/obs/metrics.py +52 -0
  152. svc_infra/security/add.py +201 -0
  153. svc_infra/security/audit.py +130 -0
  154. svc_infra/security/audit_service.py +73 -0
  155. svc_infra/security/headers.py +52 -0
  156. svc_infra/security/hibp.py +95 -0
  157. svc_infra/security/jwt_rotation.py +53 -0
  158. svc_infra/security/lockout.py +96 -0
  159. svc_infra/security/models.py +255 -0
  160. svc_infra/security/org_invites.py +128 -0
  161. svc_infra/security/passwords.py +77 -0
  162. svc_infra/security/permissions.py +149 -0
  163. svc_infra/security/session.py +98 -0
  164. svc_infra/security/signed_cookies.py +80 -0
  165. svc_infra/webhooks/__init__.py +16 -0
  166. svc_infra/webhooks/add.py +322 -0
  167. svc_infra/webhooks/fastapi.py +37 -0
  168. svc_infra/webhooks/router.py +55 -0
  169. svc_infra/webhooks/service.py +67 -0
  170. svc_infra/webhooks/signing.py +30 -0
  171. svc_infra-0.1.654.dist-info/METADATA +154 -0
  172. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/RECORD +174 -56
  173. svc_infra-0.1.562.dist-info/METADATA +0 -79
  174. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  175. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -10,7 +10,7 @@ from svc_infra.db.sql.management import make_crud_schemas
10
10
  from svc_infra.db.sql.repository import SqlRepository
11
11
  from svc_infra.db.sql.resource import SqlResource
12
12
 
13
- from .crud_router import make_crud_router_plus_sql
13
+ from .crud_router import make_crud_router_plus_sql, make_tenant_crud_router_plus_sql
14
14
  from .health import _make_db_health_router
15
15
  from .session import dispose_session, initialize_session
16
16
 
@@ -37,18 +37,37 @@ def add_sql_resources(app: FastAPI, resources: Sequence[SqlResource]) -> None:
37
37
  update_name=r.update_name,
38
38
  )
39
39
 
40
- router = make_crud_router_plus_sql(
41
- model=r.model,
42
- service=svc,
43
- read_schema=Read,
44
- create_schema=Create,
45
- update_schema=Update,
46
- prefix=r.prefix,
47
- tags=r.tags,
48
- search_fields=r.search_fields,
49
- default_ordering=r.ordering_default,
50
- allowed_order_fields=r.allowed_order_fields,
51
- )
40
+ if r.tenant_field:
41
+ # wrap service factory/instance through tenant router
42
+ def _factory():
43
+ return svc
44
+
45
+ router = make_tenant_crud_router_plus_sql(
46
+ model=r.model,
47
+ service_factory=_factory,
48
+ read_schema=Read,
49
+ create_schema=Create,
50
+ update_schema=Update,
51
+ prefix=r.prefix,
52
+ tenant_field=r.tenant_field,
53
+ tags=r.tags,
54
+ search_fields=r.search_fields,
55
+ default_ordering=r.ordering_default,
56
+ allowed_order_fields=r.allowed_order_fields,
57
+ )
58
+ else:
59
+ router = make_crud_router_plus_sql(
60
+ model=r.model,
61
+ service=svc,
62
+ read_schema=Read,
63
+ create_schema=Create,
64
+ update_schema=Update,
65
+ prefix=r.prefix,
66
+ tags=r.tags,
67
+ search_fields=r.search_fields,
68
+ default_ordering=r.ordering_default,
69
+ allowed_order_fields=r.allowed_order_fields,
70
+ )
52
71
  app.include_router(router)
53
72
 
54
73
 
@@ -67,16 +86,19 @@ def add_sql_db(app: FastAPI, *, url: Optional[str] = None, dsn_env: str = "SQL_U
67
86
  app.router.lifespan_context = lifespan
68
87
  return
69
88
 
70
- @app.on_event("startup")
71
- async def _startup() -> None: # noqa: ANN202
89
+ # Use lifespan context manager instead of deprecated on_event
90
+ @asynccontextmanager
91
+ async def lifespan(_app: FastAPI):
72
92
  env_url = os.getenv(dsn_env)
73
93
  if not env_url:
74
94
  raise RuntimeError(f"Missing environment variable {dsn_env} for database URL")
75
95
  initialize_session(env_url)
96
+ try:
97
+ yield
98
+ finally:
99
+ await dispose_session()
76
100
 
77
- @app.on_event("shutdown")
78
- async def _shutdown() -> None: # noqa: ANN202
79
- await dispose_session()
101
+ app.router.lifespan_context = lifespan
80
102
 
81
103
 
82
104
  def add_sql_health(
@@ -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()
@@ -12,6 +12,7 @@ from svc_infra.api.fastapi.auth.settings import get_auth_settings
12
12
  from svc_infra.api.fastapi.dual.dualize import dualize_public, dualize_user
13
13
  from svc_infra.api.fastapi.dual.router import DualAPIRouter
14
14
  from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
15
+ from svc_infra.security.jwt_rotation import RotatingJWTStrategy
15
16
 
16
17
  from ...auth.security import auth_login_path
17
18
  from ...auth.sender import get_sender
@@ -94,7 +95,18 @@ def get_fastapi_users(
94
95
  lifetime = getattr(jwt_block, "lifetime_seconds", None) if jwt_block else None
95
96
  if not isinstance(lifetime, int) or lifetime <= 0:
96
97
  lifetime = 3600
97
- return JWTStrategy(secret=secret, lifetime_seconds=lifetime)
98
+ old = []
99
+ if jwt_block and getattr(jwt_block, "old_secrets", None):
100
+ old = [s.get_secret_value() for s in jwt_block.old_secrets or []]
101
+ audience = "fastapi-users:auth"
102
+ if old:
103
+ return RotatingJWTStrategy(
104
+ secret=secret,
105
+ lifetime_seconds=lifetime,
106
+ old_secrets=old,
107
+ token_audience=audience,
108
+ )
109
+ return JWTStrategy(secret=secret, lifetime_seconds=lifetime, token_audience=audience)
98
110
 
99
111
  bearer_transport = BearerTransport(tokenUrl=auth_login_path)
100
112
  auth_backend = AuthenticationBackend(
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Callable, Optional
5
+
6
+ from fastapi import HTTPException
7
+ from starlette.requests import Request
8
+
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
15
+ from svc_infra.obs.metrics import emit_rate_limited
16
+
17
+
18
+ class RateLimiter:
19
+ def __init__(
20
+ self,
21
+ *,
22
+ limit: int,
23
+ window: int = 60,
24
+ key_fn: Callable = lambda r: "global",
25
+ limit_resolver: Optional[Callable[[Request, Optional[str]], Optional[int]]] = None,
26
+ scope_by_tenant: bool = False,
27
+ store: RateLimitStore | None = None,
28
+ ):
29
+ self.limit = limit
30
+ self.window = window
31
+ self.key_fn = key_fn
32
+ self._limit_resolver = limit_resolver
33
+ self.scope_by_tenant = scope_by_tenant
34
+ self.store = store or InMemoryRateLimitStore(limit=limit)
35
+
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
+
46
+ key = self.key_fn(request)
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:
60
+ retry = max(0, reset - int(time.time()))
61
+ try:
62
+ emit_rate_limited(str(key), eff_limit, retry)
63
+ except Exception:
64
+ pass
65
+ raise HTTPException(
66
+ status_code=429, detail="Rate limit exceeded", headers={"Retry-After": str(retry)}
67
+ )
68
+
69
+
70
+ __all__ = ["RateLimiter"]
71
+
72
+
73
+ def rate_limiter(
74
+ *,
75
+ limit: int,
76
+ window: int = 60,
77
+ key_fn: Callable = lambda r: "global",
78
+ limit_resolver: Optional[Callable[[Request, Optional[str]], Optional[int]]] = None,
79
+ scope_by_tenant: bool = False,
80
+ store: RateLimitStore | None = None,
81
+ ):
82
+ store_ = store or InMemoryRateLimitStore(limit=limit)
83
+
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
+
93
+ key = key_fn(request)
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:
107
+ retry = max(0, reset - int(time.time()))
108
+ try:
109
+ emit_rate_limited(str(key), eff_limit, retry)
110
+ except Exception:
111
+ pass
112
+ raise HTTPException(
113
+ status_code=429, detail="Rate limit exceeded", headers={"Retry-After": str(retry)}
114
+ )
115
+
116
+ return dep
@@ -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"]