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
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from typing import Any
6
+
7
+ from fastapi import Request
8
+ from starlette.types import ASGIApp, Receive, Scope, Send
9
+
10
+ from svc_infra.api.fastapi.middleware.errors.handlers import problem_response
11
+ from svc_infra.app.env import pick
12
+
13
+
14
+ def _env_int(name: str, default: int) -> int:
15
+ v = os.getenv(name)
16
+ if v is None:
17
+ return default
18
+ try:
19
+ return int(v)
20
+ except Exception:
21
+ return default
22
+
23
+
24
+ REQUEST_BODY_TIMEOUT_SECONDS: int = pick(
25
+ prod=_env_int("REQUEST_BODY_TIMEOUT_SECONDS", 15),
26
+ nonprod=_env_int("REQUEST_BODY_TIMEOUT_SECONDS", 30),
27
+ )
28
+ REQUEST_TIMEOUT_SECONDS: int = pick(
29
+ prod=_env_int("REQUEST_TIMEOUT_SECONDS", 30),
30
+ nonprod=_env_int("REQUEST_TIMEOUT_SECONDS", 15),
31
+ )
32
+
33
+
34
+ class HandlerTimeoutMiddleware:
35
+ """
36
+ Caps total handler execution time. If exceeded, returns 504 Problem+JSON.
37
+
38
+ Use skip_paths for endpoints that may run longer than the timeout
39
+ (e.g., streaming responses, long-polling, file uploads).
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ app: ASGIApp,
45
+ timeout_seconds: int | None = None,
46
+ skip_paths: list[str] | None = None,
47
+ ) -> None:
48
+ self.app = app
49
+ self.timeout_seconds = (
50
+ timeout_seconds if timeout_seconds is not None else REQUEST_TIMEOUT_SECONDS
51
+ )
52
+ self.skip_paths = skip_paths or []
53
+
54
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
55
+ if scope.get("type") != "http":
56
+ await self.app(scope, receive, send)
57
+ return
58
+
59
+ path = scope.get("path", "")
60
+
61
+ # Skip specified paths (e.g., long-running endpoints)
62
+ if any(skip in path for skip in self.skip_paths):
63
+ await self.app(scope, receive, send)
64
+ return
65
+
66
+ # Track if response has started (headers sent)
67
+ response_started = False
68
+
69
+ async def send_wrapper(message: dict) -> None:
70
+ nonlocal response_started
71
+ if message.get("type") == "http.response.start":
72
+ response_started = True
73
+ await send(message)
74
+
75
+ try:
76
+ await asyncio.wait_for(
77
+ self.app(scope, receive, send_wrapper), # type: ignore[arg-type] # ASGI send signature
78
+ timeout=self.timeout_seconds,
79
+ )
80
+ except asyncio.TimeoutError:
81
+ # Only send 504 if response hasn't started yet
82
+ if not response_started:
83
+ response = problem_response(
84
+ status=504,
85
+ title="Gateway Timeout",
86
+ detail=f"Handler did not complete within {self.timeout_seconds}s",
87
+ )
88
+ await response(scope, receive, send)
89
+ # If response already started, we can't change it - just let it fail
90
+
91
+
92
+ class BodyReadTimeoutMiddleware:
93
+ """
94
+ Enforces a timeout while reading the request body to mitigate slowloris.
95
+ If body read does not make progress within the timeout, returns 408 Problem+JSON.
96
+ """
97
+
98
+ def __init__(self, app: ASGIApp, timeout_seconds: int | None = None) -> None:
99
+ self.app = app
100
+ self.timeout_seconds = (
101
+ timeout_seconds
102
+ if timeout_seconds is not None
103
+ else REQUEST_BODY_TIMEOUT_SECONDS
104
+ )
105
+
106
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
107
+ if scope.get("type") != "http":
108
+ await self.app(scope, receive, send)
109
+ return
110
+
111
+ # Strategy: greedily drain the incoming request body here while enforcing
112
+ # per-receive timeout, then replay it to the downstream app from a buffer.
113
+ # This ensures we can detect slowloris-style uploads even if the app only
114
+ # reads the body later (after the server has finished buffering).
115
+ buffered = bytearray()
116
+
117
+ try:
118
+ while True:
119
+ message = await asyncio.wait_for(
120
+ receive(), timeout=self.timeout_seconds
121
+ )
122
+
123
+ mtype = message.get("type")
124
+ if mtype == "http.request":
125
+ chunk = message.get("body", b"") or b""
126
+ if chunk:
127
+ buffered.extend(chunk)
128
+ # Stop when server indicates no more body
129
+ if not message.get("more_body", False):
130
+ break
131
+ # else: continue reading remaining chunks with timeout
132
+ continue
133
+
134
+ if mtype == "http.disconnect": # client disconnected mid-upload
135
+ # Treat as end of body for the purposes of replay; downstream
136
+ # will see an empty body. No timeout response needed here.
137
+ break
138
+ # Ignore other message types and continue
139
+ except asyncio.TimeoutError:
140
+ # Timed out while waiting for the next body chunk → return 408
141
+ request = Request(scope, receive=receive)
142
+ trace_id = None
143
+ for h in ("x-request-id", "x-correlation-id", "x-trace-id"):
144
+ v = request.headers.get(h)
145
+ if v:
146
+ trace_id = v
147
+ break
148
+ resp = problem_response(
149
+ status=408,
150
+ title="Request Timeout",
151
+ detail="Timed out while reading request body.",
152
+ code="REQUEST_TIMEOUT",
153
+ instance=str(request.url),
154
+ trace_id=trace_id,
155
+ )
156
+ await resp(scope, receive, send)
157
+ return
158
+
159
+ # Replay the drained body to the app as a single http.request message.
160
+ # IMPORTANT: After replaying the body, we must forward the original receive()
161
+ # so that Starlette's listen_for_disconnect can properly detect client disconnects.
162
+ # This is required for streaming responses on ASGI spec < 2.4.
163
+ body_sent = False
164
+
165
+ async def _replay_receive() -> dict[str, Any]:
166
+ nonlocal body_sent
167
+ if not body_sent:
168
+ body_sent = True
169
+ return {
170
+ "type": "http.request",
171
+ "body": bytes(buffered),
172
+ "more_body": False,
173
+ }
174
+ # After body is sent, forward to original receive for disconnect detection
175
+ return dict(await receive())
176
+
177
+ await self.app(scope, _replay_receive, send)
@@ -5,7 +5,9 @@ from typing import Any, Callable
5
5
  from fastapi import APIRouter
6
6
 
7
7
 
8
- def apply_default_security(router: APIRouter, *, default_security: list[dict] | None) -> None:
8
+ def apply_default_security(
9
+ router: APIRouter, *, default_security: list[dict] | None
10
+ ) -> None:
9
11
  if default_security is None:
10
12
  return
11
13
  original_add = router.add_api_route
@@ -17,7 +19,7 @@ def apply_default_security(router: APIRouter, *, default_security: list[dict] |
17
19
  kwargs["openapi_extra"] = ox
18
20
  return original_add(path, endpoint, **kwargs)
19
21
 
20
- router.add_api_route = _wrapped_add_api_route # type: ignore[attr-defined]
22
+ setattr(router, "add_api_route", _wrapped_add_api_route)
21
23
 
22
24
 
23
25
  def apply_default_responses(router: APIRouter, defaults: dict[int, dict]) -> None:
@@ -38,4 +40,4 @@ def apply_default_responses(router: APIRouter, defaults: dict[int, dict]) -> Non
38
40
  kwargs["responses"] = responses
39
41
  return original_add(path, endpoint, **kwargs)
40
42
 
41
- router.add_api_route = _wrapped_add_api_route # type: ignore[attr-defined]
43
+ setattr(router, "add_api_route", _wrapped_add_api_route)
@@ -16,7 +16,11 @@ PROBLEM_SCHEMA: Dict[str, Any] = {
16
16
  "description": "URI identifying the error type",
17
17
  },
18
18
  "title": {"type": "string", "description": "Short, human-readable summary"},
19
- "status": {"type": "integer", "format": "int32", "description": "HTTP status code"},
19
+ "status": {
20
+ "type": "integer",
21
+ "format": "int32",
22
+ "description": "HTTP status code",
23
+ },
20
24
  "detail": {"type": "string", "description": "Human-readable explanation"},
21
25
  "instance": {
22
26
  "type": "string",
@@ -36,7 +40,10 @@ PROBLEM_SCHEMA: Dict[str, Any] = {
36
40
  },
37
41
  },
38
42
  },
39
- "trace_id": {"type": "string", "description": "Correlation/trace id (if available)"},
43
+ "trace_id": {
44
+ "type": "string",
45
+ "description": "Correlation/trace id (if available)",
46
+ },
40
47
  },
41
48
  "required": ["title", "status"],
42
49
  }
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import re
4
- from typing import Dict, Iterable, Iterator, Tuple
4
+ from typing import Callable, Dict, Iterable, Iterator, Tuple
5
5
 
6
6
  from ..auth.security import auth_login_path
7
7
  from .models import APIVersionSpec, ServiceInfo, VersionInfo
@@ -51,7 +51,7 @@ def pagination_components_mutator(
51
51
  *,
52
52
  default_limit: int = 50,
53
53
  max_limit: int = 200,
54
- ) -> callable:
54
+ ) -> Callable[[dict], dict]:
55
55
  """
56
56
  Adds reusable pagination/filtering parameters & paginated envelope schemas.
57
57
  - Cursor: cursor/limit
@@ -196,7 +196,7 @@ def auto_attach_pagination_params_mutator(
196
196
  attach_filters: bool = True,
197
197
  apply_when: str = "array_200",
198
198
  flag_disable: str = "x_no_auto_pagination",
199
- ) -> callable:
199
+ ) -> Callable[[dict], dict]:
200
200
  """
201
201
  Attaches reusable pagination/filter parameters to GET "listy" operations.
202
202
 
@@ -273,7 +273,9 @@ def normalize_problem_and_examples_mutator():
273
273
  if not isinstance(val, dict):
274
274
  return
275
275
  inst = val.get("instance")
276
- if isinstance(inst, str) and (inst.startswith("/") or inst.startswith("about:")):
276
+ if isinstance(inst, str) and (
277
+ inst.startswith("/") or inst.startswith("about:")
278
+ ):
277
279
  # make absolute to satisfy format: uri
278
280
  val["instance"] = ABSOLUTE_INSTANCE
279
281
 
@@ -487,13 +489,18 @@ def ensure_global_tags_mutator(default_desc: str = "Operations related to {tag}.
487
489
  # add missing tags; do NOT override existing descriptions
488
490
  for name in sorted(used):
489
491
  if name not in existing_map:
490
- existing_map[name] = {"name": name, "description": default_desc.format(tag=name)}
492
+ existing_map[name] = {
493
+ "name": name,
494
+ "description": default_desc.format(tag=name),
495
+ }
491
496
  else:
492
497
  if not existing_map[name].get("description"):
493
498
  existing_map[name]["description"] = default_desc.format(tag=name)
494
499
 
495
500
  if existing_map:
496
- schema["tags"] = sorted(existing_map.values(), key=lambda x: x.get("name", ""))
501
+ schema["tags"] = sorted(
502
+ existing_map.values(), key=lambda x: x.get("name", "")
503
+ )
497
504
 
498
505
  return schema
499
506
 
@@ -541,7 +548,7 @@ def attach_standard_responses_mutator(
541
548
 
542
549
 
543
550
  def drop_unused_components_mutator(
544
- drop_responses: list[str] = None, drop_schemas: list[str] = None
551
+ drop_responses: list[str] | None = None, drop_schemas: list[str] | None = None
545
552
  ):
546
553
  drop_responses = drop_responses or []
547
554
  drop_schemas = drop_schemas or []
@@ -640,7 +647,9 @@ def ensure_media_type_schemas_mutator():
640
647
 
641
648
 
642
649
  # ---------- 3) Request body descriptions ----------
643
- def ensure_request_body_descriptions_mutator(default_template="Request body for {method} {path}."):
650
+ def ensure_request_body_descriptions_mutator(
651
+ default_template="Request body for {method} {path}.",
652
+ ):
644
653
  def m(schema: dict) -> dict:
645
654
  schema = dict(schema)
646
655
  for path, method, op in _iter_ops(schema):
@@ -648,7 +657,9 @@ def ensure_request_body_descriptions_mutator(default_template="Request body for
648
657
  if isinstance(rb, dict):
649
658
  desc = rb.get("description")
650
659
  if not isinstance(desc, str) or not desc.strip():
651
- rb["description"] = default_template.format(method=method.upper(), path=path)
660
+ rb["description"] = default_template.format(
661
+ method=method.upper(), path=path
662
+ )
652
663
  return schema
653
664
 
654
665
  return m
@@ -836,7 +847,9 @@ def inject_safe_examples_mutator():
836
847
  """
837
848
 
838
849
  def _has_examples(mt_obj: dict) -> bool:
839
- return isinstance(mt_obj, dict) and ("example" in mt_obj or "examples" in mt_obj)
850
+ return isinstance(mt_obj, dict) and (
851
+ "example" in mt_obj or "examples" in mt_obj
852
+ )
840
853
 
841
854
  def m(schema: dict) -> dict:
842
855
  schema = dict(schema)
@@ -1082,7 +1095,11 @@ def ensure_success_examples_mutator():
1082
1095
  if not (200 <= ic < 300) or ic == 204:
1083
1096
  continue
1084
1097
  mt_obj = (resp.get("content") or {}).get("application/json")
1085
- if not isinstance(mt_obj, dict) or "example" in mt_obj or "examples" in mt_obj:
1098
+ if (
1099
+ not isinstance(mt_obj, dict)
1100
+ or "example" in mt_obj
1101
+ or "examples" in mt_obj
1102
+ ):
1086
1103
  continue
1087
1104
  sch = mt_obj.get("schema") or {}
1088
1105
 
@@ -1102,6 +1119,119 @@ def ensure_success_examples_mutator():
1102
1119
  return m
1103
1120
 
1104
1121
 
1122
+ # --- NEW: attach minimal x-codeSamples for common operations ---
1123
+ def attach_code_samples_mutator():
1124
+ """Attach minimal curl/httpie x-codeSamples for each operation if missing.
1125
+
1126
+ We avoid templating parameters; samples illustrate method and path only.
1127
+ """
1128
+
1129
+ def m(schema: dict) -> dict:
1130
+ schema = dict(schema)
1131
+ servers = schema.get("servers") or [{"url": ""}]
1132
+ base = servers[0].get("url") or ""
1133
+
1134
+ for path, method, op in _iter_ops(schema):
1135
+ # Don't override existing samples
1136
+ if isinstance(op.get("x-codeSamples"), list) and op["x-codeSamples"]:
1137
+ continue
1138
+ url = f"{base}{path}"
1139
+ method_up = method.upper()
1140
+ samples = [
1141
+ {
1142
+ "lang": "bash",
1143
+ "label": "curl",
1144
+ "source": f"curl -X {method_up} '{url}'",
1145
+ },
1146
+ {
1147
+ "lang": "bash",
1148
+ "label": "httpie",
1149
+ "source": f"http {method_up} '{url}'",
1150
+ },
1151
+ ]
1152
+ op["x-codeSamples"] = samples
1153
+ return schema
1154
+
1155
+ return m
1156
+
1157
+
1158
+ # --- NEW: ensure Problem+JSON examples exist for standard error responses ---
1159
+ def ensure_problem_examples_mutator():
1160
+ """Add example objects for 4xx/5xx responses using Problem schema if absent."""
1161
+
1162
+ try:
1163
+ # Internal helper with sensible defaults
1164
+ from .conventions import _problem_example
1165
+ except Exception: # pragma: no cover - fallback
1166
+
1167
+ def _problem_example(**kw): # type: ignore
1168
+ base = {
1169
+ "type": "about:blank",
1170
+ "title": "Error",
1171
+ "status": 500,
1172
+ "detail": "An error occurred.",
1173
+ "instance": "/request/trace",
1174
+ "code": "INTERNAL_ERROR",
1175
+ }
1176
+ base.update(kw)
1177
+ return base
1178
+
1179
+ def m(schema: dict) -> dict:
1180
+ schema = dict(schema)
1181
+ for _, _, op in _iter_ops(schema):
1182
+ resps = op.get("responses") or {}
1183
+ for code, resp in resps.items():
1184
+ if not isinstance(resp, dict):
1185
+ continue
1186
+ try:
1187
+ ic = int(code)
1188
+ except Exception:
1189
+ continue
1190
+ if ic < 400:
1191
+ continue
1192
+ # Do not add content if response is a $ref; avoid creating siblings
1193
+ if "$ref" in resp:
1194
+ continue
1195
+ content = resp.setdefault("content", {})
1196
+ # prefer problem+json but also set application/json if present
1197
+ for mt in ("application/problem+json", "application/json"):
1198
+ mt_obj = content.get(mt)
1199
+ if mt_obj is None:
1200
+ # Create a basic media type referencing Problem schema when appropriate
1201
+ if mt == "application/problem+json":
1202
+ mt_obj = {
1203
+ "schema": {"$ref": "#/components/schemas/Problem"}
1204
+ }
1205
+ content[mt] = mt_obj
1206
+ else:
1207
+ continue
1208
+ if not isinstance(mt_obj, dict):
1209
+ continue
1210
+ if "example" in mt_obj or "examples" in mt_obj:
1211
+ continue
1212
+ mt_obj["example"] = _problem_example(status=ic)
1213
+ return schema
1214
+
1215
+ return m
1216
+
1217
+
1218
+ # --- NEW: attach default tags from first path segment when missing ---
1219
+ def attach_default_tags_mutator():
1220
+ """If an operation has no tags, tag it by its first path segment."""
1221
+
1222
+ def m(schema: dict) -> dict:
1223
+ schema = dict(schema)
1224
+ for path, _method, op in _iter_ops(schema):
1225
+ tags = op.get("tags")
1226
+ if tags:
1227
+ continue
1228
+ seg = path.strip("/").split("/", 1)[0] or "root"
1229
+ op["tags"] = [seg]
1230
+ return schema
1231
+
1232
+ return m
1233
+
1234
+
1105
1235
  def dedupe_tags_mutator():
1106
1236
  def m(schema: dict) -> dict:
1107
1237
  schema = dict(schema)
@@ -1140,7 +1270,7 @@ def scrub_invalid_object_examples_mutator():
1140
1270
  sch = mt_obj.get("schema")
1141
1271
  ex = mt_obj.get("example")
1142
1272
  if "example" in mt_obj and _invalid_object_example(
1143
- sch if isinstance(sch, dict) else {}, ex
1273
+ sch if isinstance(sch, dict) else {}, ex if isinstance(ex, dict) else {}
1144
1274
  ):
1145
1275
  mt_obj.pop("example", None)
1146
1276
 
@@ -1269,17 +1399,20 @@ def hardening_components_mutator():
1269
1399
  },
1270
1400
  )
1271
1401
  headers.setdefault(
1272
- "XRateLimitLimit", {"schema": {"type": "integer"}, "description": "Tokens in window."}
1402
+ "XRateLimitLimit",
1403
+ {"schema": {"type": "integer"}, "description": "Tokens in window."},
1273
1404
  )
1274
1405
  headers.setdefault(
1275
1406
  "XRateLimitRemaining",
1276
1407
  {"schema": {"type": "integer"}, "description": "Remaining tokens."},
1277
1408
  )
1278
1409
  headers.setdefault(
1279
- "XRateLimitReset", {"schema": {"type": "integer"}, "description": "Unix reset time."}
1410
+ "XRateLimitReset",
1411
+ {"schema": {"type": "integer"}, "description": "Unix reset time."},
1280
1412
  )
1281
1413
  headers.setdefault(
1282
- "XRequestId", {"schema": {"type": "string"}, "description": "Correlation id."}
1414
+ "XRequestId",
1415
+ {"schema": {"type": "string"}, "description": "Correlation id."},
1283
1416
  )
1284
1417
  headers.setdefault(
1285
1418
  "Deprecation",
@@ -1290,7 +1423,10 @@ def hardening_components_mutator():
1290
1423
  )
1291
1424
  headers.setdefault(
1292
1425
  "Sunset",
1293
- {"schema": {"type": "string"}, "description": "HTTP-date for deprecation sunset."},
1426
+ {
1427
+ "schema": {"type": "string"},
1428
+ "description": "HTTP-date for deprecation sunset.",
1429
+ },
1294
1430
  )
1295
1431
  return schema
1296
1432
 
@@ -1359,17 +1495,23 @@ def attach_header_params_mutator():
1359
1495
  if 200 <= ic < 300:
1360
1496
  hdrs = resp.setdefault("headers", {})
1361
1497
  hdrs.setdefault("ETag", {"$ref": "#/components/headers/ETag"})
1362
- hdrs.setdefault("Last-Modified", {"$ref": "#/components/headers/LastModified"})
1363
- hdrs.setdefault("X-Request-Id", {"$ref": "#/components/headers/XRequestId"})
1364
1498
  hdrs.setdefault(
1365
- "X-RateLimit-Limit", {"$ref": "#/components/headers/XRateLimitLimit"}
1499
+ "Last-Modified", {"$ref": "#/components/headers/LastModified"}
1500
+ )
1501
+ hdrs.setdefault(
1502
+ "X-Request-Id", {"$ref": "#/components/headers/XRequestId"}
1503
+ )
1504
+ hdrs.setdefault(
1505
+ "X-RateLimit-Limit",
1506
+ {"$ref": "#/components/headers/XRateLimitLimit"},
1366
1507
  )
1367
1508
  hdrs.setdefault(
1368
1509
  "X-RateLimit-Remaining",
1369
1510
  {"$ref": "#/components/headers/XRateLimitRemaining"},
1370
1511
  )
1371
1512
  hdrs.setdefault(
1372
- "X-RateLimit-Reset", {"$ref": "#/components/headers/XRateLimitReset"}
1513
+ "X-RateLimit-Reset",
1514
+ {"$ref": "#/components/headers/XRateLimitReset"},
1373
1515
  )
1374
1516
  if code == "429":
1375
1517
  resp.setdefault("headers", {})["Retry-After"] = {
@@ -1429,6 +1571,9 @@ def setup_mutators(
1429
1571
  ensure_media_type_schemas_mutator(),
1430
1572
  ensure_examples_for_json_mutator(),
1431
1573
  ensure_success_examples_mutator(),
1574
+ attach_default_tags_mutator(),
1575
+ attach_code_samples_mutator(),
1576
+ ensure_problem_examples_mutator(),
1432
1577
  ensure_media_examples_mutator(),
1433
1578
  scrub_invalid_object_examples_mutator(),
1434
1579
  normalize_no_content_204_mutator(),
@@ -23,4 +23,4 @@ def apply_mutators(app: FastAPI, *mutators):
23
23
  app.openapi_schema = schema
24
24
  return schema
25
25
 
26
- app.openapi = patched
26
+ setattr(app, "openapi", patched)
@@ -6,7 +6,9 @@ from .mutators import auth_mutator
6
6
  from .pipeline import apply_mutators
7
7
 
8
8
 
9
- def _normalize_security_list(sec: list | None, *, drop_schemes: set[str] = None) -> list:
9
+ def _normalize_security_list(
10
+ sec: list | None, *, drop_schemes: set[str] | None = None
11
+ ) -> list:
10
12
  if not sec:
11
13
  return []
12
14
  drop_schemes = drop_schemes or set()
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Callable
5
+
6
+ from fastapi import FastAPI, HTTPException, Request
7
+ from starlette.responses import JSONResponse
8
+
9
+
10
+ def add_probes(
11
+ app: FastAPI,
12
+ *,
13
+ prefix: str = "/_ops",
14
+ include_in_schema: bool = False,
15
+ ) -> None:
16
+ """Mount basic liveness/readiness/startup probes under prefix."""
17
+ from svc_infra.api.fastapi.dual.public import public_router
18
+
19
+ router = public_router(
20
+ prefix=prefix, tags=["ops"], include_in_schema=include_in_schema
21
+ )
22
+
23
+ @router.get("/live")
24
+ async def live() -> JSONResponse: # noqa: D401, ANN201
25
+ return JSONResponse({"status": "ok"})
26
+
27
+ @router.get("/ready")
28
+ async def ready() -> JSONResponse: # noqa: D401, ANN201
29
+ # In the future, add checks (DB ping, cache ping) via DI hooks.
30
+ return JSONResponse({"status": "ok"})
31
+
32
+ @router.get("/startup")
33
+ async def startup_probe() -> JSONResponse: # noqa: D401, ANN201
34
+ return JSONResponse({"status": "ok"})
35
+
36
+ app.include_router(router)
37
+
38
+
39
+ def add_maintenance_mode(
40
+ app: FastAPI,
41
+ *,
42
+ env_var: str = "MAINTENANCE_MODE",
43
+ exempt_prefixes: tuple[str, ...] | None = None,
44
+ ) -> None:
45
+ """Enable a simple maintenance gate controlled by an env var.
46
+
47
+ When MAINTENANCE_MODE is truthy, all non-GET requests return 503.
48
+ """
49
+
50
+ @app.middleware("http")
51
+ async def _maintenance_gate(request: Request, call_next): # noqa: ANN001, ANN202
52
+ flag = str(os.getenv(env_var, "")).lower() in {"1", "true", "yes", "on"}
53
+ if flag and request.method not in {"GET", "HEAD", "OPTIONS"}:
54
+ path = request.scope.get("path", "")
55
+ if exempt_prefixes and any(path.startswith(p) for p in exempt_prefixes):
56
+ return await call_next(request)
57
+ return JSONResponse({"detail": "maintenance"}, status_code=503)
58
+ return await call_next(request)
59
+
60
+
61
+ def circuit_breaker_dependency(limit: int = 100, window_seconds: int = 60) -> Callable:
62
+ """Return a dependency that can trip rejective errors based on external metrics.
63
+
64
+ This is a placeholder; callers can swap with a provider that tracks failures and opens the
65
+ breaker. Here, we read an env var to simulate an open breaker.
66
+ """
67
+
68
+ async def _dep(_: Request) -> None: # noqa: D401, ANN202
69
+ if str(os.getenv("CIRCUIT_OPEN", "")).lower() in {"1", "true", "yes", "on"}:
70
+ raise HTTPException(status_code=503, detail="circuit open")
71
+
72
+ return _dep
73
+
74
+
75
+ __all__ = ["add_probes", "add_maintenance_mode", "circuit_breaker_dependency"]