svc-infra 0.1.595__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 (256) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +133 -42
  3. svc_infra/apf_payments/provider/aiydan.py +121 -47
  4. svc_infra/apf_payments/provider/base.py +30 -9
  5. svc_infra/apf_payments/provider/stripe.py +156 -62
  6. svc_infra/apf_payments/schemas.py +18 -9
  7. svc_infra/apf_payments/service.py +98 -41
  8. svc_infra/apf_payments/settings.py +5 -1
  9. svc_infra/api/__init__.py +61 -0
  10. svc_infra/api/fastapi/__init__.py +15 -0
  11. svc_infra/api/fastapi/admin/__init__.py +3 -0
  12. svc_infra/api/fastapi/admin/add.py +245 -0
  13. svc_infra/api/fastapi/apf_payments/router.py +128 -70
  14. svc_infra/api/fastapi/apf_payments/setup.py +13 -6
  15. svc_infra/api/fastapi/auth/__init__.py +65 -0
  16. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  17. svc_infra/api/fastapi/auth/add.py +17 -14
  18. svc_infra/api/fastapi/auth/gaurd.py +45 -16
  19. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  21. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  22. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  23. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  24. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  25. svc_infra/api/fastapi/auth/policy.py +0 -1
  26. svc_infra/api/fastapi/auth/providers.py +3 -1
  27. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  28. svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
  29. svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
  30. svc_infra/api/fastapi/auth/security.py +31 -10
  31. svc_infra/api/fastapi/auth/sender.py +8 -1
  32. svc_infra/api/fastapi/auth/state.py +3 -1
  33. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  34. svc_infra/api/fastapi/billing/router.py +73 -0
  35. svc_infra/api/fastapi/billing/setup.py +19 -0
  36. svc_infra/api/fastapi/cache/add.py +9 -5
  37. svc_infra/api/fastapi/db/__init__.py +5 -1
  38. svc_infra/api/fastapi/db/http.py +3 -1
  39. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  40. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  41. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  42. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  43. svc_infra/api/fastapi/db/sql/add.py +71 -26
  44. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  45. svc_infra/api/fastapi/db/sql/health.py +3 -1
  46. svc_infra/api/fastapi/db/sql/session.py +18 -0
  47. svc_infra/api/fastapi/db/sql/users.py +18 -6
  48. svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
  49. svc_infra/api/fastapi/docs/add.py +173 -0
  50. svc_infra/api/fastapi/docs/landing.py +4 -2
  51. svc_infra/api/fastapi/docs/scoped.py +62 -15
  52. svc_infra/api/fastapi/dual/__init__.py +12 -2
  53. svc_infra/api/fastapi/dual/dualize.py +1 -1
  54. svc_infra/api/fastapi/dual/protected.py +126 -4
  55. svc_infra/api/fastapi/dual/public.py +25 -0
  56. svc_infra/api/fastapi/dual/router.py +40 -13
  57. svc_infra/api/fastapi/dx.py +33 -2
  58. svc_infra/api/fastapi/ease.py +10 -2
  59. svc_infra/api/fastapi/http/concurrency.py +2 -1
  60. svc_infra/api/fastapi/http/conditional.py +3 -1
  61. svc_infra/api/fastapi/middleware/debug.py +4 -1
  62. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
  71. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  74. svc_infra/api/fastapi/openapi/apply.py +5 -3
  75. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  76. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  77. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  78. svc_infra/api/fastapi/openapi/security.py +3 -1
  79. svc_infra/api/fastapi/ops/add.py +75 -0
  80. svc_infra/api/fastapi/pagination.py +47 -20
  81. svc_infra/api/fastapi/routers/__init__.py +43 -15
  82. svc_infra/api/fastapi/routers/ping.py +1 -0
  83. svc_infra/api/fastapi/setup.py +188 -57
  84. svc_infra/api/fastapi/tenancy/add.py +19 -0
  85. svc_infra/api/fastapi/tenancy/context.py +112 -0
  86. svc_infra/api/fastapi/versioned.py +101 -0
  87. svc_infra/app/README.md +5 -5
  88. svc_infra/app/__init__.py +3 -1
  89. svc_infra/app/env.py +69 -1
  90. svc_infra/app/logging/add.py +9 -2
  91. svc_infra/app/logging/formats.py +12 -5
  92. svc_infra/billing/__init__.py +23 -0
  93. svc_infra/billing/async_service.py +147 -0
  94. svc_infra/billing/jobs.py +241 -0
  95. svc_infra/billing/models.py +177 -0
  96. svc_infra/billing/quotas.py +103 -0
  97. svc_infra/billing/schemas.py +36 -0
  98. svc_infra/billing/service.py +123 -0
  99. svc_infra/bundled_docs/README.md +5 -0
  100. svc_infra/bundled_docs/__init__.py +1 -0
  101. svc_infra/bundled_docs/getting-started.md +6 -0
  102. svc_infra/cache/__init__.py +9 -0
  103. svc_infra/cache/add.py +170 -0
  104. svc_infra/cache/backend.py +7 -6
  105. svc_infra/cache/decorators.py +81 -15
  106. svc_infra/cache/demo.py +2 -2
  107. svc_infra/cache/keys.py +24 -4
  108. svc_infra/cache/recache.py +26 -14
  109. svc_infra/cache/resources.py +14 -5
  110. svc_infra/cache/tags.py +19 -44
  111. svc_infra/cache/utils.py +3 -1
  112. svc_infra/cli/__init__.py +52 -8
  113. svc_infra/cli/__main__.py +4 -0
  114. svc_infra/cli/cmds/__init__.py +39 -2
  115. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  116. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  117. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  118. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  119. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  120. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  121. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  122. svc_infra/cli/cmds/dx/__init__.py +12 -0
  123. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  124. svc_infra/cli/cmds/health/__init__.py +179 -0
  125. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  126. svc_infra/cli/cmds/help.py +4 -0
  127. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  128. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  129. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  130. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  131. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  132. svc_infra/cli/foundation/runner.py +6 -2
  133. svc_infra/data/add.py +61 -0
  134. svc_infra/data/backup.py +58 -0
  135. svc_infra/data/erasure.py +45 -0
  136. svc_infra/data/fixtures.py +42 -0
  137. svc_infra/data/retention.py +61 -0
  138. svc_infra/db/__init__.py +15 -0
  139. svc_infra/db/crud_schema.py +9 -9
  140. svc_infra/db/inbox.py +67 -0
  141. svc_infra/db/nosql/__init__.py +3 -0
  142. svc_infra/db/nosql/core.py +30 -9
  143. svc_infra/db/nosql/indexes.py +3 -1
  144. svc_infra/db/nosql/management.py +1 -1
  145. svc_infra/db/nosql/mongo/README.md +13 -13
  146. svc_infra/db/nosql/mongo/client.py +19 -2
  147. svc_infra/db/nosql/mongo/settings.py +6 -2
  148. svc_infra/db/nosql/repository.py +35 -15
  149. svc_infra/db/nosql/resource.py +20 -3
  150. svc_infra/db/nosql/scaffold.py +9 -3
  151. svc_infra/db/nosql/service.py +3 -1
  152. svc_infra/db/nosql/types.py +6 -2
  153. svc_infra/db/ops.py +384 -0
  154. svc_infra/db/outbox.py +108 -0
  155. svc_infra/db/sql/apikey.py +37 -9
  156. svc_infra/db/sql/authref.py +9 -3
  157. svc_infra/db/sql/constants.py +12 -8
  158. svc_infra/db/sql/core.py +2 -2
  159. svc_infra/db/sql/management.py +11 -8
  160. svc_infra/db/sql/repository.py +99 -26
  161. svc_infra/db/sql/resource.py +5 -0
  162. svc_infra/db/sql/scaffold.py +6 -2
  163. svc_infra/db/sql/service.py +15 -5
  164. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  165. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  166. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  167. svc_infra/db/sql/tenant.py +88 -0
  168. svc_infra/db/sql/uniq_hooks.py +9 -3
  169. svc_infra/db/sql/utils.py +138 -51
  170. svc_infra/db/sql/versioning.py +14 -0
  171. svc_infra/deploy/__init__.py +538 -0
  172. svc_infra/documents/__init__.py +100 -0
  173. svc_infra/documents/add.py +264 -0
  174. svc_infra/documents/ease.py +233 -0
  175. svc_infra/documents/models.py +114 -0
  176. svc_infra/documents/storage.py +264 -0
  177. svc_infra/dx/add.py +65 -0
  178. svc_infra/dx/changelog.py +74 -0
  179. svc_infra/dx/checks.py +68 -0
  180. svc_infra/exceptions.py +141 -0
  181. svc_infra/health/__init__.py +864 -0
  182. svc_infra/http/__init__.py +13 -0
  183. svc_infra/http/client.py +105 -0
  184. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  185. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  186. svc_infra/jobs/easy.py +33 -0
  187. svc_infra/jobs/loader.py +50 -0
  188. svc_infra/jobs/queue.py +116 -0
  189. svc_infra/jobs/redis_queue.py +256 -0
  190. svc_infra/jobs/runner.py +79 -0
  191. svc_infra/jobs/scheduler.py +53 -0
  192. svc_infra/jobs/worker.py +40 -0
  193. svc_infra/loaders/__init__.py +186 -0
  194. svc_infra/loaders/base.py +142 -0
  195. svc_infra/loaders/github.py +311 -0
  196. svc_infra/loaders/models.py +147 -0
  197. svc_infra/loaders/url.py +235 -0
  198. svc_infra/logging/__init__.py +374 -0
  199. svc_infra/mcp/svc_infra_mcp.py +91 -33
  200. svc_infra/obs/README.md +2 -0
  201. svc_infra/obs/add.py +65 -9
  202. svc_infra/obs/cloud_dash.py +2 -1
  203. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  204. svc_infra/obs/metrics/__init__.py +3 -4
  205. svc_infra/obs/metrics/asgi.py +13 -7
  206. svc_infra/obs/metrics/http.py +9 -5
  207. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  208. svc_infra/obs/metrics.py +6 -5
  209. svc_infra/obs/settings.py +6 -2
  210. svc_infra/security/add.py +217 -0
  211. svc_infra/security/audit.py +92 -10
  212. svc_infra/security/audit_service.py +4 -3
  213. svc_infra/security/headers.py +15 -2
  214. svc_infra/security/hibp.py +14 -4
  215. svc_infra/security/jwt_rotation.py +74 -22
  216. svc_infra/security/lockout.py +11 -5
  217. svc_infra/security/models.py +54 -12
  218. svc_infra/security/oauth_models.py +73 -0
  219. svc_infra/security/org_invites.py +5 -3
  220. svc_infra/security/passwords.py +3 -1
  221. svc_infra/security/permissions.py +25 -2
  222. svc_infra/security/session.py +1 -1
  223. svc_infra/security/signed_cookies.py +21 -1
  224. svc_infra/storage/__init__.py +93 -0
  225. svc_infra/storage/add.py +253 -0
  226. svc_infra/storage/backends/__init__.py +11 -0
  227. svc_infra/storage/backends/local.py +339 -0
  228. svc_infra/storage/backends/memory.py +216 -0
  229. svc_infra/storage/backends/s3.py +353 -0
  230. svc_infra/storage/base.py +239 -0
  231. svc_infra/storage/easy.py +185 -0
  232. svc_infra/storage/settings.py +195 -0
  233. svc_infra/testing/__init__.py +685 -0
  234. svc_infra/utils.py +7 -3
  235. svc_infra/webhooks/__init__.py +69 -0
  236. svc_infra/webhooks/add.py +339 -0
  237. svc_infra/webhooks/encryption.py +115 -0
  238. svc_infra/webhooks/fastapi.py +39 -0
  239. svc_infra/webhooks/router.py +55 -0
  240. svc_infra/webhooks/service.py +70 -0
  241. svc_infra/webhooks/signing.py +34 -0
  242. svc_infra/websocket/__init__.py +79 -0
  243. svc_infra/websocket/add.py +140 -0
  244. svc_infra/websocket/client.py +282 -0
  245. svc_infra/websocket/config.py +69 -0
  246. svc_infra/websocket/easy.py +76 -0
  247. svc_infra/websocket/exceptions.py +61 -0
  248. svc_infra/websocket/manager.py +344 -0
  249. svc_infra/websocket/models.py +49 -0
  250. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  251. svc_infra-0.1.706.dist-info/METADATA +356 -0
  252. svc_infra-0.1.706.dist-info/RECORD +357 -0
  253. svc_infra-0.1.595.dist-info/METADATA +0 -80
  254. svc_infra-0.1.595.dist-info/RECORD +0 -253
  255. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  256. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -4,12 +4,18 @@ import importlib
4
4
  import logging
5
5
  import pkgutil
6
6
  from types import ModuleType
7
- from typing import Optional
7
+ from typing import Any, Optional
8
8
 
9
9
  from fastapi import FastAPI
10
10
  from fastapi.routing import APIRoute
11
11
 
12
- from svc_infra.app.env import ALL_ENVIRONMENTS, CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV, Environment
12
+ from svc_infra.app.env import (
13
+ ALL_ENVIRONMENTS,
14
+ CURRENT_ENVIRONMENT,
15
+ DEV_ENV,
16
+ LOCAL_ENV,
17
+ Environment,
18
+ )
13
19
 
14
20
  logger = logging.getLogger(__name__)
15
21
 
@@ -49,7 +55,9 @@ def _validate_base_package(base_package: str) -> ModuleType:
49
55
  try:
50
56
  package_module: ModuleType = importlib.import_module(base_package)
51
57
  except Exception as exc:
52
- raise RuntimeError(f"Could not import base_package '{base_package}': {exc}") from exc
58
+ raise RuntimeError(
59
+ f"Could not import base_package '{base_package}': {exc}"
60
+ ) from exc
53
61
 
54
62
  if not hasattr(package_module, "__path__"):
55
63
  raise RuntimeError(
@@ -64,7 +72,11 @@ def _normalize_environment(environment: Optional[Environment | str]) -> Environm
64
72
  return (
65
73
  CURRENT_ENVIRONMENT
66
74
  if environment is None
67
- else (Environment(environment) if not isinstance(environment, Environment) else environment)
75
+ else (
76
+ Environment(environment)
77
+ if not isinstance(environment, Environment)
78
+ else environment
79
+ )
68
80
  )
69
81
 
70
82
 
@@ -87,9 +99,12 @@ def _is_router_excluded_by_environment(
87
99
 
88
100
  # Support ALL_ENVIRONMENTS as a special value
89
101
  if router_excluded_envs is ALL_ENVIRONMENTS or (
90
- isinstance(router_excluded_envs, set) and router_excluded_envs == ALL_ENVIRONMENTS
102
+ isinstance(router_excluded_envs, set)
103
+ and router_excluded_envs == ALL_ENVIRONMENTS
91
104
  ):
92
- logger.debug(f"Skipping router module {module_name} due to ALL_ENVIRONMENTS exclusion.")
105
+ logger.debug(
106
+ f"Skipping router module {module_name} due to ALL_ENVIRONMENTS exclusion."
107
+ )
93
108
  return True
94
109
 
95
110
  # Normalize to set of Environment or str
@@ -99,14 +114,19 @@ def _is_router_excluded_by_environment(
99
114
  )
100
115
  return False
101
116
 
102
- normalized_excluded_envs = set()
117
+ normalized_excluded_envs: set[Environment | str] = set()
103
118
  for e in router_excluded_envs:
104
119
  try:
105
- normalized_excluded_envs.add(Environment(e) if not isinstance(e, Environment) else e)
120
+ normalized_excluded_envs.add(
121
+ Environment(e) if not isinstance(e, Environment) else e
122
+ )
106
123
  except Exception:
107
124
  normalized_excluded_envs.add(str(e))
108
125
 
109
- if environment in normalized_excluded_envs or str(environment) in normalized_excluded_envs:
126
+ if (
127
+ environment in normalized_excluded_envs
128
+ or str(environment) in normalized_excluded_envs
129
+ ):
110
130
  logger.debug(
111
131
  f"Skipping router module {module_name} due to ROUTER_EXCLUDED_ENVIRONMENTS restriction: {router_excluded_envs}"
112
132
  )
@@ -126,7 +146,7 @@ def _is_router_included_by_environment(
126
146
  f"ROUTER_ENVIRONMENTS in {module_name} must be a set/list/tuple, got {type(router_envs)}"
127
147
  )
128
148
  return True
129
- normalized = set()
149
+ normalized: set[Environment | str] = set()
130
150
  for e in router_envs:
131
151
  try:
132
152
  normalized.add(Environment(e) if not isinstance(e, Environment) else e)
@@ -163,7 +183,7 @@ def _build_include_kwargs(module: ModuleType, prefix: str, force_include: bool)
163
183
  router_tag = getattr(module, "ROUTER_TAG", None)
164
184
  include_in_schema = getattr(module, "INCLUDE_ROUTER_IN_SCHEMA", True)
165
185
 
166
- include_kwargs = {"prefix": prefix}
186
+ include_kwargs: dict[str, Any] = {"prefix": prefix}
167
187
  if router_prefix:
168
188
  include_kwargs["prefix"] = prefix.rstrip("/") + router_prefix
169
189
  if router_tag:
@@ -230,18 +250,24 @@ def register_all_routers(
230
250
  """
231
251
  if base_package is None:
232
252
  if __package__ is None:
233
- raise RuntimeError("Cannot derive base_package; please pass base_package explicitly.")
253
+ raise RuntimeError(
254
+ "Cannot derive base_package; please pass base_package explicitly."
255
+ )
234
256
  base_package = __package__
235
257
 
236
258
  package_module = _validate_base_package(base_package)
237
259
  environment = _normalize_environment(environment)
238
- force_include = _should_force_include_in_schema(environment, force_include_in_schema)
260
+ force_include = _should_force_include_in_schema(
261
+ environment, force_include_in_schema
262
+ )
239
263
 
240
264
  for _, module_name, _ in pkgutil.walk_packages(
241
265
  package_module.__path__, prefix=f"{base_package}."
242
266
  ):
243
267
  if _should_skip_module(module_name):
244
- logger.debug("Skipping router module due to exclusion/private: %s", module_name)
268
+ logger.debug(
269
+ "Skipping router module due to exclusion/private: %s", module_name
270
+ )
245
271
  continue
246
272
 
247
273
  try:
@@ -250,4 +276,6 @@ def register_all_routers(
250
276
  logger.exception("Failed to import router module %s: %s", module_name, exc)
251
277
  continue
252
278
 
253
- _process_router_module(app, module, module_name, prefix, environment, force_include)
279
+ _process_router_module(
280
+ app, module, module_name, prefix, environment, force_include
281
+ )
@@ -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.")
@@ -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,35 +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
- # Strict by default: no CORS unless explicitly configured via env or parameter.
65
- fallback = os.getenv("CORS_ALLOW_ORIGINS", "")
66
- 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)
67
105
 
68
106
  if not origins:
69
107
  return
70
108
 
71
109
  cors_kwargs = dict(allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
110
+
111
+ # Check for "*" (allow all) first
72
112
  if "*" in origins:
73
113
  cors_kwargs["allow_origin_regex"] = ".*"
74
114
  else:
75
- 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 []
76
149
 
77
- app.add_middleware(CORSMiddleware, **cors_kwargs)
78
-
79
-
80
- def _setup_middlewares(app: FastAPI):
81
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)
82
154
  app.add_middleware(CatchAllExceptionMiddleware)
83
- app.add_middleware(IdempotencyMiddleware)
84
- 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)
85
158
  register_error_handlers(app)
86
- _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)
87
162
 
88
163
 
89
164
  def _coerce_list(value: str | Iterable[str] | None) -> list[str]:
@@ -98,23 +173,30 @@ def _dump_or_none(model):
98
173
  return model.model_dump(exclude_none=True) if model is not None else None
99
174
 
100
175
 
101
- def _build_child_app(service: ServiceInfo, spec: APIVersionSpec) -> FastAPI:
102
- 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
+ )
103
182
  child = FastAPI(
104
183
  title=title,
105
184
  version=service.release,
106
185
  contact=_dump_or_none(service.contact),
107
186
  license_info=_dump_or_none(service.license),
108
187
  terms_of_service=service.terms_of_service,
109
- description=service.description,
188
+ description=service.description or "",
110
189
  generate_unique_id_function=_gen_operation_id_factory(),
111
190
  )
112
191
 
113
- _setup_middlewares(child)
192
+ _setup_middlewares(child, skip_paths=skip_paths)
114
193
 
115
194
  # ---- OpenAPI pipeline (DRY!) ----
116
- include_api_key = bool(spec.include_api_key) if spec.include_api_key is not None else False
117
- 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}"
118
200
  server_url = (
119
201
  mount_path
120
202
  if not spec.public_base_url
@@ -131,11 +213,17 @@ def _build_child_app(service: ServiceInfo, spec: APIVersionSpec) -> FastAPI:
131
213
 
132
214
  if spec.routers_package:
133
215
  register_all_routers(
134
- child, base_package=spec.routers_package, prefix="", environment=CURRENT_ENVIRONMENT
216
+ child,
217
+ base_package=spec.routers_package,
218
+ prefix="",
219
+ environment=CURRENT_ENVIRONMENT,
135
220
  )
136
221
 
137
222
  logger.info(
138
- "[%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,
139
227
  )
140
228
  return child
141
229
 
@@ -147,23 +235,25 @@ def _build_parent_app(
147
235
  root_routers: list[str] | str | None,
148
236
  root_server_url: str | None = None,
149
237
  root_include_api_key: bool = False,
238
+ skip_paths: list[str] | None = None,
239
+ **fastapi_kwargs, # Accept FastAPI kwargs
150
240
  ) -> FastAPI:
151
- show_root_docs = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
152
-
241
+ # Root docs are now enabled in all environments to match root card visibility
153
242
  parent = FastAPI(
154
243
  title=service.name,
155
244
  version=service.release,
156
245
  contact=_dump_or_none(service.contact),
157
246
  license_info=_dump_or_none(service.license),
158
247
  terms_of_service=service.terms_of_service,
159
- description=service.description,
160
- docs_url=("/docs" if show_root_docs else None),
161
- redoc_url=("/redoc" if show_root_docs else None),
162
- 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
163
253
  )
164
254
 
165
255
  _setup_cors(parent, public_cors_origins)
166
- _setup_middlewares(parent)
256
+ _setup_middlewares(parent, skip_paths=skip_paths)
167
257
 
168
258
  mutators = setup_mutators(
169
259
  service=service,
@@ -182,23 +272,54 @@ def _build_parent_app(
182
272
  )
183
273
  # app-provided root routers
184
274
  for pkg in _coerce_list(root_routers):
185
- 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
+ )
186
278
 
187
279
  return parent
188
280
 
189
281
 
190
- def _add_route_logger(app: FastAPI):
191
- @app.middleware("http")
192
- async def _log_route(request, call_next):
193
- resp = await call_next(request)
194
- route = request.scope.get("route")
195
- # Prefer FastAPI's path_format (shows param patterns), fall back to path
196
- path = getattr(route, "path_format", None) or getattr(route, "path", None)
197
- if path:
198
- # Include mount root_path so mounted children show their full path
199
- root_path = request.scope.get("root_path", "") or ""
200
- resp.headers["X-Handled-By"] = f"{request.method} {root_path}{path}"
201
- 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)
202
323
 
203
324
 
204
325
  def setup_service_api(
@@ -209,6 +330,8 @@ def setup_service_api(
209
330
  public_cors_origins: list[str] | str | None = None,
210
331
  root_public_base_url: str | None = None,
211
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.)
212
335
  ) -> FastAPI:
213
336
  # infer if not explicitly provided
214
337
  effective_root_include_api_key = (
@@ -224,31 +347,35 @@ def setup_service_api(
224
347
  root_routers=root_routers,
225
348
  root_server_url=root_server,
226
349
  root_include_api_key=effective_root_include_api_key,
350
+ skip_paths=skip_paths,
351
+ **fastapi_kwargs, # Forward to _build_parent_app
227
352
  )
228
353
 
229
354
  # Mount each version
230
355
  for spec in versions:
231
- child = _build_child_app(service, spec)
232
- mount_path = f"/{spec.tag.strip('/')}"
233
- 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)
234
360
 
235
- @parent.get("/", include_in_schema=False)
361
+ @parent.get("/", include_in_schema=False, response_class=HTMLResponse)
236
362
  def index():
237
363
  cards: list[CardSpec] = []
238
364
  is_local_dev = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
239
365
 
240
- if is_local_dev:
241
- # Root card
242
- cards.append(
243
- CardSpec(
244
- tag="",
245
- docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
246
- )
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
+ ),
247
373
  )
374
+ )
248
375
 
249
376
  # Version cards
250
377
  for spec in versions:
251
- tag = spec.tag.strip("/")
378
+ tag = str(spec.tag).strip("/")
252
379
  cards.append(
253
380
  CardSpec(
254
381
  tag=tag,
@@ -266,11 +393,15 @@ def setup_service_api(
266
393
  cards.append(
267
394
  CardSpec(
268
395
  tag=scope.strip("/"),
269
- docs=DocTargets(swagger=swagger, redoc=redoc, openapi_json=openapi_json),
396
+ docs=DocTargets(
397
+ swagger=swagger, redoc=redoc, openapi_json=openapi_json
398
+ ),
270
399
  )
271
400
  )
272
401
 
273
- 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
+ )
274
405
  return HTMLResponse(html)
275
406
 
276
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
+ ]