svc-infra 0.1.589__py3-none-any.whl → 0.1.706__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 (260) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/README.md +732 -0
  3. svc_infra/apf_payments/models.py +133 -42
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +871 -0
  6. svc_infra/apf_payments/provider/base.py +30 -9
  7. svc_infra/apf_payments/provider/stripe.py +156 -62
  8. svc_infra/apf_payments/schemas.py +19 -10
  9. svc_infra/apf_payments/service.py +211 -68
  10. svc_infra/apf_payments/settings.py +27 -3
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +15 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +245 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +145 -46
  16. svc_infra/api/fastapi/apf_payments/setup.py +26 -8
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  19. svc_infra/api/fastapi/auth/add.py +27 -14
  20. svc_infra/api/fastapi/auth/gaurd.py +104 -13
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  23. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  25. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  26. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  27. svc_infra/api/fastapi/auth/policy.py +0 -1
  28. svc_infra/api/fastapi/auth/providers.py +3 -1
  29. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  30. svc_infra/api/fastapi/auth/routers/oauth_router.py +214 -75
  31. svc_infra/api/fastapi/auth/routers/session_router.py +67 -0
  32. svc_infra/api/fastapi/auth/security.py +31 -10
  33. svc_infra/api/fastapi/auth/sender.py +8 -1
  34. svc_infra/api/fastapi/auth/settings.py +2 -0
  35. svc_infra/api/fastapi/auth/state.py +3 -1
  36. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  37. svc_infra/api/fastapi/billing/router.py +73 -0
  38. svc_infra/api/fastapi/billing/setup.py +19 -0
  39. svc_infra/api/fastapi/cache/add.py +9 -5
  40. svc_infra/api/fastapi/db/__init__.py +5 -1
  41. svc_infra/api/fastapi/db/http.py +3 -1
  42. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  43. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  44. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  45. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  46. svc_infra/api/fastapi/db/sql/add.py +71 -26
  47. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  48. svc_infra/api/fastapi/db/sql/health.py +3 -1
  49. svc_infra/api/fastapi/db/sql/session.py +18 -0
  50. svc_infra/api/fastapi/db/sql/users.py +29 -5
  51. svc_infra/api/fastapi/dependencies/ratelimit.py +130 -0
  52. svc_infra/api/fastapi/docs/add.py +173 -0
  53. svc_infra/api/fastapi/docs/landing.py +4 -2
  54. svc_infra/api/fastapi/docs/scoped.py +62 -15
  55. svc_infra/api/fastapi/dual/__init__.py +12 -2
  56. svc_infra/api/fastapi/dual/dualize.py +1 -1
  57. svc_infra/api/fastapi/dual/protected.py +126 -4
  58. svc_infra/api/fastapi/dual/public.py +25 -0
  59. svc_infra/api/fastapi/dual/router.py +40 -13
  60. svc_infra/api/fastapi/dx.py +33 -2
  61. svc_infra/api/fastapi/ease.py +10 -2
  62. svc_infra/api/fastapi/http/concurrency.py +2 -1
  63. svc_infra/api/fastapi/http/conditional.py +3 -1
  64. svc_infra/api/fastapi/middleware/debug.py +4 -1
  65. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  66. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  67. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  68. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  69. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  70. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  71. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  72. svc_infra/api/fastapi/middleware/ratelimit.py +143 -31
  73. svc_infra/api/fastapi/middleware/ratelimit_store.py +111 -0
  74. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  75. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  76. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  77. svc_infra/api/fastapi/openapi/apply.py +5 -3
  78. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  79. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  80. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  81. svc_infra/api/fastapi/openapi/security.py +3 -1
  82. svc_infra/api/fastapi/ops/add.py +75 -0
  83. svc_infra/api/fastapi/pagination.py +47 -20
  84. svc_infra/api/fastapi/routers/__init__.py +43 -15
  85. svc_infra/api/fastapi/routers/ping.py +1 -0
  86. svc_infra/api/fastapi/setup.py +188 -56
  87. svc_infra/api/fastapi/tenancy/add.py +19 -0
  88. svc_infra/api/fastapi/tenancy/context.py +112 -0
  89. svc_infra/api/fastapi/versioned.py +101 -0
  90. svc_infra/app/README.md +5 -5
  91. svc_infra/app/__init__.py +3 -1
  92. svc_infra/app/env.py +69 -1
  93. svc_infra/app/logging/add.py +9 -2
  94. svc_infra/app/logging/formats.py +12 -5
  95. svc_infra/billing/__init__.py +23 -0
  96. svc_infra/billing/async_service.py +147 -0
  97. svc_infra/billing/jobs.py +241 -0
  98. svc_infra/billing/models.py +177 -0
  99. svc_infra/billing/quotas.py +103 -0
  100. svc_infra/billing/schemas.py +36 -0
  101. svc_infra/billing/service.py +123 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +9 -0
  106. svc_infra/cache/add.py +170 -0
  107. svc_infra/cache/backend.py +7 -6
  108. svc_infra/cache/decorators.py +81 -15
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +24 -4
  111. svc_infra/cache/recache.py +26 -14
  112. svc_infra/cache/resources.py +14 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/utils.py +3 -1
  115. svc_infra/cli/__init__.py +52 -8
  116. svc_infra/cli/__main__.py +4 -0
  117. svc_infra/cli/cmds/__init__.py +39 -2
  118. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  120. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  121. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  122. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  123. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  124. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  125. svc_infra/cli/cmds/dx/__init__.py +12 -0
  126. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  127. svc_infra/cli/cmds/health/__init__.py +179 -0
  128. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  129. svc_infra/cli/cmds/help.py +4 -0
  130. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  131. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  132. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  133. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  134. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  135. svc_infra/cli/foundation/runner.py +6 -2
  136. svc_infra/data/add.py +61 -0
  137. svc_infra/data/backup.py +58 -0
  138. svc_infra/data/erasure.py +45 -0
  139. svc_infra/data/fixtures.py +42 -0
  140. svc_infra/data/retention.py +61 -0
  141. svc_infra/db/__init__.py +15 -0
  142. svc_infra/db/crud_schema.py +9 -9
  143. svc_infra/db/inbox.py +67 -0
  144. svc_infra/db/nosql/__init__.py +3 -0
  145. svc_infra/db/nosql/core.py +30 -9
  146. svc_infra/db/nosql/indexes.py +3 -1
  147. svc_infra/db/nosql/management.py +1 -1
  148. svc_infra/db/nosql/mongo/README.md +13 -13
  149. svc_infra/db/nosql/mongo/client.py +19 -2
  150. svc_infra/db/nosql/mongo/settings.py +6 -2
  151. svc_infra/db/nosql/repository.py +35 -15
  152. svc_infra/db/nosql/resource.py +20 -3
  153. svc_infra/db/nosql/scaffold.py +9 -3
  154. svc_infra/db/nosql/service.py +3 -1
  155. svc_infra/db/nosql/types.py +6 -2
  156. svc_infra/db/ops.py +384 -0
  157. svc_infra/db/outbox.py +108 -0
  158. svc_infra/db/sql/apikey.py +37 -9
  159. svc_infra/db/sql/authref.py +9 -3
  160. svc_infra/db/sql/constants.py +12 -8
  161. svc_infra/db/sql/core.py +2 -2
  162. svc_infra/db/sql/management.py +11 -8
  163. svc_infra/db/sql/repository.py +99 -26
  164. svc_infra/db/sql/resource.py +5 -0
  165. svc_infra/db/sql/scaffold.py +6 -2
  166. svc_infra/db/sql/service.py +15 -5
  167. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  168. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  169. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  170. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  171. svc_infra/db/sql/tenant.py +88 -0
  172. svc_infra/db/sql/uniq_hooks.py +9 -3
  173. svc_infra/db/sql/utils.py +138 -51
  174. svc_infra/db/sql/versioning.py +14 -0
  175. svc_infra/deploy/__init__.py +538 -0
  176. svc_infra/documents/__init__.py +100 -0
  177. svc_infra/documents/add.py +264 -0
  178. svc_infra/documents/ease.py +233 -0
  179. svc_infra/documents/models.py +114 -0
  180. svc_infra/documents/storage.py +264 -0
  181. svc_infra/dx/add.py +65 -0
  182. svc_infra/dx/changelog.py +74 -0
  183. svc_infra/dx/checks.py +68 -0
  184. svc_infra/exceptions.py +141 -0
  185. svc_infra/health/__init__.py +864 -0
  186. svc_infra/http/__init__.py +13 -0
  187. svc_infra/http/client.py +105 -0
  188. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  189. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  190. svc_infra/jobs/easy.py +33 -0
  191. svc_infra/jobs/loader.py +50 -0
  192. svc_infra/jobs/queue.py +116 -0
  193. svc_infra/jobs/redis_queue.py +256 -0
  194. svc_infra/jobs/runner.py +79 -0
  195. svc_infra/jobs/scheduler.py +53 -0
  196. svc_infra/jobs/worker.py +40 -0
  197. svc_infra/loaders/__init__.py +186 -0
  198. svc_infra/loaders/base.py +142 -0
  199. svc_infra/loaders/github.py +311 -0
  200. svc_infra/loaders/models.py +147 -0
  201. svc_infra/loaders/url.py +235 -0
  202. svc_infra/logging/__init__.py +374 -0
  203. svc_infra/mcp/svc_infra_mcp.py +91 -33
  204. svc_infra/obs/README.md +2 -0
  205. svc_infra/obs/add.py +65 -9
  206. svc_infra/obs/cloud_dash.py +2 -1
  207. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  208. svc_infra/obs/metrics/__init__.py +52 -0
  209. svc_infra/obs/metrics/asgi.py +13 -7
  210. svc_infra/obs/metrics/http.py +9 -5
  211. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  212. svc_infra/obs/metrics.py +53 -0
  213. svc_infra/obs/settings.py +6 -2
  214. svc_infra/security/add.py +217 -0
  215. svc_infra/security/audit.py +212 -0
  216. svc_infra/security/audit_service.py +74 -0
  217. svc_infra/security/headers.py +52 -0
  218. svc_infra/security/hibp.py +101 -0
  219. svc_infra/security/jwt_rotation.py +105 -0
  220. svc_infra/security/lockout.py +102 -0
  221. svc_infra/security/models.py +287 -0
  222. svc_infra/security/oauth_models.py +73 -0
  223. svc_infra/security/org_invites.py +130 -0
  224. svc_infra/security/passwords.py +79 -0
  225. svc_infra/security/permissions.py +171 -0
  226. svc_infra/security/session.py +98 -0
  227. svc_infra/security/signed_cookies.py +100 -0
  228. svc_infra/storage/__init__.py +93 -0
  229. svc_infra/storage/add.py +253 -0
  230. svc_infra/storage/backends/__init__.py +11 -0
  231. svc_infra/storage/backends/local.py +339 -0
  232. svc_infra/storage/backends/memory.py +216 -0
  233. svc_infra/storage/backends/s3.py +353 -0
  234. svc_infra/storage/base.py +239 -0
  235. svc_infra/storage/easy.py +185 -0
  236. svc_infra/storage/settings.py +195 -0
  237. svc_infra/testing/__init__.py +685 -0
  238. svc_infra/utils.py +7 -3
  239. svc_infra/webhooks/__init__.py +69 -0
  240. svc_infra/webhooks/add.py +339 -0
  241. svc_infra/webhooks/encryption.py +115 -0
  242. svc_infra/webhooks/fastapi.py +39 -0
  243. svc_infra/webhooks/router.py +55 -0
  244. svc_infra/webhooks/service.py +70 -0
  245. svc_infra/webhooks/signing.py +34 -0
  246. svc_infra/websocket/__init__.py +79 -0
  247. svc_infra/websocket/add.py +140 -0
  248. svc_infra/websocket/client.py +282 -0
  249. svc_infra/websocket/config.py +69 -0
  250. svc_infra/websocket/easy.py +76 -0
  251. svc_infra/websocket/exceptions.py +61 -0
  252. svc_infra/websocket/manager.py +344 -0
  253. svc_infra/websocket/models.py +49 -0
  254. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  255. svc_infra-0.1.706.dist-info/METADATA +356 -0
  256. svc_infra-0.1.706.dist-info/RECORD +357 -0
  257. svc_infra-0.1.589.dist-info/METADATA +0 -79
  258. svc_infra-0.1.589.dist-info/RECORD +0 -234
  259. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  260. {svc_infra-0.1.589.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -9,14 +9,20 @@ from fastapi import FastAPI
9
9
  from fastapi.middleware.cors import CORSMiddleware
10
10
  from fastapi.responses import HTMLResponse
11
11
  from fastapi.routing import APIRoute
12
+ from starlette.types import ASGIApp, Receive, Scope, Send
12
13
 
13
14
  from svc_infra.api.fastapi.docs.landing import CardSpec, DocTargets, render_index_html
14
15
  from svc_infra.api.fastapi.docs.scoped import DOC_SCOPES
15
16
  from svc_infra.api.fastapi.middleware.errors.catchall import CatchAllExceptionMiddleware
16
17
  from svc_infra.api.fastapi.middleware.errors.handlers import register_error_handlers
18
+ from svc_infra.api.fastapi.middleware.graceful_shutdown import install_graceful_shutdown
17
19
  from svc_infra.api.fastapi.middleware.idempotency import IdempotencyMiddleware
18
20
  from svc_infra.api.fastapi.middleware.ratelimit import SimpleRateLimitMiddleware
19
21
  from svc_infra.api.fastapi.middleware.request_id import RequestIdMiddleware
22
+ from svc_infra.api.fastapi.middleware.timeout import (
23
+ BodyReadTimeoutMiddleware,
24
+ HandlerTimeoutMiddleware,
25
+ )
20
26
  from svc_infra.api.fastapi.openapi.models import APIVersionSpec, ServiceInfo
21
27
  from svc_infra.api.fastapi.openapi.mutators import setup_mutators
22
28
  from svc_infra.api.fastapi.openapi.pipeline import apply_mutators
@@ -34,8 +40,9 @@ def _gen_operation_id_factory():
34
40
 
35
41
  def _gen(route: APIRoute) -> str:
36
42
  base = route.name or getattr(route.endpoint, "__name__", "op")
37
- base = _normalize(base)
38
- tag = _normalize(route.tags[0]) if route.tags else ""
43
+ base = _normalize(str(base)) # Convert Enum to str if needed
44
+ tag_raw = route.tags[0] if route.tags else ""
45
+ tag = _normalize(str(tag_raw)) if tag_raw else ""
39
46
  method = next(iter(route.methods or ["GET"])).lower()
40
47
 
41
48
  candidate = base
@@ -55,34 +62,103 @@ def _gen_operation_id_factory():
55
62
  return _gen
56
63
 
57
64
 
65
+ def _origin_to_regex(origin: str) -> str | None:
66
+ """Convert a wildcard origin pattern to a regex.
67
+
68
+ Supports patterns like:
69
+ - "https://*.vercel.app" -> matches any subdomain
70
+ - "https://nfrax-*.vercel.app" -> matches nfrax-xxx.vercel.app
71
+
72
+ Returns None if the origin is not a pattern (no wildcards).
73
+ """
74
+ import re
75
+
76
+ if "*" not in origin:
77
+ return None
78
+ # Escape special regex chars except *, then replace * with regex pattern
79
+ escaped = re.escape(origin).replace(r"\*", "[a-zA-Z0-9_-]+")
80
+ return f"^{escaped}$"
81
+
82
+
58
83
  def _setup_cors(app: FastAPI, public_cors_origins: list[str] | str | None = None):
84
+ # Collect origins from parameter
59
85
  if isinstance(public_cors_origins, list):
60
- origins = [o.strip() for o in public_cors_origins if o and o.strip()]
86
+ param_origins = [o.strip() for o in public_cors_origins if o and o.strip()]
61
87
  elif isinstance(public_cors_origins, str):
62
- origins = [o.strip() for o in public_cors_origins.split(",") if o and o.strip()]
88
+ param_origins = [
89
+ o.strip() for o in public_cors_origins.split(",") if o and o.strip()
90
+ ]
63
91
  else:
64
- fallback = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3000")
65
- origins = [o.strip() for o in fallback.split(",") if o and o.strip()]
92
+ param_origins = []
93
+
94
+ # Collect origins from environment variable
95
+ env_value = os.getenv("CORS_ALLOW_ORIGINS", "")
96
+ env_origins = [o.strip() for o in env_value.split(",") if o and o.strip()]
97
+
98
+ # Merge both sources, removing duplicates while preserving order
99
+ seen = set()
100
+ origins = []
101
+ for o in param_origins + env_origins:
102
+ if o not in seen:
103
+ seen.add(o)
104
+ origins.append(o)
66
105
 
67
106
  if not origins:
68
107
  return
69
108
 
70
109
  cors_kwargs = dict(allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
110
+
111
+ # Check for "*" (allow all) first
71
112
  if "*" in origins:
72
113
  cors_kwargs["allow_origin_regex"] = ".*"
73
114
  else:
74
- cors_kwargs["allow_origins"] = origins
115
+ # Separate exact origins from wildcard patterns
116
+ exact_origins = []
117
+ patterns = []
118
+ for o in origins:
119
+ regex = _origin_to_regex(o)
120
+ if regex:
121
+ patterns.append(regex)
122
+ else:
123
+ exact_origins.append(o)
124
+
125
+ # If we have patterns, combine into a single regex with exact origins
126
+ if patterns:
127
+ # Convert exact origins to regex patterns too
128
+ import re
129
+
130
+ for exact in exact_origins:
131
+ patterns.append(f"^{re.escape(exact)}$")
132
+ # Combine all patterns with OR
133
+ cors_kwargs["allow_origin_regex"] = "|".join(patterns)
134
+ else:
135
+ # No patterns, just use allow_origins
136
+ cors_kwargs["allow_origins"] = exact_origins
137
+
138
+ app.add_middleware(CORSMiddleware, **cors_kwargs) # type: ignore[arg-type] # CORSMiddleware accepts these kwargs
139
+
140
+
141
+ def _setup_middlewares(app: FastAPI, skip_paths: list[str] | None = None):
142
+ """Configure middleware stack. All middlewares are pure ASGI for streaming compatibility.
143
+
144
+ Args:
145
+ app: FastAPI application
146
+ skip_paths: Paths to skip for certain middlewares (e.g., long-running or streaming endpoints)
147
+ """
148
+ paths = skip_paths or []
75
149
 
76
- app.add_middleware(CORSMiddleware, **cors_kwargs)
77
-
78
-
79
- def _setup_middlewares(app: FastAPI):
80
150
  app.add_middleware(RequestIdMiddleware)
151
+ # Timeouts: enforce body read timeout first, then total handler timeout
152
+ app.add_middleware(BodyReadTimeoutMiddleware)
153
+ app.add_middleware(HandlerTimeoutMiddleware, skip_paths=paths)
81
154
  app.add_middleware(CatchAllExceptionMiddleware)
82
- app.add_middleware(IdempotencyMiddleware)
83
- app.add_middleware(SimpleRateLimitMiddleware)
155
+ # Idempotency and rate limiting
156
+ app.add_middleware(IdempotencyMiddleware, skip_paths=paths)
157
+ app.add_middleware(SimpleRateLimitMiddleware, skip_paths=paths)
84
158
  register_error_handlers(app)
85
- _add_route_logger(app)
159
+ _add_route_logger(app, skip_paths=paths)
160
+ # Graceful shutdown: track in-flight and wait on shutdown
161
+ install_graceful_shutdown(app)
86
162
 
87
163
 
88
164
  def _coerce_list(value: str | Iterable[str] | None) -> list[str]:
@@ -97,23 +173,30 @@ def _dump_or_none(model):
97
173
  return model.model_dump(exclude_none=True) if model is not None else None
98
174
 
99
175
 
100
- def _build_child_app(service: ServiceInfo, spec: APIVersionSpec) -> FastAPI:
101
- title = f"{service.name} {spec.tag}" if getattr(spec, "tag", None) else service.name
176
+ def _build_child_app(
177
+ service: ServiceInfo, spec: APIVersionSpec, skip_paths: list[str] | None = None
178
+ ) -> FastAPI:
179
+ title = (
180
+ f"{service.name} • {spec.tag}" if getattr(spec, "tag", None) else service.name
181
+ )
102
182
  child = FastAPI(
103
183
  title=title,
104
184
  version=service.release,
105
185
  contact=_dump_or_none(service.contact),
106
186
  license_info=_dump_or_none(service.license),
107
187
  terms_of_service=service.terms_of_service,
108
- description=service.description,
188
+ description=service.description or "",
109
189
  generate_unique_id_function=_gen_operation_id_factory(),
110
190
  )
111
191
 
112
- _setup_middlewares(child)
192
+ _setup_middlewares(child, skip_paths=skip_paths)
113
193
 
114
194
  # ---- OpenAPI pipeline (DRY!) ----
115
- include_api_key = bool(spec.include_api_key) if spec.include_api_key is not None else False
116
- mount_path = f"/{spec.tag.strip('/')}"
195
+ include_api_key = (
196
+ bool(spec.include_api_key) if spec.include_api_key is not None else False
197
+ )
198
+ tag_str = str(spec.tag).strip("/")
199
+ mount_path = f"/{tag_str}"
117
200
  server_url = (
118
201
  mount_path
119
202
  if not spec.public_base_url
@@ -130,11 +213,17 @@ def _build_child_app(service: ServiceInfo, spec: APIVersionSpec) -> FastAPI:
130
213
 
131
214
  if spec.routers_package:
132
215
  register_all_routers(
133
- child, base_package=spec.routers_package, prefix="", environment=CURRENT_ENVIRONMENT
216
+ child,
217
+ base_package=spec.routers_package,
218
+ prefix="",
219
+ environment=CURRENT_ENVIRONMENT,
134
220
  )
135
221
 
136
222
  logger.info(
137
- "[%s] initialized version %s [env: %s]", service.name, spec.tag, CURRENT_ENVIRONMENT
223
+ "[%s] initialized version %s [env: %s]",
224
+ service.name,
225
+ spec.tag,
226
+ CURRENT_ENVIRONMENT,
138
227
  )
139
228
  return child
140
229
 
@@ -146,23 +235,25 @@ def _build_parent_app(
146
235
  root_routers: list[str] | str | None,
147
236
  root_server_url: str | None = None,
148
237
  root_include_api_key: bool = False,
238
+ skip_paths: list[str] | None = None,
239
+ **fastapi_kwargs, # Accept FastAPI kwargs
149
240
  ) -> FastAPI:
150
- show_root_docs = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
151
-
241
+ # Root docs are now enabled in all environments to match root card visibility
152
242
  parent = FastAPI(
153
243
  title=service.name,
154
244
  version=service.release,
155
245
  contact=_dump_or_none(service.contact),
156
246
  license_info=_dump_or_none(service.license),
157
247
  terms_of_service=service.terms_of_service,
158
- 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),
248
+ description=service.description or "",
249
+ docs_url="/docs",
250
+ redoc_url="/redoc",
251
+ openapi_url="/openapi.json",
252
+ **fastapi_kwargs, # Forward to FastAPI constructor
162
253
  )
163
254
 
164
255
  _setup_cors(parent, public_cors_origins)
165
- _setup_middlewares(parent)
256
+ _setup_middlewares(parent, skip_paths=skip_paths)
166
257
 
167
258
  mutators = setup_mutators(
168
259
  service=service,
@@ -181,23 +272,54 @@ def _build_parent_app(
181
272
  )
182
273
  # app-provided root routers
183
274
  for pkg in _coerce_list(root_routers):
184
- register_all_routers(parent, base_package=pkg, prefix="", environment=CURRENT_ENVIRONMENT)
275
+ register_all_routers(
276
+ parent, base_package=pkg, prefix="", environment=CURRENT_ENVIRONMENT
277
+ )
185
278
 
186
279
  return parent
187
280
 
188
281
 
189
- def _add_route_logger(app: FastAPI):
190
- @app.middleware("http")
191
- async def _log_route(request, call_next):
192
- resp = await call_next(request)
193
- route = request.scope.get("route")
194
- # Prefer FastAPI's path_format (shows param patterns), fall back to path
195
- path = getattr(route, "path_format", None) or getattr(route, "path", None)
196
- if path:
197
- # Include mount root_path so mounted children show their full path
198
- root_path = request.scope.get("root_path", "") or ""
199
- resp.headers["X-Handled-By"] = f"{request.method} {root_path}{path}"
200
- return resp
282
+ class RouteLoggerMiddleware:
283
+ """Pure ASGI middleware to add X-Handled-By header."""
284
+
285
+ def __init__(self, app: ASGIApp, skip_paths: list[str] | None = None):
286
+ self.app = app
287
+ self.skip_paths = skip_paths or []
288
+
289
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
290
+ if scope.get("type") != "http":
291
+ await self.app(scope, receive, send)
292
+ return
293
+
294
+ path = scope.get("path", "")
295
+ method = scope.get("method", "")
296
+
297
+ # Skip specified paths
298
+ if any(skip in path for skip in self.skip_paths):
299
+ await self.app(scope, receive, send)
300
+ return
301
+
302
+ # Wrap send to add header after response starts
303
+ async def send_wrapper(message):
304
+ if message["type"] == "http.response.start":
305
+ route = scope.get("route")
306
+ route_path = getattr(route, "path_format", None) or getattr(
307
+ route, "path", None
308
+ )
309
+ if route_path:
310
+ root_path = scope.get("root_path", "") or ""
311
+ headers = list(message.get("headers", []))
312
+ headers.append(
313
+ (b"x-handled-by", f"{method} {root_path}{route_path}".encode())
314
+ )
315
+ message = {**message, "headers": headers}
316
+ await send(message)
317
+
318
+ await self.app(scope, receive, send_wrapper)
319
+
320
+
321
+ def _add_route_logger(app: FastAPI, skip_paths: list[str] | None = None):
322
+ app.add_middleware(RouteLoggerMiddleware, skip_paths=skip_paths)
201
323
 
202
324
 
203
325
  def setup_service_api(
@@ -208,6 +330,8 @@ def setup_service_api(
208
330
  public_cors_origins: list[str] | str | None = None,
209
331
  root_public_base_url: str | None = None,
210
332
  root_include_api_key: bool | None = None,
333
+ skip_paths: list[str] | None = None,
334
+ **fastapi_kwargs, # Forward all other FastAPI kwargs (lifespan, etc.)
211
335
  ) -> FastAPI:
212
336
  # infer if not explicitly provided
213
337
  effective_root_include_api_key = (
@@ -223,31 +347,35 @@ def setup_service_api(
223
347
  root_routers=root_routers,
224
348
  root_server_url=root_server,
225
349
  root_include_api_key=effective_root_include_api_key,
350
+ skip_paths=skip_paths,
351
+ **fastapi_kwargs, # Forward to _build_parent_app
226
352
  )
227
353
 
228
354
  # Mount each version
229
355
  for spec in versions:
230
- child = _build_child_app(service, spec)
231
- mount_path = f"/{spec.tag.strip('/')}"
232
- parent.mount(mount_path, child, name=spec.tag.strip("/"))
356
+ child = _build_child_app(service, spec, skip_paths=skip_paths)
357
+ tag_str = str(spec.tag).strip("/")
358
+ mount_path = f"/{tag_str}"
359
+ parent.mount(mount_path, child, name=tag_str)
233
360
 
234
- @parent.get("/", include_in_schema=False)
361
+ @parent.get("/", include_in_schema=False, response_class=HTMLResponse)
235
362
  def index():
236
363
  cards: list[CardSpec] = []
237
364
  is_local_dev = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
238
365
 
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
- )
366
+ # Root card - always show in all environments
367
+ cards.append(
368
+ CardSpec(
369
+ tag="",
370
+ docs=DocTargets(
371
+ swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"
372
+ ),
246
373
  )
374
+ )
247
375
 
248
376
  # Version cards
249
377
  for spec in versions:
250
- tag = spec.tag.strip("/")
378
+ tag = str(spec.tag).strip("/")
251
379
  cards.append(
252
380
  CardSpec(
253
381
  tag=tag,
@@ -265,11 +393,15 @@ def setup_service_api(
265
393
  cards.append(
266
394
  CardSpec(
267
395
  tag=scope.strip("/"),
268
- docs=DocTargets(swagger=swagger, redoc=redoc, openapi_json=openapi_json),
396
+ docs=DocTargets(
397
+ swagger=swagger, redoc=redoc, openapi_json=openapi_json
398
+ ),
269
399
  )
270
400
  )
271
401
 
272
- html = render_index_html(service_name=service.name, release=service.release, cards=cards)
402
+ html = render_index_html(
403
+ service_name=service.name, release=service.release, cards=cards
404
+ )
273
405
  return HTMLResponse(html)
274
406
 
275
407
  return parent
@@ -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[misc,assignment]
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
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")
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
+ setattr(mock_app, "include_router", _capture_router)
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
 
svc_infra/app/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- from .env import pick
1
+ from .env import MissingSecretError, pick, require_secret
2
2
  from .logging import setup_logging
3
3
  from .logging.formats import LoggingConfig, LogLevelOptions
4
4
 
@@ -7,4 +7,6 @@ __all__ = [
7
7
  "LoggingConfig",
8
8
  "LogLevelOptions",
9
9
  "pick",
10
+ "require_secret",
11
+ "MissingSecretError",
10
12
  ]