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.
- svc_infra/apf_payments/README.md +732 -0
- svc_infra/apf_payments/models.py +142 -4
- svc_infra/apf_payments/provider/__init__.py +4 -0
- svc_infra/apf_payments/provider/aiydan.py +797 -0
- svc_infra/apf_payments/provider/base.py +178 -12
- svc_infra/apf_payments/provider/stripe.py +757 -48
- svc_infra/apf_payments/schemas.py +163 -1
- svc_infra/apf_payments/service.py +582 -42
- svc_infra/apf_payments/settings.py +22 -2
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/router.py +792 -73
- svc_infra/api/fastapi/apf_payments/setup.py +13 -4
- svc_infra/api/fastapi/auth/add.py +10 -4
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +74 -34
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/billing/router.py +64 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
- svc_infra/api/fastapi/db/sql/add.py +40 -18
- svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
- svc_infra/api/fastapi/db/sql/session.py +16 -0
- svc_infra/api/fastapi/db/sql/users.py +13 -1
- svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
- svc_infra/api/fastapi/docs/add.py +160 -0
- svc_infra/api/fastapi/docs/landing.py +1 -1
- svc_infra/api/fastapi/docs/scoped.py +41 -6
- svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
- svc_infra/api/fastapi/middleware/idempotency.py +82 -42
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +84 -11
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
- svc_infra/api/fastapi/middleware/timeout.py +148 -0
- svc_infra/api/fastapi/openapi/mutators.py +244 -38
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +133 -32
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +23 -14
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +230 -0
- svc_infra/billing/models.py +131 -0
- svc_infra/billing/quotas.py +101 -0
- svc_infra/billing/schemas.py +33 -0
- svc_infra/billing/service.py +115 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +4 -0
- svc_infra/cache/add.py +158 -0
- svc_infra/cache/backend.py +5 -2
- svc_infra/cache/decorators.py +19 -1
- svc_infra/cache/keys.py +24 -4
- svc_infra/cli/__init__.py +32 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +10 -0
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +53 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +40 -0
- svc_infra/data/retention.py +55 -0
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/outbox.py +104 -0
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +13 -8
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -5
- svc_infra/db/sql/tenant.py +79 -0
- svc_infra/db/sql/utils.py +18 -4
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/docs/acceptance-matrix.md +71 -0
- svc_infra/docs/acceptance.md +44 -0
- svc_infra/docs/admin.md +425 -0
- svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
- svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
- svc_infra/docs/adr/0004-tenancy-model.md +42 -0
- svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
- svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
- svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
- svc_infra/docs/adr/0008-billing-primitives.md +143 -0
- svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
- svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
- svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
- svc_infra/docs/api.md +59 -0
- svc_infra/docs/auth.md +11 -0
- svc_infra/docs/billing.md +190 -0
- svc_infra/docs/cache.md +76 -0
- svc_infra/docs/cli.md +74 -0
- svc_infra/docs/contributing.md +34 -0
- svc_infra/docs/data-lifecycle.md +52 -0
- svc_infra/docs/database.md +14 -0
- svc_infra/docs/docs-and-sdks.md +62 -0
- svc_infra/docs/environment.md +114 -0
- svc_infra/docs/getting-started.md +63 -0
- svc_infra/docs/idempotency.md +111 -0
- svc_infra/docs/jobs.md +67 -0
- svc_infra/docs/observability.md +16 -0
- svc_infra/docs/ops.md +37 -0
- svc_infra/docs/rate-limiting.md +125 -0
- svc_infra/docs/repo-review.md +48 -0
- svc_infra/docs/security.md +176 -0
- svc_infra/docs/tenancy.md +35 -0
- svc_infra/docs/timeouts-and-resource-limits.md +147 -0
- svc_infra/docs/versioned-integrations.md +146 -0
- svc_infra/docs/webhooks.md +112 -0
- svc_infra/dx/add.py +63 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +67 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +72 -0
- svc_infra/jobs/builtins/outbox_processor.py +38 -0
- svc_infra/jobs/builtins/webhook_delivery.py +90 -0
- svc_infra/jobs/easy.py +32 -0
- svc_infra/jobs/loader.py +45 -0
- svc_infra/jobs/queue.py +81 -0
- svc_infra/jobs/redis_queue.py +191 -0
- svc_infra/jobs/runner.py +75 -0
- svc_infra/jobs/scheduler.py +41 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/mcp/svc_infra_mcp.py +85 -28
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +54 -7
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +53 -0
- svc_infra/obs/metrics.py +52 -0
- svc_infra/security/add.py +201 -0
- svc_infra/security/audit.py +130 -0
- svc_infra/security/audit_service.py +73 -0
- svc_infra/security/headers.py +52 -0
- svc_infra/security/hibp.py +95 -0
- svc_infra/security/jwt_rotation.py +53 -0
- svc_infra/security/lockout.py +96 -0
- svc_infra/security/models.py +255 -0
- svc_infra/security/org_invites.py +128 -0
- svc_infra/security/passwords.py +77 -0
- svc_infra/security/permissions.py +149 -0
- svc_infra/security/session.py +98 -0
- svc_infra/security/signed_cookies.py +80 -0
- svc_infra/webhooks/__init__.py +16 -0
- svc_infra/webhooks/add.py +322 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +67 -0
- svc_infra/webhooks/signing.py +30 -0
- svc_infra-0.1.654.dist-info/METADATA +154 -0
- {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/RECORD +174 -56
- svc_infra-0.1.562.dist-info/METADATA +0 -79
- {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
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",
|
|
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
|
|
694
|
-
-
|
|
695
|
-
-
|
|
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
|
|
706
|
-
|
|
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
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
#
|
|
788
|
+
# inline param
|
|
732
789
|
name = p.get("name")
|
|
733
790
|
where = p.get("in")
|
|
734
|
-
if isinstance(name, str) and
|
|
735
|
-
|
|
791
|
+
if not (isinstance(name, str) and isinstance(where, str)):
|
|
792
|
+
result.append(p)
|
|
736
793
|
continue
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1456
|
+
add_ref_if_absent("IfMatch")
|
|
1457
|
+
|
|
1458
|
+
# ---- Conditional GET headers (optional via $ref) ----
|
|
1257
1459
|
if method == "get":
|
|
1258
|
-
|
|
1259
|
-
|
|
1460
|
+
add_ref_if_absent("IfNoneMatch")
|
|
1461
|
+
add_ref_if_absent("IfModifiedSince")
|
|
1260
1462
|
|
|
1261
|
-
#
|
|
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"]
|