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
@@ -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"]
@@ -3,11 +3,24 @@ from __future__ import annotations
3
3
  import base64
4
4
  import contextvars
5
5
  import json
6
- from typing import Any, Callable, Generic, Iterable, List, Optional, Sequence, TypeVar
6
+ import logging
7
+ from typing import (
8
+ Any,
9
+ Callable,
10
+ Generic,
11
+ Iterable,
12
+ List,
13
+ Optional,
14
+ Sequence,
15
+ TypeVar,
16
+ cast,
17
+ )
7
18
 
8
19
  from fastapi import Query, Request
9
20
  from pydantic import BaseModel, Field
10
21
 
22
+ logger = logging.getLogger(__name__)
23
+
11
24
  T = TypeVar("T")
12
25
 
13
26
 
@@ -44,13 +57,13 @@ def _encode_cursor(payload: dict) -> str:
44
57
  return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
45
58
 
46
59
 
47
- def decode_cursor(token: Optional[str]) -> dict:
60
+ def decode_cursor(token: Optional[str]) -> dict[Any, Any]:
48
61
  """Public: decode an incoming cursor token for debugging/ops."""
49
62
  if not token:
50
63
  return {}
51
64
  s = token + "=" * (-len(token) % 4)
52
65
  raw = base64.urlsafe_b64decode(s.encode("ascii")).decode("utf-8")
53
- return json.loads(raw)
66
+ return cast(dict[Any, Any], json.loads(raw))
54
67
 
55
68
 
56
69
  # ---------- Context ----------
@@ -85,11 +98,15 @@ class PaginationContext(Generic[T]):
85
98
 
86
99
  @property
87
100
  def cursor(self) -> Optional[str]:
88
- return (self.cursor_params or CursorParams()).cursor if self.allow_cursor else None
101
+ return (
102
+ (self.cursor_params or CursorParams()).cursor if self.allow_cursor else None
103
+ )
89
104
 
90
105
  @property
91
106
  def limit(self) -> int:
92
- if self.allow_cursor and self.cursor_params and self.cursor_params.cursor is not None:
107
+ # For cursor-based pagination, always honor the requested limit, even on the first page
108
+ # (cursor may be None for the first page).
109
+ if self.allow_cursor and self.cursor_params:
93
110
  return self.cursor_params.limit
94
111
  if self.allow_page and self.page_params:
95
112
  return self.limit_override or self.page_params.page_size
@@ -101,7 +118,11 @@ class PaginationContext(Generic[T]):
101
118
 
102
119
  @property
103
120
  def page_size(self) -> Optional[int]:
104
- return self.page_params.page_size if (self.allow_page and self.page_params) else None
121
+ return (
122
+ self.page_params.page_size
123
+ if (self.allow_page and self.page_params)
124
+ else None
125
+ )
105
126
 
106
127
  @property
107
128
  def offset(self) -> int:
@@ -110,7 +131,11 @@ class PaginationContext(Generic[T]):
110
131
  return 0
111
132
 
112
133
  def wrap(
113
- self, items: list[T], *, next_cursor: Optional[str] = None, total: Optional[int] = None
134
+ self,
135
+ items: list[T],
136
+ *,
137
+ next_cursor: Optional[str] = None,
138
+ total: Optional[int] = None,
114
139
  ):
115
140
  if self.envelope:
116
141
  return Paginated[T](items=items, next_cursor=next_cursor, total=total)
@@ -125,8 +150,8 @@ class PaginationContext(Generic[T]):
125
150
  return _encode_cursor({"after": last_key})
126
151
 
127
152
 
128
- _pagination_ctx: contextvars.ContextVar[PaginationContext] = contextvars.ContextVar(
129
- "pagination_ctx", default=None
153
+ _pagination_ctx: contextvars.ContextVar[PaginationContext | None] = (
154
+ contextvars.ContextVar("pagination_ctx", default=None)
130
155
  )
131
156
 
132
157
 
@@ -146,7 +171,9 @@ def use_pagination() -> PaginationContext:
146
171
 
147
172
 
148
173
  # ---------- Utilities ----------
149
- def text_filter(items: Iterable[T], q: Optional[str], *getters: Callable[[T], str]) -> list[T]:
174
+ def text_filter(
175
+ items: Iterable[T], q: Optional[str], *getters: Callable[[T], str]
176
+ ) -> list[T]:
150
177
  if not q:
151
178
  return list(items)
152
179
  ql = q.lower()
@@ -157,8 +184,8 @@ def text_filter(items: Iterable[T], q: Optional[str], *getters: Callable[[T], st
157
184
  if ql in (g(it) or "").lower():
158
185
  out.append(it)
159
186
  break
160
- except Exception:
161
- pass
187
+ except Exception as e:
188
+ logger.debug("text_filter getter failed for item: %s", e)
162
189
  return out
163
190
 
164
191
 
@@ -215,7 +242,7 @@ def make_pagination_injector(
215
242
  # Cursor-only (common case)
216
243
  if allow_cursor and not allow_page and not include_filters:
217
244
 
218
- async def _inject(
245
+ async def _inject_cursor(
219
246
  request: Request,
220
247
  cursor: str | None = Query(None),
221
248
  limit: int = Query(default_limit, ge=1, le=max_limit),
@@ -233,12 +260,12 @@ def make_pagination_injector(
233
260
  )
234
261
  return None
235
262
 
236
- return _inject
263
+ return _inject_cursor
237
264
 
238
265
  # Cursor + filters
239
266
  if allow_cursor and not allow_page and include_filters:
240
267
 
241
- async def _inject(
268
+ async def _inject_cursor_with_filters(
242
269
  request: Request,
243
270
  cursor: str | None = Query(None),
244
271
  limit: int = Query(default_limit, ge=1, le=max_limit),
@@ -270,12 +297,12 @@ def make_pagination_injector(
270
297
  )
271
298
  return None
272
299
 
273
- return _inject
300
+ return _inject_cursor_with_filters
274
301
 
275
302
  # Page-only
276
303
  if not allow_cursor and allow_page:
277
304
 
278
- async def _inject(
305
+ async def _inject_page(
279
306
  request: Request,
280
307
  page: int = Query(1, ge=1),
281
308
  page_size: int = Query(default_limit, ge=1, le=max_limit),
@@ -293,10 +320,10 @@ def make_pagination_injector(
293
320
  )
294
321
  return None
295
322
 
296
- return _inject
323
+ return _inject_page
297
324
 
298
325
  # Both cursor + page (rare; exposes all)
299
- async def _inject(
326
+ async def _inject_all(
300
327
  request: Request,
301
328
  cursor: str | None = Query(None),
302
329
  limit: int = Query(default_limit, ge=1, le=max_limit),
@@ -336,7 +363,7 @@ def make_pagination_injector(
336
363
  )
337
364
  return None
338
365
 
339
- return _inject
366
+ return _inject_all
340
367
 
341
368
 
342
369
  # ----- Convenience helpers for routers -----