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