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
@@ -20,3 +20,28 @@ def public_router(**kwargs: Any) -> DualAPIRouter:
20
20
  apply_default_responses(r, DEFAULT_PUBLIC)
21
21
 
22
22
  return r
23
+
24
+
25
+ def ws_public_router(**kwargs: Any) -> DualAPIRouter:
26
+ """
27
+ Public WebSocket router: no auth dependencies.
28
+
29
+ Use this for WebSocket endpoints that don't require authentication.
30
+ This is the WebSocket equivalent of `public_router()`.
31
+
32
+ Example:
33
+ router = ws_public_router(prefix="/api")
34
+
35
+ @router.websocket("/ws/public")
36
+ async def ws_endpoint(websocket: WebSocket):
37
+ await websocket.accept()
38
+ # No auth required - anyone can connect
39
+ async for msg in websocket.iter_json():
40
+ await websocket.send_json({"echo": msg})
41
+ """
42
+ r = DualAPIRouter(**kwargs)
43
+
44
+ # Keep OpenAPI consistent - no security requirement
45
+ apply_default_security(r, default_security=[])
46
+
47
+ return r
@@ -37,7 +37,11 @@ class DualAPIRouter(APIRouter):
37
37
  if is_rootish:
38
38
  # primary root
39
39
  self.add_api_route(
40
- "", func, methods=methods, include_in_schema=show_in_schema, **kwargs
40
+ "",
41
+ func,
42
+ methods=methods,
43
+ include_in_schema=show_in_schema,
44
+ **kwargs,
41
45
  )
42
46
  # only add the "/" twin for *safe* methods
43
47
  if set(m.upper() for m in methods) <= safe_methods:
@@ -48,10 +52,16 @@ class DualAPIRouter(APIRouter):
48
52
 
49
53
  # non-root unchanged
50
54
  self.add_api_route(
51
- primary, func, methods=methods, include_in_schema=show_in_schema, **kwargs
55
+ primary,
56
+ func,
57
+ methods=methods,
58
+ include_in_schema=show_in_schema,
59
+ **kwargs,
52
60
  )
53
61
  if alt != primary:
54
- self.add_api_route(alt, func, methods=methods, include_in_schema=False, **kwargs)
62
+ self.add_api_route(
63
+ alt, func, methods=methods, include_in_schema=False, **kwargs
64
+ )
55
65
  return func
56
66
 
57
67
  return decorator
@@ -68,25 +78,39 @@ class DualAPIRouter(APIRouter):
68
78
  # ---------- HTTP method shorthands ----------
69
79
 
70
80
  def get(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
71
- return self._dual_decorator(path, ["GET"], show_in_schema=show_in_schema, **kwargs)
81
+ return self._dual_decorator(
82
+ path, ["GET"], show_in_schema=show_in_schema, **kwargs
83
+ )
72
84
 
73
85
  def post(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
74
- return self._dual_decorator(path, ["POST"], show_in_schema=show_in_schema, **kwargs)
86
+ return self._dual_decorator(
87
+ path, ["POST"], show_in_schema=show_in_schema, **kwargs
88
+ )
75
89
 
76
90
  def patch(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
77
- return self._dual_decorator(path, ["PATCH"], show_in_schema=show_in_schema, **kwargs)
91
+ return self._dual_decorator(
92
+ path, ["PATCH"], show_in_schema=show_in_schema, **kwargs
93
+ )
78
94
 
79
95
  def delete(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
80
- return self._dual_decorator(path, ["DELETE"], show_in_schema=show_in_schema, **kwargs)
96
+ return self._dual_decorator(
97
+ path, ["DELETE"], show_in_schema=show_in_schema, **kwargs
98
+ )
81
99
 
82
100
  def put(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
83
- return self._dual_decorator(path, ["PUT"], show_in_schema=show_in_schema, **kwargs)
101
+ return self._dual_decorator(
102
+ path, ["PUT"], show_in_schema=show_in_schema, **kwargs
103
+ )
84
104
 
85
105
  def options(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
86
- return self._dual_decorator(path, ["OPTIONS"], show_in_schema=show_in_schema, **kwargs)
106
+ return self._dual_decorator(
107
+ path, ["OPTIONS"], show_in_schema=show_in_schema, **kwargs
108
+ )
87
109
 
88
110
  def head(self, path: str, *_, show_in_schema: bool = True, **kwargs: Any):
89
- return self._dual_decorator(path, ["HEAD"], show_in_schema=show_in_schema, **kwargs)
111
+ return self._dual_decorator(
112
+ path, ["HEAD"], show_in_schema=show_in_schema, **kwargs
113
+ )
90
114
 
91
115
  def list(
92
116
  self,
@@ -110,10 +134,11 @@ class DualAPIRouter(APIRouter):
110
134
  - Per-route opt-out of OpenAPI param auto-attach: openapi_extra={"x_no_auto_pagination": True}
111
135
  """
112
136
  # pick response model
137
+ response_model: Any
113
138
  if envelope:
114
- response_model = Paginated[model] # type: ignore[index]
139
+ response_model = Paginated[model] # type: ignore[valid-type]
115
140
  else:
116
- response_model = list[model] # type: ignore[index]
141
+ response_model = list[model] # type: ignore[valid-type]
117
142
 
118
143
  injector = make_pagination_injector(
119
144
  envelope=envelope,
@@ -130,7 +155,9 @@ class DualAPIRouter(APIRouter):
130
155
  kwargs["response_model"] = kwargs.get("response_model") or response_model
131
156
 
132
157
  # we still want the dual-registration behavior
133
- return self._dual_decorator(path, ["GET"], show_in_schema=show_in_schema, **kwargs)
158
+ return self._dual_decorator(
159
+ path, ["GET"], show_in_schema=show_in_schema, **kwargs
160
+ )
134
161
 
135
162
  # ---------- WebSocket ----------
136
163
 
@@ -59,19 +59,36 @@ from svc_infra.api.fastapi.auth.security import (
59
59
  RequireUser,
60
60
  )
61
61
  from svc_infra.api.fastapi.auth.settings import AuthSettings, get_auth_settings
62
- from svc_infra.api.fastapi.dual.protected import (
62
+
63
+ # ----------------
64
+ # WebSocket identity primitives (lightweight JWT, no DB required)
65
+ # ----------------
66
+ from svc_infra.api.fastapi.auth.ws_security import (
67
+ AllowWSIdentity,
68
+ OptionalWSIdentity,
69
+ RequireWSAnyScope,
70
+ RequireWSIdentity,
71
+ RequireWSScopes,
72
+ WSIdentity,
73
+ WSPrincipal,
74
+ )
75
+ from svc_infra.api.fastapi.dual.protected import ( # WebSocket routers (DualAPIRouter with JWT auth, no DB required)
63
76
  optional_identity_router,
64
77
  protected_router,
65
78
  roles_router,
66
79
  scopes_router,
67
80
  service_router,
68
81
  user_router,
82
+ ws_optional_router,
83
+ ws_protected_router,
84
+ ws_scopes_router,
85
+ ws_user_router,
69
86
  )
70
87
 
71
88
  # ----------------
72
89
  # Pre-wired routers (OpenAPI security auto-injected)
73
90
  # ----------------
74
- from svc_infra.api.fastapi.dual.public import public_router
91
+ from svc_infra.api.fastapi.dual.public import public_router, ws_public_router
75
92
 
76
93
  # ----------------
77
94
  # App bootstrap
@@ -127,6 +144,20 @@ __all__ = [
127
144
  "service_router",
128
145
  "scopes_router",
129
146
  "roles_router",
147
+ # WebSocket identity
148
+ "WSPrincipal",
149
+ "WSIdentity",
150
+ "OptionalWSIdentity",
151
+ "RequireWSIdentity",
152
+ "AllowWSIdentity",
153
+ "RequireWSScopes",
154
+ "RequireWSAnyScope",
155
+ # WebSocket routers
156
+ "ws_public_router",
157
+ "ws_protected_router",
158
+ "ws_user_router",
159
+ "ws_scopes_router",
160
+ "ws_optional_router",
130
161
  # Feature routers
131
162
  "apikey_router",
132
163
  "mfa_router",
@@ -104,9 +104,13 @@ class EasyAppOptions(BaseModel):
104
104
  else self.logging.enable
105
105
  ),
106
106
  level=(
107
- override.logging.level if override.logging.level is not None else self.logging.level
107
+ override.logging.level
108
+ if override.logging.level is not None
109
+ else self.logging.level
108
110
  ),
109
- fmt=override.logging.fmt if override.logging.fmt is not None else self.logging.fmt,
111
+ fmt=override.logging.fmt
112
+ if override.logging.fmt is not None
113
+ else self.logging.fmt,
110
114
  )
111
115
 
112
116
  # observability
@@ -148,6 +152,7 @@ def easy_service_api(
148
152
  public_cors_origins: list[str] | str | None = None,
149
153
  root_public_base_url: str | None = None,
150
154
  root_include_api_key: bool | None = None,
155
+ **fastapi_kwargs, # Forward all other FastAPI kwargs
151
156
  ) -> FastAPI:
152
157
  service = ServiceInfo(name=name, release=release)
153
158
  specs = [
@@ -161,6 +166,7 @@ def easy_service_api(
161
166
  public_cors_origins=public_cors_origins,
162
167
  root_public_base_url=root_public_base_url,
163
168
  root_include_api_key=root_include_api_key,
169
+ **fastapi_kwargs, # Forward to setup_service_api
164
170
  )
165
171
 
166
172
 
@@ -176,6 +182,7 @@ def easy_service_app(
176
182
  options: EasyAppOptions | None = None,
177
183
  enable_logging: bool | None = None,
178
184
  enable_observability: bool | None = None,
185
+ **fastapi_kwargs, # Forward all other FastAPI kwargs (lifespan, etc.)
179
186
  ) -> FastAPI:
180
187
  """
181
188
  One-call bootstrap with env + options + flags:
@@ -226,6 +233,7 @@ def easy_service_app(
226
233
  public_cors_origins=public_cors_origins,
227
234
  root_public_base_url=root_public_base_url,
228
235
  root_include_api_key=root_include_api_key,
236
+ **fastapi_kwargs, # Forward FastAPI kwargs (lifespan, etc.)
229
237
  )
230
238
 
231
239
  # 5) Observability
@@ -10,5 +10,6 @@ def require_if_match(request: Request, current_etag: str):
10
10
  )
11
11
  if current_etag not in [t.strip() for t in val.split(",")]:
12
12
  raise HTTPException(
13
- status_code=status.HTTP_412_PRECONDITION_FAILED, detail="ETag precondition failed."
13
+ status_code=status.HTTP_412_PRECONDITION_FAILED,
14
+ detail="ETag precondition failed.",
14
15
  )
@@ -20,7 +20,9 @@ def set_conditional_headers(
20
20
  resp.headers["Last-Modified"] = format_datetime(last_modified)
21
21
 
22
22
 
23
- def maybe_not_modified(request: Request, etag: str | None, last_modified: datetime | None) -> bool:
23
+ def maybe_not_modified(
24
+ request: Request, etag: str | None, last_modified: datetime | None
25
+ ) -> bool:
24
26
  inm = request.headers.get("If-None-Match")
25
27
  ims = request.headers.get("If-Modified-Since")
26
28
  etag_ok = etag and inm and etag in [t.strip() for t in inm.split(",")]
@@ -15,6 +15,9 @@ class RouteDebugMiddleware(BaseHTTPMiddleware):
15
15
  route = request.scope.get("route")
16
16
  ep = getattr(route, "endpoint", None) if route else None
17
17
  log.info(
18
- "MATCHED %s %s -> %s", request.method, request.url.path, getattr(ep, "__name__", ep)
18
+ "MATCHED %s %s -> %s",
19
+ request.method,
20
+ request.url.path,
21
+ getattr(ep, "__name__", ep),
19
22
  )
20
23
  return response
@@ -31,7 +31,9 @@ class CatchAllExceptionMiddleware:
31
31
 
32
32
  if response_started:
33
33
  try:
34
- await send({"type": "http.response.body", "body": b"", "more_body": False})
34
+ await send(
35
+ {"type": "http.response.body", "body": b"", "more_body": False}
36
+ )
35
37
  except Exception:
36
38
  pass
37
39
  else:
@@ -52,4 +54,6 @@ class CatchAllExceptionMiddleware:
52
54
  "headers": [(b"content-type", PROBLEM_MT.encode("ascii"))],
53
55
  }
54
56
  )
55
- await send({"type": "http.response.body", "body": body, "more_body": False})
57
+ await send(
58
+ {"type": "http.response.body", "body": body, "more_body": False}
59
+ )
@@ -12,7 +12,7 @@ class FastApiException(Exception):
12
12
  detail: Optional[str] = None,
13
13
  status_code: int = 400,
14
14
  *,
15
- code: str | None = None
15
+ code: str | None = None,
16
16
  ):
17
17
  self.title = title
18
18
  self.detail = detail
@@ -2,6 +2,7 @@ import logging
2
2
  import traceback
3
3
  from typing import Any, Dict, Optional
4
4
 
5
+ import httpx
5
6
  from fastapi import Request
6
7
  from fastapi.exceptions import HTTPException, RequestValidationError
7
8
  from fastapi.responses import JSONResponse, Response
@@ -46,6 +47,7 @@ def problem_response(
46
47
  code: str | None = None,
47
48
  errors: list[dict] | None = None,
48
49
  trace_id: str | None = None,
50
+ headers: dict[str, str] | None = None,
49
51
  ) -> Response:
50
52
  body: Dict[str, Any] = {
51
53
  "type": type_uri,
@@ -62,10 +64,30 @@ def problem_response(
62
64
  body["errors"] = errors
63
65
  if trace_id:
64
66
  body["trace_id"] = trace_id
65
- return JSONResponse(status_code=status, content=body, media_type=PROBLEM_MT)
67
+ return JSONResponse(
68
+ status_code=status, content=body, media_type=PROBLEM_MT, headers=headers
69
+ )
66
70
 
67
71
 
68
72
  def register_error_handlers(app):
73
+ @app.exception_handler(httpx.TimeoutException)
74
+ async def handle_httpx_timeout(request: Request, exc: httpx.TimeoutException):
75
+ trace_id = _trace_id_from_request(request)
76
+ # Map outbound HTTP client timeouts to 504 Gateway Timeout
77
+ # Keep details generic in prod
78
+ return problem_response(
79
+ status=504,
80
+ title="Gateway Timeout",
81
+ detail=(
82
+ "Upstream request timed out."
83
+ if IS_PROD
84
+ else (str(exc) or "httpx timeout")
85
+ ),
86
+ code="GATEWAY_TIMEOUT",
87
+ instance=str(request.url),
88
+ trace_id=trace_id,
89
+ )
90
+
69
91
  @app.exception_handler(FastApiException)
70
92
  async def handle_app_exception(request: Request, exc: FastApiException):
71
93
  trace_id = _trace_id_from_request(request)
@@ -104,14 +126,25 @@ def register_error_handlers(app):
104
126
  @app.exception_handler(HTTPException)
105
127
  async def handle_http_exception(request: Request, exc: HTTPException):
106
128
  trace_id = _trace_id_from_request(request)
107
- title = {401: "Unauthorized", 403: "Forbidden", 404: "Not Found"}.get(
108
- exc.status_code, "Error"
109
- )
129
+ title = {
130
+ 401: "Unauthorized",
131
+ 403: "Forbidden",
132
+ 404: "Not Found",
133
+ 429: "Too Many Requests",
134
+ }.get(exc.status_code, "Error")
110
135
  detail = (
111
136
  exc.detail
112
137
  if not IS_PROD or exc.status_code < 500
113
138
  else "Something went wrong. Please contact support."
114
139
  )
140
+ # Preserve headers set on the exception (e.g., Retry-After for rate limits)
141
+ hdrs: dict[str, str] | None = None
142
+ try:
143
+ if getattr(exc, "headers", None):
144
+ # FastAPI/Starlette exceptions store headers as a dict[str, str]
145
+ hdrs = dict(getattr(exc, "headers"))
146
+ except Exception:
147
+ hdrs = None
115
148
  return problem_response(
116
149
  status=exc.status_code,
117
150
  title=title,
@@ -119,19 +152,31 @@ def register_error_handlers(app):
119
152
  code=title.replace(" ", "_").upper(),
120
153
  instance=str(request.url),
121
154
  trace_id=trace_id,
155
+ headers=hdrs,
122
156
  )
123
157
 
124
158
  @app.exception_handler(StarletteHTTPException)
125
- async def handle_starlette_http_exception(request: Request, exc: StarletteHTTPException):
159
+ async def handle_starlette_http_exception(
160
+ request: Request, exc: StarletteHTTPException
161
+ ):
126
162
  trace_id = _trace_id_from_request(request)
127
- title = {401: "Unauthorized", 403: "Forbidden", 404: "Not Found"}.get(
128
- exc.status_code, "Error"
129
- )
163
+ title = {
164
+ 401: "Unauthorized",
165
+ 403: "Forbidden",
166
+ 404: "Not Found",
167
+ 429: "Too Many Requests",
168
+ }.get(exc.status_code, "Error")
130
169
  detail = (
131
170
  exc.detail
132
171
  if not IS_PROD or exc.status_code < 500
133
172
  else "Something went wrong. Please contact support."
134
173
  )
174
+ hdrs: dict[str, str] | None = None
175
+ try:
176
+ if getattr(exc, "headers", None):
177
+ hdrs = dict(getattr(exc, "headers"))
178
+ except Exception:
179
+ hdrs = None
135
180
  return problem_response(
136
181
  status=exc.status_code,
137
182
  title=title,
@@ -139,6 +184,7 @@ def register_error_handlers(app):
139
184
  code=title.replace(" ", "_").upper(),
140
185
  instance=str(request.url),
141
186
  trace_id=trace_id,
187
+ headers=hdrs,
142
188
  )
143
189
 
144
190
  @app.exception_handler(IntegrityError)
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ from contextlib import asynccontextmanager
7
+ from typing import Optional
8
+
9
+ from fastapi import FastAPI
10
+ from starlette.types import ASGIApp, Receive, Scope, Send
11
+
12
+ from svc_infra.app.env import pick
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _get_grace_period_seconds() -> float:
18
+ default = pick(prod=20.0, nonprod=5.0)
19
+ raw = os.getenv("SHUTDOWN_GRACE_PERIOD_SECONDS")
20
+ if raw is None or raw == "":
21
+ return float(default)
22
+ try:
23
+ return float(raw)
24
+ except ValueError:
25
+ return float(default)
26
+
27
+
28
+ class InflightTrackerMiddleware:
29
+ """Tracks number of in-flight requests to support graceful shutdown drains."""
30
+
31
+ def __init__(self, app: ASGIApp):
32
+ self.app = app
33
+
34
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
35
+ if scope.get("type") != "http":
36
+ await self.app(scope, receive, send)
37
+ return
38
+ app = scope.get("app")
39
+ if app is None:
40
+ await self.app(scope, receive, send)
41
+ return
42
+ state = getattr(app, "state", None)
43
+ if state is None:
44
+ await self.app(scope, receive, send)
45
+ return
46
+ state._inflight_requests = getattr(state, "_inflight_requests", 0) + 1
47
+ try:
48
+ await self.app(scope, receive, send)
49
+ finally:
50
+ state._inflight_requests = max(
51
+ 0, getattr(state, "_inflight_requests", 1) - 1
52
+ )
53
+
54
+
55
+ async def _wait_for_drain(app: FastAPI, grace: float) -> None:
56
+ interval = 0.1
57
+ waited = 0.0
58
+ while waited < grace:
59
+ inflight = int(getattr(app.state, "_inflight_requests", 0))
60
+ if inflight <= 0:
61
+ return
62
+ await asyncio.sleep(interval)
63
+ waited += interval
64
+ inflight = int(getattr(app.state, "_inflight_requests", 0))
65
+ if inflight > 0:
66
+ logger.warning(
67
+ "Graceful shutdown timeout: %s in-flight request(s) after %.2fs",
68
+ inflight,
69
+ waited,
70
+ )
71
+
72
+
73
+ def install_graceful_shutdown(
74
+ app: FastAPI, *, grace_seconds: Optional[float] = None
75
+ ) -> None:
76
+ """Install inflight tracking and lifespan hooks to wait for requests to drain.
77
+
78
+ - Adds InflightTrackerMiddleware
79
+ - Registers a lifespan handler that initializes state and waits up to grace_seconds on shutdown
80
+ """
81
+ app.add_middleware(InflightTrackerMiddleware)
82
+
83
+ g = (
84
+ float(grace_seconds)
85
+ if grace_seconds is not None
86
+ else _get_grace_period_seconds()
87
+ )
88
+
89
+ # Preserve any existing lifespan and wrap it so our drain runs on shutdown.
90
+ previous_lifespan = getattr(app.router, "lifespan_context", None)
91
+
92
+ @asynccontextmanager
93
+ async def _lifespan(a: FastAPI): # noqa: ANN202
94
+ # Startup: initialize inflight counter
95
+ a.state._inflight_requests = 0
96
+ if previous_lifespan is not None:
97
+ async with previous_lifespan(a):
98
+ yield
99
+ else:
100
+ yield
101
+ # Shutdown: wait for in-flight requests to drain (up to grace period)
102
+ await _wait_for_drain(a, g)
103
+
104
+ app.router.lifespan_context = _lifespan