svc-infra 0.1.595__py3-none-any.whl → 1.1.0__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 (274) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +68 -38
  3. svc_infra/apf_payments/provider/__init__.py +2 -2
  4. svc_infra/apf_payments/provider/aiydan.py +39 -23
  5. svc_infra/apf_payments/provider/base.py +8 -3
  6. svc_infra/apf_payments/provider/registry.py +3 -5
  7. svc_infra/apf_payments/provider/stripe.py +74 -52
  8. svc_infra/apf_payments/schemas.py +84 -83
  9. svc_infra/apf_payments/service.py +27 -16
  10. svc_infra/apf_payments/settings.py +12 -11
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +34 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +240 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +94 -73
  16. svc_infra/api/fastapi/apf_payments/setup.py +10 -9
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +1 -3
  19. svc_infra/api/fastapi/auth/add.py +14 -15
  20. svc_infra/api/fastapi/auth/gaurd.py +32 -20
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -4
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
  23. svc_infra/api/fastapi/auth/mfa/router.py +9 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +4 -7
  25. svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
  26. svc_infra/api/fastapi/auth/policy.py +0 -1
  27. svc_infra/api/fastapi/auth/providers.py +3 -3
  28. svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
  29. svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
  30. svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
  31. svc_infra/api/fastapi/auth/security.py +25 -15
  32. svc_infra/api/fastapi/auth/sender.py +5 -0
  33. svc_infra/api/fastapi/auth/settings.py +18 -19
  34. svc_infra/api/fastapi/auth/state.py +5 -4
  35. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  36. svc_infra/api/fastapi/billing/router.py +71 -0
  37. svc_infra/api/fastapi/billing/setup.py +19 -0
  38. svc_infra/api/fastapi/cache/add.py +9 -5
  39. svc_infra/api/fastapi/db/__init__.py +5 -1
  40. svc_infra/api/fastapi/db/http.py +10 -9
  41. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  42. svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
  43. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
  44. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  45. svc_infra/api/fastapi/db/sql/add.py +62 -25
  46. svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
  47. svc_infra/api/fastapi/db/sql/session.py +19 -2
  48. svc_infra/api/fastapi/db/sql/users.py +18 -9
  49. svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
  50. svc_infra/api/fastapi/docs/add.py +163 -0
  51. svc_infra/api/fastapi/docs/landing.py +6 -6
  52. svc_infra/api/fastapi/docs/scoped.py +75 -36
  53. svc_infra/api/fastapi/dual/__init__.py +12 -2
  54. svc_infra/api/fastapi/dual/dualize.py +2 -2
  55. svc_infra/api/fastapi/dual/protected.py +123 -10
  56. svc_infra/api/fastapi/dual/public.py +25 -0
  57. svc_infra/api/fastapi/dual/router.py +18 -8
  58. svc_infra/api/fastapi/dx.py +33 -2
  59. svc_infra/api/fastapi/ease.py +59 -7
  60. svc_infra/api/fastapi/http/concurrency.py +2 -1
  61. svc_infra/api/fastapi/http/conditional.py +2 -2
  62. svc_infra/api/fastapi/middleware/debug.py +4 -1
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +190 -68
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
  71. svc_infra/api/fastapi/middleware/request_id.py +24 -10
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +176 -0
  74. svc_infra/api/fastapi/object_router.py +1060 -0
  75. svc_infra/api/fastapi/openapi/apply.py +4 -3
  76. svc_infra/api/fastapi/openapi/conventions.py +13 -6
  77. svc_infra/api/fastapi/openapi/mutators.py +144 -17
  78. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  79. svc_infra/api/fastapi/openapi/responses.py +4 -6
  80. svc_infra/api/fastapi/openapi/security.py +1 -1
  81. svc_infra/api/fastapi/ops/add.py +73 -0
  82. svc_infra/api/fastapi/pagination.py +47 -32
  83. svc_infra/api/fastapi/routers/__init__.py +16 -10
  84. svc_infra/api/fastapi/routers/ping.py +1 -0
  85. svc_infra/api/fastapi/setup.py +167 -54
  86. svc_infra/api/fastapi/tenancy/add.py +20 -0
  87. svc_infra/api/fastapi/tenancy/context.py +113 -0
  88. svc_infra/api/fastapi/versioned.py +102 -0
  89. svc_infra/app/README.md +5 -5
  90. svc_infra/app/__init__.py +3 -1
  91. svc_infra/app/env.py +70 -4
  92. svc_infra/app/logging/add.py +10 -2
  93. svc_infra/app/logging/filter.py +1 -1
  94. svc_infra/app/logging/formats.py +13 -5
  95. svc_infra/app/root.py +3 -3
  96. svc_infra/billing/__init__.py +40 -0
  97. svc_infra/billing/async_service.py +167 -0
  98. svc_infra/billing/jobs.py +231 -0
  99. svc_infra/billing/models.py +146 -0
  100. svc_infra/billing/quotas.py +101 -0
  101. svc_infra/billing/schemas.py +34 -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 +21 -5
  106. svc_infra/cache/add.py +167 -0
  107. svc_infra/cache/backend.py +9 -7
  108. svc_infra/cache/decorators.py +75 -20
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +26 -6
  111. svc_infra/cache/recache.py +26 -27
  112. svc_infra/cache/resources.py +6 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/ttl.py +2 -3
  115. svc_infra/cache/utils.py +4 -3
  116. svc_infra/cli/__init__.py +44 -8
  117. svc_infra/cli/__main__.py +4 -0
  118. svc_infra/cli/cmds/__init__.py +39 -2
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
  120. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
  121. svc_infra/cli/cmds/db/ops_cmds.py +267 -0
  122. svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
  123. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  124. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
  125. svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
  126. svc_infra/cli/cmds/dx/__init__.py +12 -0
  127. svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
  128. svc_infra/cli/cmds/health/__init__.py +179 -0
  129. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  130. svc_infra/cli/cmds/help.py +4 -0
  131. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  132. svc_infra/cli/cmds/jobs/jobs_cmds.py +42 -0
  133. svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
  134. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  135. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  136. svc_infra/cli/foundation/runner.py +4 -5
  137. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  138. svc_infra/data/__init__.py +83 -0
  139. svc_infra/data/add.py +61 -0
  140. svc_infra/data/backup.py +56 -0
  141. svc_infra/data/erasure.py +46 -0
  142. svc_infra/data/fixtures.py +42 -0
  143. svc_infra/data/retention.py +56 -0
  144. svc_infra/db/__init__.py +15 -0
  145. svc_infra/db/crud_schema.py +14 -13
  146. svc_infra/db/inbox.py +67 -0
  147. svc_infra/db/nosql/__init__.py +2 -0
  148. svc_infra/db/nosql/constants.py +1 -1
  149. svc_infra/db/nosql/core.py +19 -5
  150. svc_infra/db/nosql/indexes.py +12 -9
  151. svc_infra/db/nosql/management.py +4 -4
  152. svc_infra/db/nosql/mongo/README.md +13 -13
  153. svc_infra/db/nosql/mongo/client.py +21 -4
  154. svc_infra/db/nosql/mongo/settings.py +1 -1
  155. svc_infra/db/nosql/repository.py +46 -27
  156. svc_infra/db/nosql/resource.py +28 -16
  157. svc_infra/db/nosql/scaffold.py +14 -12
  158. svc_infra/db/nosql/service.py +2 -1
  159. svc_infra/db/nosql/service_with_hooks.py +4 -3
  160. svc_infra/db/nosql/utils.py +4 -4
  161. svc_infra/db/ops.py +380 -0
  162. svc_infra/db/outbox.py +105 -0
  163. svc_infra/db/sql/apikey.py +34 -15
  164. svc_infra/db/sql/authref.py +8 -6
  165. svc_infra/db/sql/constants.py +5 -1
  166. svc_infra/db/sql/core.py +13 -13
  167. svc_infra/db/sql/management.py +5 -6
  168. svc_infra/db/sql/repository.py +92 -26
  169. svc_infra/db/sql/resource.py +18 -12
  170. svc_infra/db/sql/scaffold.py +11 -11
  171. svc_infra/db/sql/service.py +2 -1
  172. svc_infra/db/sql/service_with_hooks.py +4 -3
  173. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  174. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  175. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  176. svc_infra/db/sql/tenant.py +80 -0
  177. svc_infra/db/sql/uniq.py +8 -7
  178. svc_infra/db/sql/uniq_hooks.py +12 -11
  179. svc_infra/db/sql/utils.py +105 -47
  180. svc_infra/db/sql/versioning.py +14 -0
  181. svc_infra/db/utils.py +3 -3
  182. svc_infra/deploy/__init__.py +531 -0
  183. svc_infra/documents/__init__.py +100 -0
  184. svc_infra/documents/add.py +263 -0
  185. svc_infra/documents/ease.py +233 -0
  186. svc_infra/documents/models.py +114 -0
  187. svc_infra/documents/storage.py +262 -0
  188. svc_infra/dx/__init__.py +58 -0
  189. svc_infra/dx/add.py +63 -0
  190. svc_infra/dx/changelog.py +74 -0
  191. svc_infra/dx/checks.py +68 -0
  192. svc_infra/exceptions.py +141 -0
  193. svc_infra/health/__init__.py +863 -0
  194. svc_infra/http/__init__.py +13 -0
  195. svc_infra/http/client.py +101 -0
  196. svc_infra/jobs/__init__.py +79 -0
  197. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  198. svc_infra/jobs/builtins/webhook_delivery.py +93 -0
  199. svc_infra/jobs/easy.py +33 -0
  200. svc_infra/jobs/loader.py +49 -0
  201. svc_infra/jobs/queue.py +106 -0
  202. svc_infra/jobs/redis_queue.py +242 -0
  203. svc_infra/jobs/runner.py +75 -0
  204. svc_infra/jobs/scheduler.py +53 -0
  205. svc_infra/jobs/worker.py +40 -0
  206. svc_infra/loaders/__init__.py +186 -0
  207. svc_infra/loaders/base.py +143 -0
  208. svc_infra/loaders/github.py +309 -0
  209. svc_infra/loaders/models.py +147 -0
  210. svc_infra/loaders/url.py +229 -0
  211. svc_infra/logging/__init__.py +375 -0
  212. svc_infra/mcp/__init__.py +82 -0
  213. svc_infra/mcp/svc_infra_mcp.py +91 -33
  214. svc_infra/obs/README.md +2 -0
  215. svc_infra/obs/add.py +68 -11
  216. svc_infra/obs/cloud_dash.py +2 -1
  217. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  218. svc_infra/obs/metrics/__init__.py +6 -7
  219. svc_infra/obs/metrics/asgi.py +8 -7
  220. svc_infra/obs/metrics/base.py +13 -13
  221. svc_infra/obs/metrics/http.py +3 -3
  222. svc_infra/obs/metrics/sqlalchemy.py +14 -13
  223. svc_infra/obs/metrics.py +9 -8
  224. svc_infra/resilience/__init__.py +44 -0
  225. svc_infra/resilience/circuit_breaker.py +328 -0
  226. svc_infra/resilience/retry.py +289 -0
  227. svc_infra/security/__init__.py +167 -0
  228. svc_infra/security/add.py +213 -0
  229. svc_infra/security/audit.py +97 -18
  230. svc_infra/security/audit_service.py +10 -9
  231. svc_infra/security/headers.py +15 -2
  232. svc_infra/security/hibp.py +14 -7
  233. svc_infra/security/jwt_rotation.py +78 -29
  234. svc_infra/security/lockout.py +23 -16
  235. svc_infra/security/models.py +77 -44
  236. svc_infra/security/oauth_models.py +73 -0
  237. svc_infra/security/org_invites.py +12 -12
  238. svc_infra/security/passwords.py +3 -3
  239. svc_infra/security/permissions.py +31 -7
  240. svc_infra/security/session.py +7 -8
  241. svc_infra/security/signed_cookies.py +26 -6
  242. svc_infra/storage/__init__.py +93 -0
  243. svc_infra/storage/add.py +250 -0
  244. svc_infra/storage/backends/__init__.py +11 -0
  245. svc_infra/storage/backends/local.py +331 -0
  246. svc_infra/storage/backends/memory.py +213 -0
  247. svc_infra/storage/backends/s3.py +334 -0
  248. svc_infra/storage/base.py +239 -0
  249. svc_infra/storage/easy.py +181 -0
  250. svc_infra/storage/settings.py +193 -0
  251. svc_infra/testing/__init__.py +682 -0
  252. svc_infra/utils.py +170 -5
  253. svc_infra/webhooks/__init__.py +69 -0
  254. svc_infra/webhooks/add.py +327 -0
  255. svc_infra/webhooks/encryption.py +115 -0
  256. svc_infra/webhooks/fastapi.py +37 -0
  257. svc_infra/webhooks/router.py +55 -0
  258. svc_infra/webhooks/service.py +69 -0
  259. svc_infra/webhooks/signing.py +34 -0
  260. svc_infra/websocket/__init__.py +79 -0
  261. svc_infra/websocket/add.py +139 -0
  262. svc_infra/websocket/client.py +283 -0
  263. svc_infra/websocket/config.py +57 -0
  264. svc_infra/websocket/easy.py +76 -0
  265. svc_infra/websocket/exceptions.py +61 -0
  266. svc_infra/websocket/manager.py +343 -0
  267. svc_infra/websocket/models.py +49 -0
  268. svc_infra-1.1.0.dist-info/LICENSE +21 -0
  269. svc_infra-1.1.0.dist-info/METADATA +362 -0
  270. svc_infra-1.1.0.dist-info/RECORD +364 -0
  271. svc_infra-0.1.595.dist-info/METADATA +0 -80
  272. svc_infra-0.1.595.dist-info/RECORD +0 -253
  273. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  274. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable
3
+ from collections.abc import Callable
4
+ from typing import Any
4
5
 
5
6
  from fastapi import APIRouter
6
7
 
@@ -17,7 +18,7 @@ def apply_default_security(router: APIRouter, *, default_security: list[dict] |
17
18
  kwargs["openapi_extra"] = ox
18
19
  return original_add(path, endpoint, **kwargs)
19
20
 
20
- router.add_api_route = _wrapped_add_api_route # type: ignore[attr-defined]
21
+ router.add_api_route = _wrapped_add_api_route # type: ignore[method-assign]
21
22
 
22
23
 
23
24
  def apply_default_responses(router: APIRouter, defaults: dict[int, dict]) -> None:
@@ -38,4 +39,4 @@ def apply_default_responses(router: APIRouter, defaults: dict[int, dict]) -> Non
38
39
  kwargs["responses"] = responses
39
40
  return original_add(path, endpoint, **kwargs)
40
41
 
41
- router.add_api_route = _wrapped_add_api_route # type: ignore[attr-defined]
42
+ router.add_api_route = _wrapped_add_api_route # type: ignore[method-assign]
@@ -1,13 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Dict
3
+ from typing import Any
4
4
 
5
5
  from fastapi import FastAPI
6
6
 
7
7
  from .mutators import conventions_mutator
8
8
  from .pipeline import apply_mutators
9
9
 
10
- PROBLEM_SCHEMA: Dict[str, Any] = {
10
+ PROBLEM_SCHEMA: dict[str, Any] = {
11
11
  "type": "object",
12
12
  "properties": {
13
13
  "type": {
@@ -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,13 +40,16 @@ 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
  }
43
50
 
44
51
 
45
- def _problem_example(**kw: Any) -> Dict[str, Any]:
52
+ def _problem_example(**kw: Any) -> dict[str, Any]:
46
53
  base = {
47
54
  "type": "about:blank",
48
55
  "title": "Internal Server Error",
@@ -56,7 +63,7 @@ def _problem_example(**kw: Any) -> Dict[str, Any]:
56
63
  return base
57
64
 
58
65
 
59
- STANDARD_RESPONSES: Dict[str, Dict[str, Any]] = {
66
+ STANDARD_RESPONSES: dict[str, dict[str, Any]] = {
60
67
  "BadRequest": {
61
68
  "description": "The request is malformed or missing required fields",
62
69
  "content": {
@@ -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 collections.abc import Callable, Iterable, Iterator
5
5
 
6
6
  from ..auth.security import auth_login_path
7
7
  from .models import APIVersionSpec, ServiceInfo, VersionInfo
@@ -9,7 +9,7 @@ from .models import APIVersionSpec, ServiceInfo, VersionInfo
9
9
  _HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
10
10
 
11
11
 
12
- def _iter_ops(schema: dict) -> Iterator[Tuple[str, str, dict]]:
12
+ def _iter_ops(schema: dict) -> Iterator[tuple[str, str, dict]]:
13
13
  """Yield (path, method, op) for each operation object."""
14
14
  paths = schema.get("paths") or {}
15
15
  for path, methods in paths.items():
@@ -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
 
@@ -479,7 +479,7 @@ def ensure_global_tags_mutator(default_desc: str = "Operations related to {tag}.
479
479
 
480
480
  # map existing tags by name and preserve their fields
481
481
  existing_list = schema.get("tags") or []
482
- existing_map: Dict[str, dict] = {}
482
+ existing_map: dict[str, dict] = {}
483
483
  for item in existing_list:
484
484
  if isinstance(item, dict) and "name" in item:
485
485
  existing_map[item["name"]] = dict(item)
@@ -487,7 +487,10 @@ def ensure_global_tags_mutator(default_desc: str = "Operations related to {tag}.
487
487
  # add missing tags; do NOT override existing descriptions
488
488
  for name in sorted(used):
489
489
  if name not in existing_map:
490
- existing_map[name] = {"name": name, "description": default_desc.format(tag=name)}
490
+ existing_map[name] = {
491
+ "name": name,
492
+ "description": default_desc.format(tag=name),
493
+ }
491
494
  else:
492
495
  if not existing_map[name].get("description"):
493
496
  existing_map[name]["description"] = default_desc.format(tag=name)
@@ -501,8 +504,8 @@ def ensure_global_tags_mutator(default_desc: str = "Operations related to {tag}.
501
504
 
502
505
 
503
506
  def attach_standard_responses_mutator(
504
- codes: Dict[int, str] | None = None,
505
- per_method: Dict[str, Iterable[int]] | None = None,
507
+ codes: dict[int, str] | None = None,
508
+ per_method: dict[str, Iterable[int]] | None = None,
506
509
  exclude_tags: set[str] | None = None,
507
510
  op_flag_disable: str = "x_disable_standard_responses",
508
511
  ):
@@ -541,7 +544,7 @@ def attach_standard_responses_mutator(
541
544
 
542
545
 
543
546
  def drop_unused_components_mutator(
544
- drop_responses: list[str] = None, drop_schemas: list[str] = None
547
+ drop_responses: list[str] | None = None, drop_schemas: list[str] | None = None
545
548
  ):
546
549
  drop_responses = drop_responses or []
547
550
  drop_schemas = drop_schemas or []
@@ -640,7 +643,9 @@ def ensure_media_type_schemas_mutator():
640
643
 
641
644
 
642
645
  # ---------- 3) Request body descriptions ----------
643
- def ensure_request_body_descriptions_mutator(default_template="Request body for {method} {path}."):
646
+ def ensure_request_body_descriptions_mutator(
647
+ default_template="Request body for {method} {path}.",
648
+ ):
644
649
  def m(schema: dict) -> dict:
645
650
  schema = dict(schema)
646
651
  for path, method, op in _iter_ops(schema):
@@ -1102,6 +1107,117 @@ def ensure_success_examples_mutator():
1102
1107
  return m
1103
1108
 
1104
1109
 
1110
+ # --- NEW: attach minimal x-codeSamples for common operations ---
1111
+ def attach_code_samples_mutator():
1112
+ """Attach minimal curl/httpie x-codeSamples for each operation if missing.
1113
+
1114
+ We avoid templating parameters; samples illustrate method and path only.
1115
+ """
1116
+
1117
+ def m(schema: dict) -> dict:
1118
+ schema = dict(schema)
1119
+ servers = schema.get("servers") or [{"url": ""}]
1120
+ base = servers[0].get("url") or ""
1121
+
1122
+ for path, method, op in _iter_ops(schema):
1123
+ # Don't override existing samples
1124
+ if isinstance(op.get("x-codeSamples"), list) and op["x-codeSamples"]:
1125
+ continue
1126
+ url = f"{base}{path}"
1127
+ method_up = method.upper()
1128
+ samples = [
1129
+ {
1130
+ "lang": "bash",
1131
+ "label": "curl",
1132
+ "source": f"curl -X {method_up} '{url}'",
1133
+ },
1134
+ {
1135
+ "lang": "bash",
1136
+ "label": "httpie",
1137
+ "source": f"http {method_up} '{url}'",
1138
+ },
1139
+ ]
1140
+ op["x-codeSamples"] = samples
1141
+ return schema
1142
+
1143
+ return m
1144
+
1145
+
1146
+ # --- NEW: ensure Problem+JSON examples exist for standard error responses ---
1147
+ def ensure_problem_examples_mutator():
1148
+ """Add example objects for 4xx/5xx responses using Problem schema if absent."""
1149
+
1150
+ try:
1151
+ # Internal helper with sensible defaults
1152
+ from .conventions import _problem_example
1153
+ except Exception: # pragma: no cover - fallback
1154
+
1155
+ def _problem_example(**kw): # type: ignore
1156
+ base = {
1157
+ "type": "about:blank",
1158
+ "title": "Error",
1159
+ "status": 500,
1160
+ "detail": "An error occurred.",
1161
+ "instance": "/request/trace",
1162
+ "code": "INTERNAL_ERROR",
1163
+ }
1164
+ base.update(kw)
1165
+ return base
1166
+
1167
+ def m(schema: dict) -> dict:
1168
+ schema = dict(schema)
1169
+ for _, _, op in _iter_ops(schema):
1170
+ resps = op.get("responses") or {}
1171
+ for code, resp in resps.items():
1172
+ if not isinstance(resp, dict):
1173
+ continue
1174
+ try:
1175
+ ic = int(code)
1176
+ except Exception:
1177
+ continue
1178
+ if ic < 400:
1179
+ continue
1180
+ # Do not add content if response is a $ref; avoid creating siblings
1181
+ if "$ref" in resp:
1182
+ continue
1183
+ content = resp.setdefault("content", {})
1184
+ # prefer problem+json but also set application/json if present
1185
+ for mt in ("application/problem+json", "application/json"):
1186
+ mt_obj = content.get(mt)
1187
+ if mt_obj is None:
1188
+ # Create a basic media type referencing Problem schema when appropriate
1189
+ if mt == "application/problem+json":
1190
+ mt_obj = {"schema": {"$ref": "#/components/schemas/Problem"}}
1191
+ content[mt] = mt_obj
1192
+ else:
1193
+ continue
1194
+ if not isinstance(mt_obj, dict):
1195
+ continue
1196
+ if "example" in mt_obj or "examples" in mt_obj:
1197
+ continue
1198
+ mt_obj["example"] = _problem_example(status=ic)
1199
+ return schema
1200
+
1201
+ return m
1202
+
1203
+
1204
+ # --- NEW: attach default tags from first path segment when missing ---
1205
+ def attach_default_tags_mutator():
1206
+ """If an operation has no tags, tag it by its first path segment."""
1207
+
1208
+ def m(schema: dict) -> dict:
1209
+ schema = dict(schema)
1210
+ for path, _method, op in _iter_ops(schema):
1211
+ tags = op.get("tags")
1212
+ if tags:
1213
+ continue
1214
+ seg = path.strip("/").split("/", 1)[0] or "root"
1215
+ op["tags"] = [seg]
1216
+ return schema
1217
+
1218
+ return m
1219
+
1220
+
1105
1221
  def dedupe_tags_mutator():
1106
1222
  def m(schema: dict) -> dict:
1107
1223
  schema = dict(schema)
@@ -1140,7 +1256,7 @@ def scrub_invalid_object_examples_mutator():
1140
1256
  sch = mt_obj.get("schema")
1141
1257
  ex = mt_obj.get("example")
1142
1258
  if "example" in mt_obj and _invalid_object_example(
1143
- sch if isinstance(sch, dict) else {}, ex
1259
+ sch if isinstance(sch, dict) else {}, ex if isinstance(ex, dict) else {}
1144
1260
  ):
1145
1261
  mt_obj.pop("example", None)
1146
1262
 
@@ -1269,17 +1385,20 @@ def hardening_components_mutator():
1269
1385
  },
1270
1386
  )
1271
1387
  headers.setdefault(
1272
- "XRateLimitLimit", {"schema": {"type": "integer"}, "description": "Tokens in window."}
1388
+ "XRateLimitLimit",
1389
+ {"schema": {"type": "integer"}, "description": "Tokens in window."},
1273
1390
  )
1274
1391
  headers.setdefault(
1275
1392
  "XRateLimitRemaining",
1276
1393
  {"schema": {"type": "integer"}, "description": "Remaining tokens."},
1277
1394
  )
1278
1395
  headers.setdefault(
1279
- "XRateLimitReset", {"schema": {"type": "integer"}, "description": "Unix reset time."}
1396
+ "XRateLimitReset",
1397
+ {"schema": {"type": "integer"}, "description": "Unix reset time."},
1280
1398
  )
1281
1399
  headers.setdefault(
1282
- "XRequestId", {"schema": {"type": "string"}, "description": "Correlation id."}
1400
+ "XRequestId",
1401
+ {"schema": {"type": "string"}, "description": "Correlation id."},
1283
1402
  )
1284
1403
  headers.setdefault(
1285
1404
  "Deprecation",
@@ -1290,7 +1409,10 @@ def hardening_components_mutator():
1290
1409
  )
1291
1410
  headers.setdefault(
1292
1411
  "Sunset",
1293
- {"schema": {"type": "string"}, "description": "HTTP-date for deprecation sunset."},
1412
+ {
1413
+ "schema": {"type": "string"},
1414
+ "description": "HTTP-date for deprecation sunset.",
1415
+ },
1294
1416
  )
1295
1417
  return schema
1296
1418
 
@@ -1362,14 +1484,16 @@ def attach_header_params_mutator():
1362
1484
  hdrs.setdefault("Last-Modified", {"$ref": "#/components/headers/LastModified"})
1363
1485
  hdrs.setdefault("X-Request-Id", {"$ref": "#/components/headers/XRequestId"})
1364
1486
  hdrs.setdefault(
1365
- "X-RateLimit-Limit", {"$ref": "#/components/headers/XRateLimitLimit"}
1487
+ "X-RateLimit-Limit",
1488
+ {"$ref": "#/components/headers/XRateLimitLimit"},
1366
1489
  )
1367
1490
  hdrs.setdefault(
1368
1491
  "X-RateLimit-Remaining",
1369
1492
  {"$ref": "#/components/headers/XRateLimitRemaining"},
1370
1493
  )
1371
1494
  hdrs.setdefault(
1372
- "X-RateLimit-Reset", {"$ref": "#/components/headers/XRateLimitReset"}
1495
+ "X-RateLimit-Reset",
1496
+ {"$ref": "#/components/headers/XRateLimitReset"},
1373
1497
  )
1374
1498
  if code == "429":
1375
1499
  resp.setdefault("headers", {})["Retry-After"] = {
@@ -1429,6 +1553,9 @@ def setup_mutators(
1429
1553
  ensure_media_type_schemas_mutator(),
1430
1554
  ensure_examples_for_json_mutator(),
1431
1555
  ensure_success_examples_mutator(),
1556
+ attach_default_tags_mutator(),
1557
+ attach_code_samples_mutator(),
1558
+ ensure_problem_examples_mutator(),
1432
1559
  ensure_media_examples_mutator(),
1433
1560
  scrub_invalid_object_examples_mutator(),
1434
1561
  normalize_no_content_204_mutator(),
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Callable
3
+ from collections.abc import Callable
4
4
 
5
5
  from fastapi import FastAPI
6
6
  from fastapi.openapi.utils import get_openapi
@@ -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
+ app.openapi = patched # type: ignore[method-assign]
@@ -1,32 +1,30 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Dict
4
-
5
3
 
6
4
  def ref(name: str) -> dict:
7
5
  return {"$ref": f"#/components/responses/{name}"}
8
6
 
9
7
 
10
- DEFAULT_PUBLIC: Dict[int, dict] = {
8
+ DEFAULT_PUBLIC: dict[int, dict] = {
11
9
  400: ref("BadRequest"),
12
10
  422: ref("ValidationError"),
13
11
  500: ref("ServerError"),
14
12
  }
15
- DEFAULT_USER: Dict[int, dict] = {
13
+ DEFAULT_USER: dict[int, dict] = {
16
14
  400: ref("BadRequest"),
17
15
  401: ref("Unauthorized"),
18
16
  403: ref("Forbidden"),
19
17
  422: ref("ValidationError"),
20
18
  500: ref("ServerError"),
21
19
  }
22
- DEFAULT_SERVICE: Dict[int, dict] = {
20
+ DEFAULT_SERVICE: dict[int, dict] = {
23
21
  400: ref("BadRequest"),
24
22
  401: ref("Unauthorized"),
25
23
  403: ref("Forbidden"),
26
24
  429: ref("TooManyRequests"),
27
25
  500: ref("ServerError"),
28
26
  }
29
- DEFAULT_PROTECTED: Dict[int, dict] = {
27
+ DEFAULT_PROTECTED: dict[int, dict] = {
30
28
  400: ref("BadRequest"),
31
29
  401: ref("Unauthorized"),
32
30
  403: ref("Forbidden"),
@@ -6,7 +6,7 @@ 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(sec: list | None, *, drop_schemes: set[str] | None = None) -> list:
10
10
  if not sec:
11
11
  return []
12
12
  drop_schemes = drop_schemes or set()
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc 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(prefix=prefix, tags=["ops"], include_in_schema=include_in_schema)
20
+
21
+ @router.get("/live")
22
+ async def live() -> JSONResponse:
23
+ return JSONResponse({"status": "ok"})
24
+
25
+ @router.get("/ready")
26
+ async def ready() -> JSONResponse:
27
+ # In the future, add checks (DB ping, cache ping) via DI hooks.
28
+ return JSONResponse({"status": "ok"})
29
+
30
+ @router.get("/startup")
31
+ async def startup_probe() -> JSONResponse:
32
+ return JSONResponse({"status": "ok"})
33
+
34
+ app.include_router(router)
35
+
36
+
37
+ def add_maintenance_mode(
38
+ app: FastAPI,
39
+ *,
40
+ env_var: str = "MAINTENANCE_MODE",
41
+ exempt_prefixes: tuple[str, ...] | None = None,
42
+ ) -> None:
43
+ """Enable a simple maintenance gate controlled by an env var.
44
+
45
+ When MAINTENANCE_MODE is truthy, all non-GET requests return 503.
46
+ """
47
+
48
+ @app.middleware("http")
49
+ async def _maintenance_gate(request: Request, call_next):
50
+ flag = str(os.getenv(env_var, "")).lower() in {"1", "true", "yes", "on"}
51
+ if flag and request.method not in {"GET", "HEAD", "OPTIONS"}:
52
+ path = request.scope.get("path", "")
53
+ if exempt_prefixes and any(path.startswith(p) for p in exempt_prefixes):
54
+ return await call_next(request)
55
+ return JSONResponse({"detail": "maintenance"}, status_code=503)
56
+ return await call_next(request)
57
+
58
+
59
+ def circuit_breaker_dependency(limit: int = 100, window_seconds: int = 60) -> Callable:
60
+ """Return a dependency that can trip rejective errors based on external metrics.
61
+
62
+ This is a placeholder; callers can swap with a provider that tracks failures and opens the
63
+ breaker. Here, we read an env var to simulate an open breaker.
64
+ """
65
+
66
+ async def _dep(_: Request) -> None:
67
+ if str(os.getenv("CIRCUIT_OPEN", "")).lower() in {"1", "true", "yes", "on"}:
68
+ raise HTTPException(status_code=503, detail="circuit open")
69
+
70
+ return _dep
71
+
72
+
73
+ __all__ = ["add_probes", "add_maintenance_mode", "circuit_breaker_dependency"]