svc-infra 0.1.506__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/alembic.py +11 -0
- svc_infra/apf_payments/models.py +339 -0
- 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 +270 -0
- svc_infra/apf_payments/provider/registry.py +31 -0
- svc_infra/apf_payments/provider/stripe.py +873 -0
- svc_infra/apf_payments/schemas.py +333 -0
- svc_infra/apf_payments/service.py +892 -0
- svc_infra/apf_payments/settings.py +67 -0
- svc_infra/api/fastapi/__init__.py +6 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +231 -0
- svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
- svc_infra/api/fastapi/apf_payments/router.py +1082 -0
- svc_infra/api/fastapi/apf_payments/setup.py +73 -0
- svc_infra/api/fastapi/auth/add.py +15 -6
- svc_infra/api/fastapi/auth/gaurd.py +67 -5
- svc_infra/api/fastapi/auth/mfa/router.py +18 -9
- svc_infra/api/fastapi/auth/routers/account.py +3 -2
- svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
- svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
- svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
- svc_infra/api/fastapi/auth/security.py +3 -1
- svc_infra/api/fastapi/auth/settings.py +2 -0
- svc_infra/api/fastapi/auth/state.py +1 -1
- 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 +14 -2
- 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 +254 -0
- svc_infra/api/fastapi/dual/dualize.py +38 -33
- svc_infra/api/fastapi/dual/router.py +48 -1
- svc_infra/api/fastapi/dx.py +3 -3
- svc_infra/api/fastapi/http/__init__.py +0 -0
- svc_infra/api/fastapi/http/concurrency.py +14 -0
- svc_infra/api/fastapi/http/conditional.py +33 -0
- svc_infra/api/fastapi/http/deprecation.py +21 -0
- 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 +116 -0
- 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 +119 -0
- svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
- svc_infra/api/fastapi/middleware/request_id.py +23 -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 +768 -55
- svc_infra/api/fastapi/ops/add.py +73 -0
- svc_infra/api/fastapi/pagination.py +363 -0
- svc_infra/api/fastapi/paths/auth.py +14 -14
- svc_infra/api/fastapi/paths/prefix.py +0 -1
- svc_infra/api/fastapi/paths/user.py +1 -1
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +48 -15
- 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 +120 -14
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
- 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/apikey.py +1 -1
- svc_infra/db/sql/authref.py +61 -0
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/repository.py +52 -12
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +16 -4
- svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
- 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.654.dist-info/RECORD +352 -0
- svc_infra/api/fastapi/deps.py +0 -3
- svc_infra-0.1.506.dist-info/METADATA +0 -78
- svc_infra-0.1.506.dist-info/RECORD +0 -213
- /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
|
@@ -47,6 +47,218 @@ def conventions_mutator():
|
|
|
47
47
|
return m
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
def pagination_components_mutator(
|
|
51
|
+
*,
|
|
52
|
+
default_limit: int = 50,
|
|
53
|
+
max_limit: int = 200,
|
|
54
|
+
) -> callable:
|
|
55
|
+
"""
|
|
56
|
+
Adds reusable pagination/filtering parameters & paginated envelope schemas.
|
|
57
|
+
- Cursor: cursor/limit
|
|
58
|
+
- Page: page/page_size
|
|
59
|
+
- Common filters: q, sort, created_[after|before], updated_[after|before]
|
|
60
|
+
- Envelope: PaginatedList<T>
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def m(schema: dict) -> dict:
|
|
64
|
+
schema = dict(schema)
|
|
65
|
+
comps = schema.setdefault("components", {})
|
|
66
|
+
params = comps.setdefault("parameters", {})
|
|
67
|
+
schemas = comps.setdefault("schemas", {})
|
|
68
|
+
|
|
69
|
+
# ---- Parameters (reusable) ----
|
|
70
|
+
params.setdefault(
|
|
71
|
+
"cursor",
|
|
72
|
+
{
|
|
73
|
+
"name": "cursor",
|
|
74
|
+
"in": "query",
|
|
75
|
+
"required": False,
|
|
76
|
+
"schema": {"type": "string"},
|
|
77
|
+
"description": "Opaque cursor for forward pagination.",
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
params.setdefault(
|
|
81
|
+
"limit",
|
|
82
|
+
{
|
|
83
|
+
"name": "limit",
|
|
84
|
+
"in": "query",
|
|
85
|
+
"required": False,
|
|
86
|
+
"schema": {
|
|
87
|
+
"type": "integer",
|
|
88
|
+
"minimum": 1,
|
|
89
|
+
"maximum": max_limit,
|
|
90
|
+
"default": default_limit,
|
|
91
|
+
},
|
|
92
|
+
"description": f"Max items to return (1..{max_limit}).",
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
params.setdefault(
|
|
96
|
+
"page",
|
|
97
|
+
{
|
|
98
|
+
"name": "page",
|
|
99
|
+
"in": "query",
|
|
100
|
+
"required": False,
|
|
101
|
+
"schema": {"type": "integer", "minimum": 1, "default": 1},
|
|
102
|
+
"description": "1-based page index (alternative to cursor).",
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
params.setdefault(
|
|
106
|
+
"page_size",
|
|
107
|
+
{
|
|
108
|
+
"name": "page_size",
|
|
109
|
+
"in": "query",
|
|
110
|
+
"required": False,
|
|
111
|
+
"schema": {
|
|
112
|
+
"type": "integer",
|
|
113
|
+
"minimum": 1,
|
|
114
|
+
"maximum": max_limit,
|
|
115
|
+
"default": default_limit,
|
|
116
|
+
},
|
|
117
|
+
"description": f"Number of items per page (1..{max_limit}).",
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
params.setdefault(
|
|
121
|
+
"q",
|
|
122
|
+
{
|
|
123
|
+
"name": "q",
|
|
124
|
+
"in": "query",
|
|
125
|
+
"required": False,
|
|
126
|
+
"schema": {"type": "string"},
|
|
127
|
+
"description": "Free-text filter query.",
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
params.setdefault(
|
|
131
|
+
"sort",
|
|
132
|
+
{
|
|
133
|
+
"name": "sort",
|
|
134
|
+
"in": "query",
|
|
135
|
+
"required": False,
|
|
136
|
+
"schema": {
|
|
137
|
+
"type": "string",
|
|
138
|
+
"examples": ["created_at", "-created_at", "name", "-name"],
|
|
139
|
+
},
|
|
140
|
+
"description": "Sort field, prefix '-' for descending.",
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
for fld in ("created", "updated"):
|
|
144
|
+
params.setdefault(
|
|
145
|
+
f"{fld}_after",
|
|
146
|
+
{
|
|
147
|
+
"name": f"{fld}_after",
|
|
148
|
+
"in": "query",
|
|
149
|
+
"required": False,
|
|
150
|
+
"schema": {"type": "string"},
|
|
151
|
+
"description": f"Only items with {fld}_at strictly after this HTTP-date (RFC 9110).",
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
params.setdefault(
|
|
155
|
+
f"{fld}_before",
|
|
156
|
+
{
|
|
157
|
+
"name": f"{fld}_before",
|
|
158
|
+
"in": "query",
|
|
159
|
+
"required": False,
|
|
160
|
+
"schema": {"type": "string"},
|
|
161
|
+
"description": f"Only items with {fld}_at strictly before this HTTP-date (RFC 9110).",
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# ---- Envelope schema (generic pattern) ----
|
|
166
|
+
# This is a non-generic "template" envelope; concrete versions are produced per endpoint, if desired.
|
|
167
|
+
schemas.setdefault(
|
|
168
|
+
"PaginatedList",
|
|
169
|
+
{
|
|
170
|
+
"type": "object",
|
|
171
|
+
"properties": {
|
|
172
|
+
"items": {"type": "array", "items": {"type": "object"}},
|
|
173
|
+
"next_cursor": {
|
|
174
|
+
"type": "string",
|
|
175
|
+
"nullable": True,
|
|
176
|
+
"description": "Opaque cursor for next page (null when no more).",
|
|
177
|
+
},
|
|
178
|
+
"total": {
|
|
179
|
+
"type": "integer",
|
|
180
|
+
"nullable": True,
|
|
181
|
+
"description": "Total items (may be null if not computed).",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
"required": ["items"],
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return schema
|
|
189
|
+
|
|
190
|
+
return m
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def auto_attach_pagination_params_mutator(
|
|
194
|
+
*,
|
|
195
|
+
mode: str = "cursor_or_page",
|
|
196
|
+
attach_filters: bool = True,
|
|
197
|
+
apply_when: str = "array_200",
|
|
198
|
+
flag_disable: str = "x_no_auto_pagination",
|
|
199
|
+
) -> callable:
|
|
200
|
+
"""
|
|
201
|
+
Attaches reusable pagination/filter parameters to GET "listy" operations.
|
|
202
|
+
|
|
203
|
+
- mode:
|
|
204
|
+
"cursor_or_page" -> attach cursor+limit and page+page_size (clients use either)
|
|
205
|
+
"cursor_only" -> attach cursor+limit
|
|
206
|
+
"page_only" -> attach page+page_size
|
|
207
|
+
- apply_when:
|
|
208
|
+
"array_200" -> only when response 200 schema is an array
|
|
209
|
+
"all_get" -> for every GET op
|
|
210
|
+
- Per-op opt-out: set operation's openapi_extra[flag_disable] = True
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
def _should_apply(op: dict) -> bool:
|
|
214
|
+
if op.get(flag_disable) is True:
|
|
215
|
+
return False
|
|
216
|
+
if apply_when == "all_get":
|
|
217
|
+
return True
|
|
218
|
+
# array_200: inspect 200->application/json->schema
|
|
219
|
+
resps = op.get("responses") or {}
|
|
220
|
+
r200 = resps.get("200")
|
|
221
|
+
if not isinstance(r200, dict):
|
|
222
|
+
return False
|
|
223
|
+
content = r200.get("content") or {}
|
|
224
|
+
mt = content.get("application/json")
|
|
225
|
+
if not isinstance(mt, dict):
|
|
226
|
+
return False
|
|
227
|
+
sch = mt.get("schema") or {}
|
|
228
|
+
return isinstance(sch, dict) and sch.get("type") == "array"
|
|
229
|
+
|
|
230
|
+
def m(schema: dict) -> dict:
|
|
231
|
+
schema = dict(schema)
|
|
232
|
+
for _, method, op in _iter_ops(schema):
|
|
233
|
+
if method != "get":
|
|
234
|
+
continue
|
|
235
|
+
if not _should_apply(op):
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
params = op.setdefault("parameters", [])
|
|
239
|
+
|
|
240
|
+
def _add_ref(name: str):
|
|
241
|
+
params.append({"$ref": f"#/components/parameters/{name}"})
|
|
242
|
+
|
|
243
|
+
if mode in ("cursor_only", "cursor_or_page"):
|
|
244
|
+
_add_ref("cursor")
|
|
245
|
+
_add_ref("limit")
|
|
246
|
+
if mode in ("page_only", "cursor_or_page"):
|
|
247
|
+
_add_ref("page")
|
|
248
|
+
_add_ref("page_size")
|
|
249
|
+
if attach_filters:
|
|
250
|
+
_add_ref("q")
|
|
251
|
+
_add_ref("sort")
|
|
252
|
+
_add_ref("created_after")
|
|
253
|
+
_add_ref("created_before")
|
|
254
|
+
_add_ref("updated_after")
|
|
255
|
+
_add_ref("updated_before")
|
|
256
|
+
|
|
257
|
+
return schema
|
|
258
|
+
|
|
259
|
+
return m
|
|
260
|
+
|
|
261
|
+
|
|
50
262
|
def normalize_problem_and_examples_mutator():
|
|
51
263
|
"""
|
|
52
264
|
1) Force components.schemas.Problem.properties.instance.format = "uri-reference".
|
|
@@ -155,6 +367,18 @@ def auth_mutator(include_api_key: bool):
|
|
|
155
367
|
|
|
156
368
|
def _m(schema: dict) -> dict:
|
|
157
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)
|
|
158
382
|
comps = schema.setdefault("components", {}).setdefault("securitySchemes", {})
|
|
159
383
|
comps.setdefault(
|
|
160
384
|
"OAuth2PasswordBearer",
|
|
@@ -163,11 +387,21 @@ def auth_mutator(include_api_key: bool):
|
|
|
163
387
|
"flows": {"password": {"tokenUrl": auth_login_path, "scopes": {}}},
|
|
164
388
|
},
|
|
165
389
|
)
|
|
166
|
-
|
|
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:
|
|
167
395
|
comps.setdefault(
|
|
168
|
-
"APIKeyHeader",
|
|
396
|
+
"APIKeyHeader",
|
|
397
|
+
{
|
|
398
|
+
"type": "apiKey",
|
|
399
|
+
"name": "X-API-Key",
|
|
400
|
+
"in": "header",
|
|
401
|
+
},
|
|
169
402
|
)
|
|
170
403
|
|
|
404
|
+
# Normalize operation security (drop only 'SessionCookie')
|
|
171
405
|
drop = {"SessionCookie"}
|
|
172
406
|
for _, _, op in _iter_ops(schema):
|
|
173
407
|
op["security"] = _normalize_security_list(op.get("security"), drop)
|
|
@@ -421,7 +655,9 @@ def ensure_request_body_descriptions_mutator(default_template="Request body for
|
|
|
421
655
|
|
|
422
656
|
|
|
423
657
|
def ensure_parameter_metadata_mutator(param_desc_template="{name} parameter."):
|
|
424
|
-
"""Add missing descriptions; enforce required for path params; ensure schema exists.
|
|
658
|
+
"""Add missing descriptions; enforce required for path params; ensure schema exists.
|
|
659
|
+
NOTE: Never touch $ref parameters here.
|
|
660
|
+
"""
|
|
425
661
|
|
|
426
662
|
def m(schema: dict) -> dict:
|
|
427
663
|
schema = dict(schema)
|
|
@@ -432,6 +668,9 @@ def ensure_parameter_metadata_mutator(param_desc_template="{name} parameter."):
|
|
|
432
668
|
for p in params:
|
|
433
669
|
if not isinstance(p, dict):
|
|
434
670
|
continue
|
|
671
|
+
if "$ref" in p:
|
|
672
|
+
# leave component parameters untouched
|
|
673
|
+
continue
|
|
435
674
|
name = p.get("name", "")
|
|
436
675
|
where = p.get("in", "")
|
|
437
676
|
# description
|
|
@@ -451,8 +690,123 @@ def ensure_parameter_metadata_mutator(param_desc_template="{name} parameter."):
|
|
|
451
690
|
return m
|
|
452
691
|
|
|
453
692
|
|
|
693
|
+
def strip_ref_siblings_in_parameters_mutator():
|
|
694
|
+
"""Normalize parameters: if an item has $ref, remove all other keys."""
|
|
695
|
+
|
|
696
|
+
def m(schema: dict) -> dict:
|
|
697
|
+
schema = dict(schema)
|
|
698
|
+
for _, _, op in _iter_ops(schema):
|
|
699
|
+
params = op.get("parameters")
|
|
700
|
+
if not isinstance(params, list):
|
|
701
|
+
continue
|
|
702
|
+
for p in params:
|
|
703
|
+
if isinstance(p, dict) and "$ref" in p and len(p) > 1:
|
|
704
|
+
ref = p["$ref"]
|
|
705
|
+
p.clear()
|
|
706
|
+
p["$ref"] = ref
|
|
707
|
+
return schema
|
|
708
|
+
|
|
709
|
+
return m
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def dedupe_parameters_mutator():
|
|
713
|
+
"""
|
|
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.
|
|
718
|
+
"""
|
|
719
|
+
|
|
720
|
+
def m(schema: dict) -> dict:
|
|
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
|
+
|
|
738
|
+
for _, _, op in _iter_ops(schema):
|
|
739
|
+
params = op.get("parameters")
|
|
740
|
+
if not isinstance(params, list) or not params:
|
|
741
|
+
continue
|
|
742
|
+
|
|
743
|
+
# First pass: collect inline params by (name, in)
|
|
744
|
+
inline_by_key: dict[tuple[str, str], dict] = {}
|
|
745
|
+
for p in params:
|
|
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()
|
|
755
|
+
result: list[dict] = []
|
|
756
|
+
|
|
757
|
+
for p in params:
|
|
758
|
+
if not isinstance(p, dict):
|
|
759
|
+
continue
|
|
760
|
+
|
|
761
|
+
if "$ref" in p:
|
|
762
|
+
ref = p.get("$ref")
|
|
763
|
+
if not isinstance(ref, str):
|
|
764
|
+
continue
|
|
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})
|
|
786
|
+
continue
|
|
787
|
+
|
|
788
|
+
# inline param
|
|
789
|
+
name = p.get("name")
|
|
790
|
+
where = p.get("in")
|
|
791
|
+
if not (isinstance(name, str) and isinstance(where, str)):
|
|
792
|
+
result.append(p)
|
|
793
|
+
continue
|
|
794
|
+
key = (name, where)
|
|
795
|
+
if key in seen_keys:
|
|
796
|
+
# already kept an inline with same (name, in)
|
|
797
|
+
continue
|
|
798
|
+
seen_keys.add(key)
|
|
799
|
+
result.append(p)
|
|
800
|
+
|
|
801
|
+
op["parameters"] = result
|
|
802
|
+
|
|
803
|
+
return schema
|
|
804
|
+
|
|
805
|
+
return m
|
|
806
|
+
|
|
807
|
+
|
|
454
808
|
def normalize_no_content_204_mutator():
|
|
455
|
-
"""Ensure 204 responses have description and no content."""
|
|
809
|
+
"""Ensure 204 responses have 'No Content' description and no content."""
|
|
456
810
|
|
|
457
811
|
def m(schema: dict) -> dict:
|
|
458
812
|
schema = dict(schema)
|
|
@@ -462,8 +816,9 @@ def normalize_no_content_204_mutator():
|
|
|
462
816
|
continue
|
|
463
817
|
r204 = resps.get("204")
|
|
464
818
|
if isinstance(r204, dict):
|
|
465
|
-
|
|
466
|
-
|
|
819
|
+
# Always normalize text to the conventional phrasing
|
|
820
|
+
r204["description"] = "No Content"
|
|
821
|
+
# Many validators prefer no 'content' for 204
|
|
467
822
|
if "content" in r204:
|
|
468
823
|
r204.pop("content", None)
|
|
469
824
|
return schema
|
|
@@ -471,6 +826,53 @@ def normalize_no_content_204_mutator():
|
|
|
471
826
|
return m
|
|
472
827
|
|
|
473
828
|
|
|
829
|
+
def inject_safe_examples_mutator():
|
|
830
|
+
"""
|
|
831
|
+
Inject a couple of specific, schema-safe examples:
|
|
832
|
+
- If a 2xx application/json response uses #/components/schemas/SendEmailCodeOut
|
|
833
|
+
and has no example(s), set {"sent": true, "cooldown_seconds": 60}.
|
|
834
|
+
- For GET/any 2xx on /ping, add {"status": "ok"} if example(s) are missing.
|
|
835
|
+
Never overwrites existing example/examples.
|
|
836
|
+
"""
|
|
837
|
+
|
|
838
|
+
def _has_examples(mt_obj: dict) -> bool:
|
|
839
|
+
return isinstance(mt_obj, dict) and ("example" in mt_obj or "examples" in mt_obj)
|
|
840
|
+
|
|
841
|
+
def m(schema: dict) -> dict:
|
|
842
|
+
schema = dict(schema)
|
|
843
|
+
for path, method, op in _iter_ops(schema):
|
|
844
|
+
resps = op.get("responses") or {}
|
|
845
|
+
for code, resp in resps.items():
|
|
846
|
+
if not isinstance(resp, dict):
|
|
847
|
+
continue
|
|
848
|
+
try:
|
|
849
|
+
ic = int(code)
|
|
850
|
+
except Exception:
|
|
851
|
+
continue
|
|
852
|
+
if not (200 <= ic < 300):
|
|
853
|
+
continue
|
|
854
|
+
|
|
855
|
+
content = resp.get("content") or {}
|
|
856
|
+
mt_obj = content.get("application/json")
|
|
857
|
+
if not isinstance(mt_obj, dict) or _has_examples(mt_obj):
|
|
858
|
+
continue
|
|
859
|
+
|
|
860
|
+
# Special-case: SendEmailCodeOut by $ref
|
|
861
|
+
sch = mt_obj.get("schema") or {}
|
|
862
|
+
ref = sch.get("$ref") if isinstance(sch, dict) else None
|
|
863
|
+
if isinstance(ref, str) and ref.endswith("/SendEmailCodeOut"):
|
|
864
|
+
mt_obj["example"] = {"sent": True, "cooldown_seconds": 60}
|
|
865
|
+
continue
|
|
866
|
+
|
|
867
|
+
# Special-case: /ping success body
|
|
868
|
+
if path == "/ping":
|
|
869
|
+
mt_obj["example"] = {"status": "ok"}
|
|
870
|
+
|
|
871
|
+
return schema
|
|
872
|
+
|
|
873
|
+
return m
|
|
874
|
+
|
|
875
|
+
|
|
474
876
|
def prune_invalid_responses_keys_mutator():
|
|
475
877
|
"""In an operation's responses object, only status codes or 'default' are allowed."""
|
|
476
878
|
|
|
@@ -664,12 +1066,11 @@ def improve_success_response_descriptions_mutator():
|
|
|
664
1066
|
|
|
665
1067
|
|
|
666
1068
|
def ensure_success_examples_mutator():
|
|
667
|
-
"""Ensure 2xx application/json responses have an example."""
|
|
1069
|
+
"""Ensure 2xx application/json responses have an example (but only when safe)."""
|
|
668
1070
|
|
|
669
1071
|
def m(schema: dict) -> dict:
|
|
670
1072
|
schema = dict(schema)
|
|
671
1073
|
for _, _, op in _iter_ops(schema):
|
|
672
|
-
summary = op.get("summary") or ""
|
|
673
1074
|
resps = op.get("responses") or {}
|
|
674
1075
|
for code, resp in resps.items():
|
|
675
1076
|
if not isinstance(resp, dict):
|
|
@@ -680,29 +1081,133 @@ def ensure_success_examples_mutator():
|
|
|
680
1081
|
continue
|
|
681
1082
|
if not (200 <= ic < 300) or ic == 204:
|
|
682
1083
|
continue
|
|
683
|
-
|
|
684
|
-
mt_obj
|
|
685
|
-
if not isinstance(mt_obj, dict):
|
|
686
|
-
continue
|
|
687
|
-
if "example" in mt_obj or "examples" in mt_obj:
|
|
1084
|
+
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:
|
|
688
1086
|
continue
|
|
689
1087
|
sch = mt_obj.get("schema") or {}
|
|
690
|
-
|
|
691
|
-
|
|
1088
|
+
|
|
1089
|
+
# Only set examples for primitives/arrays. Never for object/$ref.
|
|
1090
|
+
t = sch.get("type")
|
|
1091
|
+
if t == "string":
|
|
692
1092
|
mt_obj["example"] = "example"
|
|
693
|
-
elif
|
|
1093
|
+
elif t == "boolean":
|
|
694
1094
|
mt_obj["example"] = True
|
|
695
|
-
elif
|
|
1095
|
+
elif t == "integer":
|
|
696
1096
|
mt_obj["example"] = 0
|
|
697
|
-
elif
|
|
1097
|
+
elif t == "array":
|
|
698
1098
|
mt_obj["example"] = []
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
1099
|
+
# NOTE: no else/fallback for object/$ref
|
|
1100
|
+
return schema
|
|
1101
|
+
|
|
1102
|
+
return m
|
|
1103
|
+
|
|
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]
|
|
706
1211
|
return schema
|
|
707
1212
|
|
|
708
1213
|
return m
|
|
@@ -722,32 +1227,33 @@ def dedupe_tags_mutator():
|
|
|
722
1227
|
|
|
723
1228
|
def scrub_invalid_object_examples_mutator():
|
|
724
1229
|
"""
|
|
725
|
-
|
|
726
|
-
|
|
1230
|
+
Remove media examples that are objects but don't satisfy required keys
|
|
1231
|
+
(or when schema is a $ref/object). Keeps primitives/arrays.
|
|
727
1232
|
"""
|
|
728
1233
|
|
|
729
|
-
def
|
|
1234
|
+
def _invalid_object_example(sch: dict, ex: dict) -> bool:
|
|
1235
|
+
if not isinstance(sch, dict) or not isinstance(ex, dict):
|
|
1236
|
+
return False
|
|
1237
|
+
if "$ref" in sch:
|
|
1238
|
+
return True # can't validate here → drop
|
|
1239
|
+
if sch.get("type") == "object" or "properties" in sch or "required" in sch:
|
|
1240
|
+
req = set(sch.get("required") or [])
|
|
1241
|
+
return bool(req) and not req.issubset(ex.keys())
|
|
1242
|
+
return False
|
|
1243
|
+
|
|
1244
|
+
def _patch(node: dict):
|
|
730
1245
|
content = node.get("content")
|
|
731
1246
|
if not isinstance(content, dict):
|
|
732
1247
|
return
|
|
733
1248
|
for mt_obj in content.values():
|
|
734
1249
|
if not isinstance(mt_obj, dict):
|
|
735
1250
|
continue
|
|
736
|
-
if "example" not in mt_obj:
|
|
737
|
-
continue
|
|
738
1251
|
sch = mt_obj.get("schema")
|
|
739
1252
|
ex = mt_obj.get("example")
|
|
740
|
-
if
|
|
741
|
-
|
|
742
|
-
# Follow $ref if you want; simplest is: only act on obvious object cases.
|
|
743
|
-
if (
|
|
744
|
-
sch.get("type") == "object"
|
|
745
|
-
or "properties" in sch
|
|
746
|
-
or "required" in sch
|
|
747
|
-
or "$ref" in sch
|
|
1253
|
+
if "example" in mt_obj and _invalid_object_example(
|
|
1254
|
+
sch if isinstance(sch, dict) else {}, ex
|
|
748
1255
|
):
|
|
749
|
-
|
|
750
|
-
mt_obj.pop("example", None)
|
|
1256
|
+
mt_obj.pop("example", None)
|
|
751
1257
|
|
|
752
1258
|
def m(schema: dict) -> dict:
|
|
753
1259
|
schema = dict(schema)
|
|
@@ -756,10 +1262,10 @@ def scrub_invalid_object_examples_mutator():
|
|
|
756
1262
|
if isinstance(resps, dict):
|
|
757
1263
|
for resp in resps.values():
|
|
758
1264
|
if isinstance(resp, dict):
|
|
759
|
-
|
|
1265
|
+
_patch(resp)
|
|
760
1266
|
rb = op.get("requestBody")
|
|
761
1267
|
if isinstance(rb, dict):
|
|
762
|
-
|
|
1268
|
+
_patch(rb)
|
|
763
1269
|
return schema
|
|
764
1270
|
|
|
765
1271
|
return m
|
|
@@ -792,25 +1298,224 @@ def drop_unused_components_mutator_auto(keep_schemas: set[str] | None = None):
|
|
|
792
1298
|
|
|
793
1299
|
# schemas
|
|
794
1300
|
sch = comps.get("schemas") or {}
|
|
795
|
-
|
|
796
|
-
name
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
for name in to_drop:
|
|
801
|
-
sch.pop(name, None)
|
|
802
|
-
|
|
803
|
-
# responses (optional; usually all are used)
|
|
1301
|
+
for name in list(sch.keys()):
|
|
1302
|
+
if name not in keep_schemas and name not in (used.get("schemas") or set()):
|
|
1303
|
+
sch.pop(name, None)
|
|
1304
|
+
|
|
1305
|
+
# responses
|
|
804
1306
|
resps = comps.get("responses") or {}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
1307
|
+
for name in list(resps.keys()):
|
|
1308
|
+
if name not in (used.get("responses") or set()):
|
|
1309
|
+
resps.pop(name, None)
|
|
1310
|
+
|
|
1311
|
+
# NEW: headers
|
|
1312
|
+
hdrs = comps.get("headers") or {}
|
|
1313
|
+
for name in list(hdrs.keys()):
|
|
1314
|
+
if name not in (used.get("headers") or set()):
|
|
1315
|
+
hdrs.pop(name, None)
|
|
1316
|
+
|
|
1317
|
+
# (Optional: parameters/requestBodies/etc. same pattern)
|
|
808
1318
|
|
|
809
1319
|
return schema
|
|
810
1320
|
|
|
811
1321
|
return m
|
|
812
1322
|
|
|
813
1323
|
|
|
1324
|
+
def hardening_components_mutator():
|
|
1325
|
+
def m(schema: dict) -> dict:
|
|
1326
|
+
schema = dict(schema)
|
|
1327
|
+
comps = schema.setdefault("components", {})
|
|
1328
|
+
params = comps.setdefault("parameters", {})
|
|
1329
|
+
headers = comps.setdefault("headers", {})
|
|
1330
|
+
|
|
1331
|
+
params.setdefault(
|
|
1332
|
+
"IdempotencyKey",
|
|
1333
|
+
{
|
|
1334
|
+
"name": "Idempotency-Key",
|
|
1335
|
+
"in": "header",
|
|
1336
|
+
"required": False,
|
|
1337
|
+
"schema": {"type": "string"},
|
|
1338
|
+
"description": "Provide to make the request idempotent for 24h.",
|
|
1339
|
+
},
|
|
1340
|
+
)
|
|
1341
|
+
params.setdefault(
|
|
1342
|
+
"IfNoneMatch",
|
|
1343
|
+
{
|
|
1344
|
+
"name": "If-None-Match",
|
|
1345
|
+
"in": "header",
|
|
1346
|
+
"required": False,
|
|
1347
|
+
"schema": {"type": "string"},
|
|
1348
|
+
"description": "Conditional GET (ETag).",
|
|
1349
|
+
},
|
|
1350
|
+
)
|
|
1351
|
+
params.setdefault(
|
|
1352
|
+
"IfModifiedSince",
|
|
1353
|
+
{
|
|
1354
|
+
"name": "If-Modified-Since",
|
|
1355
|
+
"in": "header",
|
|
1356
|
+
"required": False,
|
|
1357
|
+
"schema": {"type": "string"},
|
|
1358
|
+
"description": "Conditional GET. HTTP-date per RFC 9110 (e.g. 'Wed, 01 Jan 2025 00:00:00 GMT').",
|
|
1359
|
+
},
|
|
1360
|
+
)
|
|
1361
|
+
params.setdefault(
|
|
1362
|
+
"IfMatch",
|
|
1363
|
+
{
|
|
1364
|
+
"name": "If-Match",
|
|
1365
|
+
"in": "header",
|
|
1366
|
+
"required": False,
|
|
1367
|
+
"schema": {"type": "string"},
|
|
1368
|
+
"description": "Optimistic concurrency for updates.",
|
|
1369
|
+
},
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
headers.setdefault(
|
|
1373
|
+
"ETag", {"schema": {"type": "string"}, "description": "Opaque entity tag."}
|
|
1374
|
+
)
|
|
1375
|
+
headers.setdefault(
|
|
1376
|
+
"LastModified",
|
|
1377
|
+
{
|
|
1378
|
+
"schema": {"type": "string"}, # HTTP-date string
|
|
1379
|
+
"description": "Last modification time, HTTP-date per RFC 9110.",
|
|
1380
|
+
},
|
|
1381
|
+
)
|
|
1382
|
+
headers.setdefault(
|
|
1383
|
+
"XRateLimitLimit", {"schema": {"type": "integer"}, "description": "Tokens in window."}
|
|
1384
|
+
)
|
|
1385
|
+
headers.setdefault(
|
|
1386
|
+
"XRateLimitRemaining",
|
|
1387
|
+
{"schema": {"type": "integer"}, "description": "Remaining tokens."},
|
|
1388
|
+
)
|
|
1389
|
+
headers.setdefault(
|
|
1390
|
+
"XRateLimitReset", {"schema": {"type": "integer"}, "description": "Unix reset time."}
|
|
1391
|
+
)
|
|
1392
|
+
headers.setdefault(
|
|
1393
|
+
"XRequestId", {"schema": {"type": "string"}, "description": "Correlation id."}
|
|
1394
|
+
)
|
|
1395
|
+
headers.setdefault(
|
|
1396
|
+
"Deprecation",
|
|
1397
|
+
{
|
|
1398
|
+
"schema": {"type": "string"},
|
|
1399
|
+
"description": "Set to 'true' for deprecated endpoints.",
|
|
1400
|
+
},
|
|
1401
|
+
)
|
|
1402
|
+
headers.setdefault(
|
|
1403
|
+
"Sunset",
|
|
1404
|
+
{"schema": {"type": "string"}, "description": "HTTP-date for deprecation sunset."},
|
|
1405
|
+
)
|
|
1406
|
+
return schema
|
|
1407
|
+
|
|
1408
|
+
return m
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def attach_header_params_mutator():
|
|
1412
|
+
def m(schema: dict) -> dict:
|
|
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
|
+
|
|
1429
|
+
for _, method, op in _iter_ops(schema):
|
|
1430
|
+
params = op.setdefault("parameters", [])
|
|
1431
|
+
|
|
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.
|
|
1451
|
+
if method in ("post", "patch", "delete"):
|
|
1452
|
+
if ("Idempotency-Key", "header") not in inline_names:
|
|
1453
|
+
add_ref_if_absent("IdempotencyKey")
|
|
1454
|
+
# Optional optimistic concurrency for updates
|
|
1455
|
+
if method in ("patch", "put"):
|
|
1456
|
+
add_ref_if_absent("IfMatch")
|
|
1457
|
+
|
|
1458
|
+
# ---- Conditional GET headers (optional via $ref) ----
|
|
1459
|
+
if method == "get":
|
|
1460
|
+
add_ref_if_absent("IfNoneMatch")
|
|
1461
|
+
add_ref_if_absent("IfModifiedSince")
|
|
1462
|
+
|
|
1463
|
+
# ---- Standard success/429 headers (unchanged) ----
|
|
1464
|
+
resps = op.get("responses") or {}
|
|
1465
|
+
for code, resp in resps.items():
|
|
1466
|
+
try:
|
|
1467
|
+
ic = int(code)
|
|
1468
|
+
except Exception:
|
|
1469
|
+
continue
|
|
1470
|
+
if 200 <= ic < 300:
|
|
1471
|
+
hdrs = resp.setdefault("headers", {})
|
|
1472
|
+
hdrs.setdefault("ETag", {"$ref": "#/components/headers/ETag"})
|
|
1473
|
+
hdrs.setdefault("Last-Modified", {"$ref": "#/components/headers/LastModified"})
|
|
1474
|
+
hdrs.setdefault("X-Request-Id", {"$ref": "#/components/headers/XRequestId"})
|
|
1475
|
+
hdrs.setdefault(
|
|
1476
|
+
"X-RateLimit-Limit", {"$ref": "#/components/headers/XRateLimitLimit"}
|
|
1477
|
+
)
|
|
1478
|
+
hdrs.setdefault(
|
|
1479
|
+
"X-RateLimit-Remaining",
|
|
1480
|
+
{"$ref": "#/components/headers/XRateLimitRemaining"},
|
|
1481
|
+
)
|
|
1482
|
+
hdrs.setdefault(
|
|
1483
|
+
"X-RateLimit-Reset", {"$ref": "#/components/headers/XRateLimitReset"}
|
|
1484
|
+
)
|
|
1485
|
+
if code == "429":
|
|
1486
|
+
resp.setdefault("headers", {})["Retry-After"] = {
|
|
1487
|
+
"schema": {"type": "integer"},
|
|
1488
|
+
"description": "Seconds until next allowed request.",
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
return schema
|
|
1492
|
+
|
|
1493
|
+
return m
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def attach_conditional_get_304_mutator():
|
|
1497
|
+
def m(schema: dict) -> dict:
|
|
1498
|
+
schema = dict(schema)
|
|
1499
|
+
for _, method, op in _iter_ops(schema):
|
|
1500
|
+
if method != "get":
|
|
1501
|
+
continue
|
|
1502
|
+
resps = op.setdefault("responses", {})
|
|
1503
|
+
resps.setdefault(
|
|
1504
|
+
"304",
|
|
1505
|
+
{
|
|
1506
|
+
"description": "Not Modified",
|
|
1507
|
+
"headers": {
|
|
1508
|
+
"ETag": {"$ref": "#/components/headers/ETag"},
|
|
1509
|
+
"Last-Modified": {"$ref": "#/components/headers/LastModified"},
|
|
1510
|
+
"X-Request-Id": {"$ref": "#/components/headers/XRequestId"},
|
|
1511
|
+
},
|
|
1512
|
+
},
|
|
1513
|
+
)
|
|
1514
|
+
return schema
|
|
1515
|
+
|
|
1516
|
+
return m
|
|
1517
|
+
|
|
1518
|
+
|
|
814
1519
|
def setup_mutators(
|
|
815
1520
|
service: ServiceInfo,
|
|
816
1521
|
spec: APIVersionSpec | None,
|
|
@@ -819,22 +1524,31 @@ def setup_mutators(
|
|
|
819
1524
|
) -> list:
|
|
820
1525
|
mutators = [
|
|
821
1526
|
conventions_mutator(),
|
|
1527
|
+
hardening_components_mutator(),
|
|
1528
|
+
attach_header_params_mutator(),
|
|
822
1529
|
normalize_problem_and_examples_mutator(),
|
|
823
1530
|
attach_standard_responses_mutator(),
|
|
1531
|
+
attach_conditional_get_304_mutator(),
|
|
824
1532
|
auth_mutator(include_api_key),
|
|
825
1533
|
strip_ref_siblings_in_responses_mutator(),
|
|
826
1534
|
prune_invalid_responses_keys_mutator(),
|
|
1535
|
+
strip_ref_siblings_in_parameters_mutator(),
|
|
1536
|
+
dedupe_parameters_mutator(),
|
|
827
1537
|
ensure_operation_descriptions_mutator(),
|
|
828
1538
|
ensure_request_body_descriptions_mutator(),
|
|
829
1539
|
ensure_parameter_metadata_mutator(),
|
|
830
1540
|
ensure_media_type_schemas_mutator(),
|
|
831
1541
|
ensure_examples_for_json_mutator(),
|
|
832
1542
|
ensure_success_examples_mutator(),
|
|
1543
|
+
attach_default_tags_mutator(),
|
|
1544
|
+
attach_code_samples_mutator(),
|
|
1545
|
+
ensure_problem_examples_mutator(),
|
|
833
1546
|
ensure_media_examples_mutator(),
|
|
834
1547
|
scrub_invalid_object_examples_mutator(),
|
|
835
1548
|
normalize_no_content_204_mutator(),
|
|
836
1549
|
ensure_response_descriptions_mutator(),
|
|
837
1550
|
improve_success_response_descriptions_mutator(),
|
|
1551
|
+
inject_safe_examples_mutator(),
|
|
838
1552
|
dedupe_tags_mutator(),
|
|
839
1553
|
ensure_global_tags_mutator(),
|
|
840
1554
|
drop_unused_components_mutator_auto(),
|
|
@@ -842,5 +1556,4 @@ def setup_mutators(
|
|
|
842
1556
|
]
|
|
843
1557
|
if server_url:
|
|
844
1558
|
mutators.append(servers_mutator(server_url))
|
|
845
|
-
|
|
846
1559
|
return mutators
|