svc-infra 0.1.562__py3-none-any.whl → 0.1.654__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.
Files changed (175) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/models.py +142 -4
  3. svc_infra/apf_payments/provider/__init__.py +4 -0
  4. svc_infra/apf_payments/provider/aiydan.py +797 -0
  5. svc_infra/apf_payments/provider/base.py +178 -12
  6. svc_infra/apf_payments/provider/stripe.py +757 -48
  7. svc_infra/apf_payments/schemas.py +163 -1
  8. svc_infra/apf_payments/service.py +582 -42
  9. svc_infra/apf_payments/settings.py +22 -2
  10. svc_infra/api/fastapi/admin/__init__.py +3 -0
  11. svc_infra/api/fastapi/admin/add.py +231 -0
  12. svc_infra/api/fastapi/apf_payments/router.py +792 -73
  13. svc_infra/api/fastapi/apf_payments/setup.py +13 -4
  14. svc_infra/api/fastapi/auth/add.py +10 -4
  15. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  16. svc_infra/api/fastapi/auth/routers/oauth_router.py +74 -34
  17. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  18. svc_infra/api/fastapi/auth/settings.py +2 -0
  19. svc_infra/api/fastapi/billing/router.py +64 -0
  20. svc_infra/api/fastapi/billing/setup.py +19 -0
  21. svc_infra/api/fastapi/cache/add.py +9 -5
  22. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  23. svc_infra/api/fastapi/db/sql/add.py +40 -18
  24. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  25. svc_infra/api/fastapi/db/sql/session.py +16 -0
  26. svc_infra/api/fastapi/db/sql/users.py +13 -1
  27. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  28. svc_infra/api/fastapi/docs/add.py +160 -0
  29. svc_infra/api/fastapi/docs/landing.py +1 -1
  30. svc_infra/api/fastapi/docs/scoped.py +41 -6
  31. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  32. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  33. svc_infra/api/fastapi/middleware/idempotency.py +82 -42
  34. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  35. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  36. svc_infra/api/fastapi/middleware/ratelimit.py +84 -11
  37. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  38. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  39. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  40. svc_infra/api/fastapi/openapi/mutators.py +244 -38
  41. svc_infra/api/fastapi/ops/add.py +73 -0
  42. svc_infra/api/fastapi/pagination.py +133 -32
  43. svc_infra/api/fastapi/routers/ping.py +1 -0
  44. svc_infra/api/fastapi/setup.py +23 -14
  45. svc_infra/api/fastapi/tenancy/add.py +19 -0
  46. svc_infra/api/fastapi/tenancy/context.py +112 -0
  47. svc_infra/api/fastapi/versioned.py +101 -0
  48. svc_infra/app/README.md +5 -5
  49. svc_infra/billing/__init__.py +23 -0
  50. svc_infra/billing/async_service.py +147 -0
  51. svc_infra/billing/jobs.py +230 -0
  52. svc_infra/billing/models.py +131 -0
  53. svc_infra/billing/quotas.py +101 -0
  54. svc_infra/billing/schemas.py +33 -0
  55. svc_infra/billing/service.py +115 -0
  56. svc_infra/bundled_docs/README.md +5 -0
  57. svc_infra/bundled_docs/__init__.py +1 -0
  58. svc_infra/bundled_docs/getting-started.md +6 -0
  59. svc_infra/cache/__init__.py +4 -0
  60. svc_infra/cache/add.py +158 -0
  61. svc_infra/cache/backend.py +5 -2
  62. svc_infra/cache/decorators.py +19 -1
  63. svc_infra/cache/keys.py +24 -4
  64. svc_infra/cli/__init__.py +32 -8
  65. svc_infra/cli/__main__.py +4 -0
  66. svc_infra/cli/cmds/__init__.py +10 -0
  67. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  68. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  69. svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
  70. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  71. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  72. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  73. svc_infra/cli/cmds/dx/__init__.py +12 -0
  74. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  75. svc_infra/cli/cmds/help.py +4 -0
  76. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  77. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  78. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  79. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  80. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  81. svc_infra/data/add.py +61 -0
  82. svc_infra/data/backup.py +53 -0
  83. svc_infra/data/erasure.py +45 -0
  84. svc_infra/data/fixtures.py +40 -0
  85. svc_infra/data/retention.py +55 -0
  86. svc_infra/db/inbox.py +67 -0
  87. svc_infra/db/nosql/mongo/README.md +13 -13
  88. svc_infra/db/outbox.py +104 -0
  89. svc_infra/db/sql/repository.py +52 -12
  90. svc_infra/db/sql/resource.py +5 -0
  91. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  92. svc_infra/db/sql/templates/setup/env_async.py.tmpl +13 -8
  93. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -5
  94. svc_infra/db/sql/tenant.py +79 -0
  95. svc_infra/db/sql/utils.py +18 -4
  96. svc_infra/db/sql/versioning.py +14 -0
  97. svc_infra/docs/acceptance-matrix.md +71 -0
  98. svc_infra/docs/acceptance.md +44 -0
  99. svc_infra/docs/admin.md +425 -0
  100. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  101. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  102. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  103. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  104. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  105. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  106. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  107. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  108. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  109. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  110. svc_infra/docs/api.md +59 -0
  111. svc_infra/docs/auth.md +11 -0
  112. svc_infra/docs/billing.md +190 -0
  113. svc_infra/docs/cache.md +76 -0
  114. svc_infra/docs/cli.md +74 -0
  115. svc_infra/docs/contributing.md +34 -0
  116. svc_infra/docs/data-lifecycle.md +52 -0
  117. svc_infra/docs/database.md +14 -0
  118. svc_infra/docs/docs-and-sdks.md +62 -0
  119. svc_infra/docs/environment.md +114 -0
  120. svc_infra/docs/getting-started.md +63 -0
  121. svc_infra/docs/idempotency.md +111 -0
  122. svc_infra/docs/jobs.md +67 -0
  123. svc_infra/docs/observability.md +16 -0
  124. svc_infra/docs/ops.md +37 -0
  125. svc_infra/docs/rate-limiting.md +125 -0
  126. svc_infra/docs/repo-review.md +48 -0
  127. svc_infra/docs/security.md +176 -0
  128. svc_infra/docs/tenancy.md +35 -0
  129. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  130. svc_infra/docs/versioned-integrations.md +146 -0
  131. svc_infra/docs/webhooks.md +112 -0
  132. svc_infra/dx/add.py +63 -0
  133. svc_infra/dx/changelog.py +74 -0
  134. svc_infra/dx/checks.py +67 -0
  135. svc_infra/http/__init__.py +13 -0
  136. svc_infra/http/client.py +72 -0
  137. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  138. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  139. svc_infra/jobs/easy.py +32 -0
  140. svc_infra/jobs/loader.py +45 -0
  141. svc_infra/jobs/queue.py +81 -0
  142. svc_infra/jobs/redis_queue.py +191 -0
  143. svc_infra/jobs/runner.py +75 -0
  144. svc_infra/jobs/scheduler.py +41 -0
  145. svc_infra/jobs/worker.py +40 -0
  146. svc_infra/mcp/svc_infra_mcp.py +85 -28
  147. svc_infra/obs/README.md +2 -0
  148. svc_infra/obs/add.py +54 -7
  149. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  150. svc_infra/obs/metrics/__init__.py +53 -0
  151. svc_infra/obs/metrics.py +52 -0
  152. svc_infra/security/add.py +201 -0
  153. svc_infra/security/audit.py +130 -0
  154. svc_infra/security/audit_service.py +73 -0
  155. svc_infra/security/headers.py +52 -0
  156. svc_infra/security/hibp.py +95 -0
  157. svc_infra/security/jwt_rotation.py +53 -0
  158. svc_infra/security/lockout.py +96 -0
  159. svc_infra/security/models.py +255 -0
  160. svc_infra/security/org_invites.py +128 -0
  161. svc_infra/security/passwords.py +77 -0
  162. svc_infra/security/permissions.py +149 -0
  163. svc_infra/security/session.py +98 -0
  164. svc_infra/security/signed_cookies.py +80 -0
  165. svc_infra/webhooks/__init__.py +16 -0
  166. svc_infra/webhooks/add.py +322 -0
  167. svc_infra/webhooks/fastapi.py +37 -0
  168. svc_infra/webhooks/router.py +55 -0
  169. svc_infra/webhooks/service.py +67 -0
  170. svc_infra/webhooks/signing.py +30 -0
  171. svc_infra-0.1.654.dist-info/METADATA +154 -0
  172. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/RECORD +174 -56
  173. svc_infra-0.1.562.dist-info/METADATA +0 -79
  174. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  175. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -367,6 +367,18 @@ def auth_mutator(include_api_key: bool):
367
367
 
368
368
  def _m(schema: dict) -> dict:
369
369
  schema = dict(schema)
370
+
371
+ # Detect if any operation already references APIKeyHeader
372
+ any_op_wants_api_key = False
373
+ for _, _, op in _iter_ops(schema):
374
+ for sec in op.get("security") or []:
375
+ if isinstance(sec, dict) and "APIKeyHeader" in sec:
376
+ any_op_wants_api_key = True
377
+ break
378
+ if any_op_wants_api_key:
379
+ break
380
+
381
+ # Add OAuth2 (always)
370
382
  comps = schema.setdefault("components", {}).setdefault("securitySchemes", {})
371
383
  comps.setdefault(
372
384
  "OAuth2PasswordBearer",
@@ -375,11 +387,21 @@ def auth_mutator(include_api_key: bool):
375
387
  "flows": {"password": {"tokenUrl": auth_login_path, "scopes": {}}},
376
388
  },
377
389
  )
378
- if include_api_key:
390
+
391
+ # Add API key scheme only if:
392
+ # - include_api_key flag is True (from spec/root), OR
393
+ # - at least one operation already references APIKeyHeader.
394
+ if include_api_key or any_op_wants_api_key:
379
395
  comps.setdefault(
380
- "APIKeyHeader", {"type": "apiKey", "name": "X-API-Key", "in": "header"}
396
+ "APIKeyHeader",
397
+ {
398
+ "type": "apiKey",
399
+ "name": "X-API-Key",
400
+ "in": "header",
401
+ },
381
402
  )
382
403
 
404
+ # Normalize operation security (drop only 'SessionCookie')
383
405
  drop = {"SessionCookie"}
384
406
  for _, _, op in _iter_ops(schema):
385
407
  op["security"] = _normalize_security_list(op.get("security"), drop)
@@ -689,57 +711,91 @@ def strip_ref_siblings_in_parameters_mutator():
689
711
 
690
712
  def dedupe_parameters_mutator():
691
713
  """
692
- Deduplicate operation.parameters:
693
- - Prefer $ref entries over inline params with the same name.
694
- - Deduplicate repeated $ref entries (same target).
695
- - Deduplicate repeated inline entries by (name, in).
714
+ Deduplicate operation.parameters by actual (name, in):
715
+ - Prefer **inline** params over $ref so per-op 'required: true' (e.g., Idempotency-Key) wins.
716
+ - Collapse duplicate $refs.
717
+ - If a $ref and an inline share the same (name, in), keep the inline and drop the $ref.
696
718
  """
697
719
 
698
720
  def m(schema: dict) -> dict:
699
721
  schema = dict(schema)
722
+ comps = schema.get("components") or {}
723
+ comp_params = (comps.get("parameters") or {}).copy()
724
+
725
+ def _resolve_ref(ref: str) -> tuple[str, str] | None:
726
+ # '#/components/parameters/<Key>' -> ('Idempotency-Key','header'), etc.
727
+ try:
728
+ key = ref.rsplit("/", 1)[-1]
729
+ p = comp_params.get(key) or {}
730
+ name = p.get("name")
731
+ where = p.get("in")
732
+ if isinstance(name, str) and isinstance(where, str):
733
+ return (name, where)
734
+ except Exception:
735
+ pass
736
+ return None
737
+
700
738
  for _, _, op in _iter_ops(schema):
701
739
  params = op.get("parameters")
702
740
  if not isinstance(params, list) or not params:
703
741
  continue
704
742
 
705
- # First, collect all $ref target names: "#/components/parameters/<NAME>"
706
- ref_targets: list[str] = []
743
+ # First pass: collect inline params by (name, in)
744
+ inline_by_key: dict[tuple[str, str], dict] = {}
707
745
  for p in params:
708
- if isinstance(p, dict) and "$ref" in p and isinstance(p["$ref"], str):
709
- ref_targets.append(p["$ref"].rsplit("/", 1)[-1])
710
-
711
- seen_refs: set[str] = set()
712
- seen_inline_keys: set[tuple[str, str]] = set()
746
+ if isinstance(p, dict) and "$ref" not in p:
747
+ name = p.get("name")
748
+ where = p.get("in")
749
+ if isinstance(name, str) and isinstance(where, str):
750
+ # Prefer the first inline; later duplicates ignored
751
+ inline_by_key.setdefault((name, where), p)
752
+
753
+ seen_ref_targets: set[str] = set()
754
+ seen_keys: set[tuple[str, str]] = set()
713
755
  result: list[dict] = []
714
756
 
715
757
  for p in params:
716
758
  if not isinstance(p, dict):
717
759
  continue
718
760
 
719
- # Handle $ref params
720
- if "$ref" in p and isinstance(p["$ref"], str):
721
- ref_name = p["$ref"].rsplit("/", 1)[-1]
722
- if ref_name in seen_refs:
761
+ if "$ref" in p:
762
+ ref = p.get("$ref")
763
+ if not isinstance(ref, str):
723
764
  continue
724
- seen_refs.add(ref_name)
725
- # ensure no siblings (if upstream didn't strip)
726
- if len(p) > 1:
727
- p = {"$ref": p["$ref"]}
728
- result.append(p)
765
+ # de-dup exact same $ref
766
+ if ref in seen_ref_targets:
767
+ continue
768
+ seen_ref_targets.add(ref)
769
+
770
+ tup = _resolve_ref(ref)
771
+ if tup is None:
772
+ # keep unresolved ref (unlikely)
773
+ result.append({"$ref": ref})
774
+ continue
775
+
776
+ # If an inline with same (name, in) exists, prefer inline, skip this $ref
777
+ if tup in inline_by_key:
778
+ continue
779
+
780
+ # Else, keep this $ref if we didn't already keep a param for that key
781
+ if tup in seen_keys:
782
+ continue
783
+ seen_keys.add(tup)
784
+ # Ensure no siblings remain
785
+ result.append({"$ref": ref})
729
786
  continue
730
787
 
731
- # Inline params: drop if a ref with the same name already exists
788
+ # inline param
732
789
  name = p.get("name")
733
790
  where = p.get("in")
734
- if isinstance(name, str) and name in ref_targets:
735
- # There is a component param with the same name; prefer the $ref
791
+ if not (isinstance(name, str) and isinstance(where, str)):
792
+ result.append(p)
736
793
  continue
737
-
738
- # Deduplicate inline params by (name, in)
739
- key = (str(name), str(where))
740
- if key in seen_inline_keys:
794
+ key = (name, where)
795
+ if key in seen_keys:
796
+ # already kept an inline with same (name, in)
741
797
  continue
742
- seen_inline_keys.add(key)
798
+ seen_keys.add(key)
743
799
  result.append(p)
744
800
 
745
801
  op["parameters"] = result
@@ -1046,6 +1102,117 @@ def ensure_success_examples_mutator():
1046
1102
  return m
1047
1103
 
1048
1104
 
1105
+ # --- NEW: attach minimal x-codeSamples for common operations ---
1106
+ def attach_code_samples_mutator():
1107
+ """Attach minimal curl/httpie x-codeSamples for each operation if missing.
1108
+
1109
+ We avoid templating parameters; samples illustrate method and path only.
1110
+ """
1111
+
1112
+ def m(schema: dict) -> dict:
1113
+ schema = dict(schema)
1114
+ servers = schema.get("servers") or [{"url": ""}]
1115
+ base = servers[0].get("url") or ""
1116
+
1117
+ for path, method, op in _iter_ops(schema):
1118
+ # Don't override existing samples
1119
+ if isinstance(op.get("x-codeSamples"), list) and op["x-codeSamples"]:
1120
+ continue
1121
+ url = f"{base}{path}"
1122
+ method_up = method.upper()
1123
+ samples = [
1124
+ {
1125
+ "lang": "bash",
1126
+ "label": "curl",
1127
+ "source": f"curl -X {method_up} '{url}'",
1128
+ },
1129
+ {
1130
+ "lang": "bash",
1131
+ "label": "httpie",
1132
+ "source": f"http {method_up} '{url}'",
1133
+ },
1134
+ ]
1135
+ op["x-codeSamples"] = samples
1136
+ return schema
1137
+
1138
+ return m
1139
+
1140
+
1141
+ # --- NEW: ensure Problem+JSON examples exist for standard error responses ---
1142
+ def ensure_problem_examples_mutator():
1143
+ """Add example objects for 4xx/5xx responses using Problem schema if absent."""
1144
+
1145
+ try:
1146
+ # Internal helper with sensible defaults
1147
+ from .conventions import _problem_example # type: ignore
1148
+ except Exception: # pragma: no cover - fallback
1149
+
1150
+ def _problem_example(**kw): # type: ignore
1151
+ base = {
1152
+ "type": "about:blank",
1153
+ "title": "Error",
1154
+ "status": 500,
1155
+ "detail": "An error occurred.",
1156
+ "instance": "/request/trace",
1157
+ "code": "INTERNAL_ERROR",
1158
+ }
1159
+ base.update(kw)
1160
+ return base
1161
+
1162
+ def m(schema: dict) -> dict:
1163
+ schema = dict(schema)
1164
+ for _, _, op in _iter_ops(schema):
1165
+ resps = op.get("responses") or {}
1166
+ for code, resp in resps.items():
1167
+ if not isinstance(resp, dict):
1168
+ continue
1169
+ try:
1170
+ ic = int(code)
1171
+ except Exception:
1172
+ continue
1173
+ if ic < 400:
1174
+ continue
1175
+ # Do not add content if response is a $ref; avoid creating siblings
1176
+ if "$ref" in resp:
1177
+ continue
1178
+ content = resp.setdefault("content", {})
1179
+ # prefer problem+json but also set application/json if present
1180
+ for mt in ("application/problem+json", "application/json"):
1181
+ mt_obj = content.get(mt)
1182
+ if mt_obj is None:
1183
+ # Create a basic media type referencing Problem schema when appropriate
1184
+ if mt == "application/problem+json":
1185
+ mt_obj = {"schema": {"$ref": "#/components/schemas/Problem"}}
1186
+ content[mt] = mt_obj
1187
+ else:
1188
+ continue
1189
+ if not isinstance(mt_obj, dict):
1190
+ continue
1191
+ if "example" in mt_obj or "examples" in mt_obj:
1192
+ continue
1193
+ mt_obj["example"] = _problem_example(status=ic)
1194
+ return schema
1195
+
1196
+ return m
1197
+
1198
+
1199
+ # --- NEW: attach default tags from first path segment when missing ---
1200
+ def attach_default_tags_mutator():
1201
+ """If an operation has no tags, tag it by its first path segment."""
1202
+
1203
+ def m(schema: dict) -> dict:
1204
+ schema = dict(schema)
1205
+ for path, _method, op in _iter_ops(schema):
1206
+ tags = op.get("tags")
1207
+ if tags:
1208
+ continue
1209
+ seg = path.strip("/").split("/", 1)[0] or "root"
1210
+ op["tags"] = [seg]
1211
+ return schema
1212
+
1213
+ return m
1214
+
1215
+
1049
1216
  def dedupe_tags_mutator():
1050
1217
  def m(schema: dict) -> dict:
1051
1218
  schema = dict(schema)
@@ -1244,21 +1411,56 @@ def hardening_components_mutator():
1244
1411
  def attach_header_params_mutator():
1245
1412
  def m(schema: dict) -> dict:
1246
1413
  schema = dict(schema)
1414
+ comps = schema.setdefault("components", {})
1415
+ comp_params = comps.setdefault("parameters", {})
1416
+
1417
+ def _component_param_name(ref: str) -> tuple[str, str] | None:
1418
+ try:
1419
+ key = ref.rsplit("/", 1)[-1]
1420
+ p = comp_params.get(key) or {}
1421
+ name = p.get("name")
1422
+ where = p.get("in")
1423
+ if isinstance(name, str) and isinstance(where, str):
1424
+ return (name, where)
1425
+ except Exception:
1426
+ pass
1427
+ return None
1428
+
1247
1429
  for _, method, op in _iter_ops(schema):
1248
1430
  params = op.setdefault("parameters", [])
1249
1431
 
1250
- def add_ref(name):
1251
- params.append({"$ref": f"#/components/parameters/{name}"})
1252
-
1432
+ # What inline params (non-$ref) are already present?
1433
+ inline_names = {
1434
+ (p.get("name"), p.get("in"))
1435
+ for p in params
1436
+ if isinstance(p, dict) and "$ref" not in p and "name" in p and "in" in p
1437
+ }
1438
+
1439
+ def add_ref_if_absent(name: str):
1440
+ ref = {"$ref": f"#/components/parameters/{name}"}
1441
+ tup = _component_param_name(ref["$ref"])
1442
+ # If an inline with the same (name, in) exists (e.g., from a dependency),
1443
+ # don't add the ref at all.
1444
+ if tup and tup in inline_names:
1445
+ return
1446
+ params.append(ref)
1447
+
1448
+ # ---- Write methods: Idempotency-Key fallback (optional via $ref) ----
1449
+ # If the dependency added an inline required param, nothing to do;
1450
+ # otherwise, add the optional component ref so callers can still send it.
1253
1451
  if method in ("post", "patch", "delete"):
1254
- add_ref("IdempotencyKey")
1452
+ if ("Idempotency-Key", "header") not in inline_names:
1453
+ add_ref_if_absent("IdempotencyKey")
1454
+ # Optional optimistic concurrency for updates
1255
1455
  if method in ("patch", "put"):
1256
- add_ref("IfMatch")
1456
+ add_ref_if_absent("IfMatch")
1457
+
1458
+ # ---- Conditional GET headers (optional via $ref) ----
1257
1459
  if method == "get":
1258
- add_ref("IfNoneMatch")
1259
- add_ref("IfModifiedSince")
1460
+ add_ref_if_absent("IfNoneMatch")
1461
+ add_ref_if_absent("IfModifiedSince")
1260
1462
 
1261
- # RESPONSE HEADERS use *hyphenated* header names as keys
1463
+ # ---- Standard success/429 headers (unchanged) ----
1262
1464
  resps = op.get("responses") or {}
1263
1465
  for code, resp in resps.items():
1264
1466
  try:
@@ -1285,6 +1487,7 @@ def attach_header_params_mutator():
1285
1487
  "schema": {"type": "integer"},
1286
1488
  "description": "Seconds until next allowed request.",
1287
1489
  }
1490
+
1288
1491
  return schema
1289
1492
 
1290
1493
  return m
@@ -1337,6 +1540,9 @@ def setup_mutators(
1337
1540
  ensure_media_type_schemas_mutator(),
1338
1541
  ensure_examples_for_json_mutator(),
1339
1542
  ensure_success_examples_mutator(),
1543
+ attach_default_tags_mutator(),
1544
+ attach_code_samples_mutator(),
1545
+ ensure_problem_examples_mutator(),
1340
1546
  ensure_media_examples_mutator(),
1341
1547
  scrub_invalid_object_examples_mutator(),
1342
1548
  normalize_no_content_204_mutator(),
@@ -0,0 +1,73 @@
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(prefix=prefix, tags=["ops"], include_in_schema=include_in_schema)
20
+
21
+ @router.get("/live")
22
+ async def live() -> JSONResponse: # noqa: D401, ANN201
23
+ return JSONResponse({"status": "ok"})
24
+
25
+ @router.get("/ready")
26
+ async def ready() -> JSONResponse: # noqa: D401, ANN201
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: # noqa: D401, ANN201
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): # noqa: ANN001, ANN202
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: # noqa: D401, ANN202
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"]