spring-runtime-mcp 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spring_runtime_mcp/__init__.py +3 -0
- spring_runtime_mcp/adapters/__init__.py +1 -0
- spring_runtime_mcp/adapters/beans.py +159 -0
- spring_runtime_mcp/adapters/conditions.py +333 -0
- spring_runtime_mcp/adapters/config.py +295 -0
- spring_runtime_mcp/adapters/drift.py +790 -0
- spring_runtime_mcp/adapters/health.py +164 -0
- spring_runtime_mcp/adapters/mapping_predicates.py +201 -0
- spring_runtime_mcp/adapters/mappings.py +294 -0
- spring_runtime_mcp/adapters/migrations.py +373 -0
- spring_runtime_mcp/adapters/openapi.py +683 -0
- spring_runtime_mcp/adapters/source_catalog.py +119 -0
- spring_runtime_mcp/adapters/startup.py +202 -0
- spring_runtime_mcp/analyzers/__init__.py +1 -0
- spring_runtime_mcp/analyzers/bean_graph.py +172 -0
- spring_runtime_mcp/analyzers/condition_failures.py +359 -0
- spring_runtime_mcp/cli.py +248 -0
- spring_runtime_mcp/contracts/__init__.py +1 -0
- spring_runtime_mcp/contracts/definitions.py +425 -0
- spring_runtime_mcp/diagnostics.py +254 -0
- spring_runtime_mcp/models/__init__.py +1 -0
- spring_runtime_mcp/models/bean_graph.py +77 -0
- spring_runtime_mcp/models/common.py +99 -0
- spring_runtime_mcp/models/conditions.py +56 -0
- spring_runtime_mcp/models/queries.py +140 -0
- spring_runtime_mcp/models/source_catalog.py +35 -0
- spring_runtime_mcp/models/timestamps.py +71 -0
- spring_runtime_mcp/policy/__init__.py +1 -0
- spring_runtime_mcp/policy/configuration.py +32 -0
- spring_runtime_mcp/policy/output_guard.py +117 -0
- spring_runtime_mcp/policy/sanitization.py +616 -0
- spring_runtime_mcp/py.typed +0 -0
- spring_runtime_mcp/replay.py +220 -0
- spring_runtime_mcp/replay_data/bean_graph/README.md +26 -0
- spring_runtime_mcp/replay_data/bean_graph/factory-method/conditions.json +43 -0
- spring_runtime_mcp/replay_data/bean_graph/factory-method/source-catalog.json +54 -0
- spring_runtime_mcp/replay_data/bean_graph/nested-conditions/beans.json +99 -0
- spring_runtime_mcp/replay_data/bean_graph/nested-conditions/conditions.json +25 -0
- spring_runtime_mcp/replay_data/bean_graph/parent-child/beans.json +92 -0
- spring_runtime_mcp/replay_data/bean_graph/shape-metadata.json +32 -0
- spring_runtime_mcp/replay_data/bean_graph/stripe-missing/conditions.json +43 -0
- spring_runtime_mcp/replay_data/bean_graph/stripe-missing/source-catalog.json +54 -0
- spring_runtime_mcp/replay_data/conformance/README.md +41 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v2.json +14 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/servlet-namespace-comparison.json +4 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_analyze_startup.json +62 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_check_migrations.json +63 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_detect_drift.json +64 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_diagnose_health.json +59 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_beans.json +56 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_config.json +62 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_search_openapi.json +80 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_verify_mappings.json +58 -0
- spring_runtime_mcp/replay_data/conformance/faults/cases.json +63 -0
- spring_runtime_mcp/replay_data/conformance/faults/health-hidden.expected.json +54 -0
- spring_runtime_mcp/replay_data/conformance/faults/health-hidden.json +3 -0
- spring_runtime_mcp/replay_data/conformance/large/beans-2500.json +22506 -0
- spring_runtime_mcp/replay_data/conformance/manifest.json +46 -0
- spring_runtime_mcp/schemas/tools-contract.json +1902 -0
- spring_runtime_mcp/server.py +322 -0
- spring_runtime_mcp/services.py +44 -0
- spring_runtime_mcp/snapshots.py +340 -0
- spring_runtime_mcp/tools/__init__.py +1 -0
- spring_runtime_mcp/tools/analyze_startup.py +79 -0
- spring_runtime_mcp/tools/check_migrations.py +99 -0
- spring_runtime_mcp/tools/detect_drift.py +323 -0
- spring_runtime_mcp/tools/diagnose_health.py +88 -0
- spring_runtime_mcp/tools/inspect_beans.py +479 -0
- spring_runtime_mcp/tools/inspect_config.py +102 -0
- spring_runtime_mcp/tools/registry.py +48 -0
- spring_runtime_mcp/tools/search_openapi.py +79 -0
- spring_runtime_mcp/tools/support.py +166 -0
- spring_runtime_mcp/tools/verify_mappings.py +62 -0
- spring_runtime_mcp/transport/__init__.py +1 -0
- spring_runtime_mcp/transport/actuator_http.py +459 -0
- spring_runtime_mcp/transport/bounded_json.py +144 -0
- spring_runtime_mcp/transport/replay_files.py +71 -0
- spring_runtime_mcp/transport/response_gate.py +186 -0
- spring_runtime_mcp/transport/responses.py +82 -0
- spring_runtime_mcp/transport/stdio.py +44 -0
- spring_runtime_mcp-0.2.0.dist-info/METADATA +320 -0
- spring_runtime_mcp-0.2.0.dist-info/RECORD +118 -0
- spring_runtime_mcp-0.2.0.dist-info/WHEEL +4 -0
- spring_runtime_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- spring_runtime_mcp-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
"""Local OpenAPI $ref expansion. Remote references are never fetched."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
import re
|
|
7
|
+
from urllib.parse import unquote
|
|
8
|
+
|
|
9
|
+
from ..contracts.definitions import glob_matches
|
|
10
|
+
from ..models.common import AnalysisBudget, AnalysisLimit
|
|
11
|
+
from ..models.queries import OpenApiQuery
|
|
12
|
+
|
|
13
|
+
_EXPANSION_LIMIT_GAP = "REF_EXPANSION_LIMIT"
|
|
14
|
+
_OAS_DIALECT = "https://spec.openapis.org/oas/3.1/dialect/base"
|
|
15
|
+
_DIALECTS = frozenset({_OAS_DIALECT, "https://json-schema.org/draft/2020-12/schema"})
|
|
16
|
+
_DOCUMENT_DIALECT = object()
|
|
17
|
+
|
|
18
|
+
_METHODS = {
|
|
19
|
+
"get",
|
|
20
|
+
"head",
|
|
21
|
+
"post",
|
|
22
|
+
"put",
|
|
23
|
+
"patch",
|
|
24
|
+
"delete",
|
|
25
|
+
"options",
|
|
26
|
+
"trace",
|
|
27
|
+
}
|
|
28
|
+
_SCHEMA_KEYS = {
|
|
29
|
+
"type",
|
|
30
|
+
"format",
|
|
31
|
+
"nullable",
|
|
32
|
+
"readOnly",
|
|
33
|
+
"writeOnly",
|
|
34
|
+
"minimum",
|
|
35
|
+
"maximum",
|
|
36
|
+
"exclusiveMinimum",
|
|
37
|
+
"exclusiveMaximum",
|
|
38
|
+
"multipleOf",
|
|
39
|
+
"minLength",
|
|
40
|
+
"maxLength",
|
|
41
|
+
"pattern",
|
|
42
|
+
"minItems",
|
|
43
|
+
"maxItems",
|
|
44
|
+
"uniqueItems",
|
|
45
|
+
"required",
|
|
46
|
+
"enum",
|
|
47
|
+
}
|
|
48
|
+
_SCHEMA_TYPES = frozenset(
|
|
49
|
+
{"null", "boolean", "object", "array", "number", "string", "integer"}
|
|
50
|
+
)
|
|
51
|
+
_METADATA_ARRAY_LIMITS = {"type": 7, "required": 50, "enum": 16}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _pointer(document: dict[str, Any], ref: str) -> Any:
|
|
55
|
+
if not ref.startswith("#/"):
|
|
56
|
+
return None
|
|
57
|
+
node: Any = document
|
|
58
|
+
try:
|
|
59
|
+
fragment = unquote(ref[2:], errors="strict")
|
|
60
|
+
except UnicodeError:
|
|
61
|
+
return None
|
|
62
|
+
for raw in fragment.split("/"):
|
|
63
|
+
if re.search(r"~(?![01])", raw):
|
|
64
|
+
return None
|
|
65
|
+
part = raw.replace("~1", "/").replace("~0", "~")
|
|
66
|
+
if isinstance(node, dict) and part in node:
|
|
67
|
+
node = node[part]
|
|
68
|
+
elif (
|
|
69
|
+
isinstance(node, list)
|
|
70
|
+
and re.fullmatch(r"0|[1-9][0-9]*", part)
|
|
71
|
+
and int(part) < len(node)
|
|
72
|
+
):
|
|
73
|
+
node = node[int(part)]
|
|
74
|
+
else:
|
|
75
|
+
return None
|
|
76
|
+
return node
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _schema_name(ref: str) -> str | None:
|
|
80
|
+
marker = "/components/schemas/"
|
|
81
|
+
if marker not in ref:
|
|
82
|
+
return None
|
|
83
|
+
return ref.rsplit("/", 1)[-1] or None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _Expander:
|
|
87
|
+
"""Project local schemas under one shared, per-query analysis budget.
|
|
88
|
+
|
|
89
|
+
The budget counts every expansion node across all operations and shared
|
|
90
|
+
references, so a wide fan-out of `$ref`s cannot multiply work per property
|
|
91
|
+
or per reference. Exhaustion yields a bounded placeholder plus an explicit
|
|
92
|
+
gap instead of building the remaining subtrees.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
document: dict[str, Any],
|
|
98
|
+
max_depth: int,
|
|
99
|
+
budget: AnalysisBudget | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.document = document
|
|
102
|
+
self.max_depth = max_depth
|
|
103
|
+
self.budget = AnalysisBudget() if budget is None else budget
|
|
104
|
+
self.gaps: set[str] = set()
|
|
105
|
+
self.blocked_name: str | None = None
|
|
106
|
+
self._exhausted = False
|
|
107
|
+
self.is_31 = str(document.get("openapi", "")).startswith("3.1.")
|
|
108
|
+
self.dialect = document.get("jsonSchemaDialect", _OAS_DIALECT)
|
|
109
|
+
self._targets: dict[str, Any] = {}
|
|
110
|
+
self._target_dialects: dict[str, Any] = {}
|
|
111
|
+
self._unsupported_target_bases: set[str] = set()
|
|
112
|
+
|
|
113
|
+
def target(self, ref: str) -> Any:
|
|
114
|
+
if len(ref) > 512:
|
|
115
|
+
self.gaps.add("REF_INVALID")
|
|
116
|
+
return None
|
|
117
|
+
# Cache pointer lookup separately from path-local cycle detection.
|
|
118
|
+
if ref not in self._targets:
|
|
119
|
+
self._targets[ref] = _pointer(self.document, ref)
|
|
120
|
+
# Determine the target's lexical scope, independently of the caller.
|
|
121
|
+
# Map containers are not Schema objects: a business field named
|
|
122
|
+
# $schema must not change the dialect of its siblings.
|
|
123
|
+
node: Any = self.document
|
|
124
|
+
dialect = self.dialect
|
|
125
|
+
role = "outside"
|
|
126
|
+
parts = [
|
|
127
|
+
part.replace("~1", "/").replace("~0", "~")
|
|
128
|
+
for part in unquote(ref[2:]).split("/")
|
|
129
|
+
]
|
|
130
|
+
for index, part in enumerate(parts):
|
|
131
|
+
if not self._consume():
|
|
132
|
+
self._targets[ref] = None
|
|
133
|
+
break
|
|
134
|
+
if role == "schema" and isinstance(node, dict):
|
|
135
|
+
dialect = node.get("$schema", dialect)
|
|
136
|
+
if any(key in node for key in ("$id", "$dynamicAnchor")):
|
|
137
|
+
self._unsupported_target_bases.add(ref)
|
|
138
|
+
if role in {"map", "array"}:
|
|
139
|
+
next_role = "schema"
|
|
140
|
+
elif role == "schema":
|
|
141
|
+
next_role = (
|
|
142
|
+
"map"
|
|
143
|
+
if part
|
|
144
|
+
in {"properties", "$defs", "definitions", "patternProperties"}
|
|
145
|
+
else "array"
|
|
146
|
+
if part in {"allOf", "oneOf", "anyOf", "prefixItems"}
|
|
147
|
+
else "schema"
|
|
148
|
+
if part
|
|
149
|
+
in {
|
|
150
|
+
"items",
|
|
151
|
+
"additionalProperties",
|
|
152
|
+
"contains",
|
|
153
|
+
"not",
|
|
154
|
+
"if",
|
|
155
|
+
"then",
|
|
156
|
+
"else",
|
|
157
|
+
}
|
|
158
|
+
else "outside"
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
next_role = (
|
|
162
|
+
"map"
|
|
163
|
+
if parts[: index + 1] == ["components", "schemas"]
|
|
164
|
+
else "schema"
|
|
165
|
+
if part == "schema"
|
|
166
|
+
else "outside"
|
|
167
|
+
)
|
|
168
|
+
if isinstance(node, dict):
|
|
169
|
+
node = node.get(part)
|
|
170
|
+
elif (
|
|
171
|
+
isinstance(node, list)
|
|
172
|
+
and part.isdecimal()
|
|
173
|
+
and int(part) < len(node)
|
|
174
|
+
):
|
|
175
|
+
node = node[int(part)]
|
|
176
|
+
else:
|
|
177
|
+
node = None
|
|
178
|
+
role = next_role
|
|
179
|
+
self._target_dialects[ref] = dialect
|
|
180
|
+
return self._targets[ref]
|
|
181
|
+
|
|
182
|
+
def _consume(self) -> bool:
|
|
183
|
+
"""Charge one expansion visit; False once the shared budget is spent."""
|
|
184
|
+
if self._exhausted:
|
|
185
|
+
return False
|
|
186
|
+
try:
|
|
187
|
+
self.budget.consume()
|
|
188
|
+
except AnalysisLimit:
|
|
189
|
+
self._exhausted = True
|
|
190
|
+
self.gaps.add(_EXPANSION_LIMIT_GAP)
|
|
191
|
+
return False
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
def expand(
|
|
195
|
+
self,
|
|
196
|
+
schema: Any,
|
|
197
|
+
depth: int,
|
|
198
|
+
seen: frozenset[str],
|
|
199
|
+
inherited_dialect: Any = _DOCUMENT_DIALECT,
|
|
200
|
+
) -> tuple[dict[str, Any], bool]:
|
|
201
|
+
if not self._consume():
|
|
202
|
+
return {
|
|
203
|
+
"referenceState": "unsupported",
|
|
204
|
+
"omissionCodes": [_EXPANSION_LIMIT_GAP],
|
|
205
|
+
}, False
|
|
206
|
+
if not isinstance(schema, dict):
|
|
207
|
+
self.gaps.add("OPENAPI_SCHEMA_UNREADABLE")
|
|
208
|
+
return {"referenceState": "invalid"}, False
|
|
209
|
+
dialect = schema.get(
|
|
210
|
+
"$schema",
|
|
211
|
+
self.dialect
|
|
212
|
+
if inherited_dialect is _DOCUMENT_DIALECT
|
|
213
|
+
else inherited_dialect,
|
|
214
|
+
)
|
|
215
|
+
if not isinstance(dialect, str) or dialect not in _DIALECTS:
|
|
216
|
+
self.gaps.add("OPENAPI_SCHEMA_DIALECT_UNSUPPORTED")
|
|
217
|
+
return {
|
|
218
|
+
"referenceState": "unsupported",
|
|
219
|
+
"omissionCodes": ["OPENAPI_SCHEMA_DIALECT_UNSUPPORTED"],
|
|
220
|
+
}, False
|
|
221
|
+
if any(key in schema for key in ("$id", "$dynamicRef", "$dynamicAnchor")):
|
|
222
|
+
self.gaps.add("OPENAPI_SCHEMA_BASE_UNSUPPORTED")
|
|
223
|
+
return {
|
|
224
|
+
"referenceState": "unsupported",
|
|
225
|
+
"omissionCodes": ["OPENAPI_SCHEMA_BASE_UNSUPPORTED"],
|
|
226
|
+
}, False
|
|
227
|
+
ref = schema.get("$ref")
|
|
228
|
+
if isinstance(ref, str):
|
|
229
|
+
resolved, complete = self._resolve(ref, depth, seen)
|
|
230
|
+
if self.is_31 and set(schema) - {
|
|
231
|
+
"$ref",
|
|
232
|
+
"description",
|
|
233
|
+
"summary",
|
|
234
|
+
"title",
|
|
235
|
+
"example",
|
|
236
|
+
"examples",
|
|
237
|
+
"default",
|
|
238
|
+
}:
|
|
239
|
+
sibling, sibling_complete = self._project(schema, depth, seen, dialect)
|
|
240
|
+
# Conjunction preserves conflicting constraints without flattening.
|
|
241
|
+
return {"allOf": [resolved, sibling]}, complete and sibling_complete
|
|
242
|
+
return resolved, complete
|
|
243
|
+
return self._project(schema, depth, seen, dialect)
|
|
244
|
+
|
|
245
|
+
def _resolve(
|
|
246
|
+
self, ref: str, depth: int, seen: frozenset[str]
|
|
247
|
+
) -> tuple[dict[str, Any], bool]:
|
|
248
|
+
if not ref.startswith("#"):
|
|
249
|
+
self.gaps.add("REF_EXTERNAL_BLOCKED")
|
|
250
|
+
return {"referenceState": "externalBlocked"}, False
|
|
251
|
+
if len(ref) > 512 or not ref.startswith("#/"):
|
|
252
|
+
self.gaps.add("REF_INVALID")
|
|
253
|
+
return {"referenceState": "invalid"}, False
|
|
254
|
+
if ref in seen:
|
|
255
|
+
self.gaps.add("REF_CYCLE")
|
|
256
|
+
return {"$ref": ref, "referenceState": "cycle"}, False
|
|
257
|
+
if depth >= self.max_depth:
|
|
258
|
+
self.gaps.add("REF_DEPTH_LIMIT")
|
|
259
|
+
self.blocked_name = self.blocked_name or _schema_name(ref)
|
|
260
|
+
target = self.target(ref)
|
|
261
|
+
kind = target.get("type") if isinstance(target, dict) else None
|
|
262
|
+
return {
|
|
263
|
+
"type": kind if isinstance(kind, str) else "object",
|
|
264
|
+
"referenceState": "depthLimit",
|
|
265
|
+
"$ref": ref,
|
|
266
|
+
}, False
|
|
267
|
+
target = self.target(ref)
|
|
268
|
+
if not isinstance(target, dict):
|
|
269
|
+
self.gaps.add("REF_INVALID")
|
|
270
|
+
return {"$ref": ref, "referenceState": "invalid"}, False
|
|
271
|
+
if ref in self._unsupported_target_bases:
|
|
272
|
+
self.gaps.add("OPENAPI_SCHEMA_BASE_UNSUPPORTED")
|
|
273
|
+
return {
|
|
274
|
+
"referenceState": "unsupported",
|
|
275
|
+
"omissionCodes": ["OPENAPI_SCHEMA_BASE_UNSUPPORTED"],
|
|
276
|
+
}, False
|
|
277
|
+
projected, complete = self.expand(
|
|
278
|
+
target, depth + 1, seen | {ref}, self._target_dialects[ref]
|
|
279
|
+
)
|
|
280
|
+
projected["$ref"] = ref
|
|
281
|
+
projected["referenceState"] = "resolved"
|
|
282
|
+
return projected, complete
|
|
283
|
+
|
|
284
|
+
def _project(
|
|
285
|
+
self, schema: dict[str, Any], depth: int, seen: frozenset[str], dialect: str
|
|
286
|
+
) -> tuple[dict[str, Any], bool]:
|
|
287
|
+
projected: dict[str, Any] = {}
|
|
288
|
+
complete = True
|
|
289
|
+
supported = _SCHEMA_KEYS | {
|
|
290
|
+
"$ref",
|
|
291
|
+
"properties",
|
|
292
|
+
"items",
|
|
293
|
+
"additionalProperties",
|
|
294
|
+
"allOf",
|
|
295
|
+
"oneOf",
|
|
296
|
+
"anyOf",
|
|
297
|
+
}
|
|
298
|
+
omitted = {
|
|
299
|
+
"description",
|
|
300
|
+
"summary",
|
|
301
|
+
"title",
|
|
302
|
+
"example",
|
|
303
|
+
"examples",
|
|
304
|
+
"default",
|
|
305
|
+
"$defs",
|
|
306
|
+
"$schema",
|
|
307
|
+
}
|
|
308
|
+
if any(
|
|
309
|
+
key not in supported | omitted and not key.startswith("x-")
|
|
310
|
+
for key in schema
|
|
311
|
+
):
|
|
312
|
+
self.gaps.add("OPENAPI_SCHEMA_UNSUPPORTED")
|
|
313
|
+
complete = False
|
|
314
|
+
for key in _SCHEMA_KEYS:
|
|
315
|
+
if key not in schema:
|
|
316
|
+
continue
|
|
317
|
+
value = schema[key]
|
|
318
|
+
# Never borrow a mutable node from the source. A cached $ref target
|
|
319
|
+
# can be expanded along several paths in the same output tree.
|
|
320
|
+
if isinstance(value, list):
|
|
321
|
+
limit = _METADATA_ARRAY_LIMITS.get(key, 0)
|
|
322
|
+
if not limit or len(value) > limit:
|
|
323
|
+
complete = False
|
|
324
|
+
continue
|
|
325
|
+
copied: list[Any] = []
|
|
326
|
+
valid = True
|
|
327
|
+
for member in value:
|
|
328
|
+
if not self._consume():
|
|
329
|
+
return {
|
|
330
|
+
"referenceState": "unsupported",
|
|
331
|
+
"omissionCodes": [_EXPANSION_LIMIT_GAP],
|
|
332
|
+
}, False
|
|
333
|
+
if (
|
|
334
|
+
not isinstance(member, (str, int, float, bool, type(None)))
|
|
335
|
+
or (isinstance(member, str) and len(member) > 512)
|
|
336
|
+
or (
|
|
337
|
+
key == "required"
|
|
338
|
+
and (not isinstance(member, str) or len(member) > 128)
|
|
339
|
+
)
|
|
340
|
+
or (
|
|
341
|
+
key == "type"
|
|
342
|
+
and (
|
|
343
|
+
not isinstance(member, str)
|
|
344
|
+
or member not in _SCHEMA_TYPES
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
):
|
|
348
|
+
valid = False
|
|
349
|
+
break
|
|
350
|
+
copied.append(member)
|
|
351
|
+
if valid:
|
|
352
|
+
projected[key] = copied
|
|
353
|
+
else:
|
|
354
|
+
complete = False
|
|
355
|
+
continue
|
|
356
|
+
if isinstance(value, dict) or key in {"required", "enum"}:
|
|
357
|
+
complete = False
|
|
358
|
+
continue
|
|
359
|
+
if isinstance(value, str) and any(char.isspace() for char in value):
|
|
360
|
+
complete = False
|
|
361
|
+
continue
|
|
362
|
+
projected[key] = value
|
|
363
|
+
properties = schema.get("properties")
|
|
364
|
+
if isinstance(properties, dict):
|
|
365
|
+
kept: dict[str, Any] = {}
|
|
366
|
+
for name in sorted(properties):
|
|
367
|
+
if (
|
|
368
|
+
not isinstance(name, str)
|
|
369
|
+
or len(name) > 128
|
|
370
|
+
or any(char.isspace() for char in name)
|
|
371
|
+
):
|
|
372
|
+
complete = False
|
|
373
|
+
continue
|
|
374
|
+
child, child_complete = self.expand(
|
|
375
|
+
properties[name], depth, seen, dialect
|
|
376
|
+
)
|
|
377
|
+
if self._exhausted:
|
|
378
|
+
return {
|
|
379
|
+
"referenceState": "unsupported",
|
|
380
|
+
"omissionCodes": [_EXPANSION_LIMIT_GAP],
|
|
381
|
+
}, False
|
|
382
|
+
kept[name] = child
|
|
383
|
+
complete = complete and child_complete
|
|
384
|
+
if len(kept) >= 50 and len(properties) > 50:
|
|
385
|
+
self.gaps.add("OPENAPI_PROPERTY_LIMIT")
|
|
386
|
+
complete = False
|
|
387
|
+
break
|
|
388
|
+
projected["properties"] = kept
|
|
389
|
+
if "items" in schema:
|
|
390
|
+
child, child_complete = self.expand(schema["items"], depth, seen, dialect)
|
|
391
|
+
if self._exhausted:
|
|
392
|
+
return {
|
|
393
|
+
"referenceState": "unsupported",
|
|
394
|
+
"omissionCodes": [_EXPANSION_LIMIT_GAP],
|
|
395
|
+
}, False
|
|
396
|
+
projected["items"] = child
|
|
397
|
+
complete = complete and child_complete
|
|
398
|
+
if "additionalProperties" in schema:
|
|
399
|
+
additional = schema["additionalProperties"]
|
|
400
|
+
if isinstance(additional, bool):
|
|
401
|
+
projected["additionalProperties"] = additional
|
|
402
|
+
else:
|
|
403
|
+
child, child_complete = self.expand(additional, depth, seen, dialect)
|
|
404
|
+
projected["additionalProperties"] = child
|
|
405
|
+
complete = complete and child_complete
|
|
406
|
+
for key in ("allOf", "oneOf", "anyOf"):
|
|
407
|
+
if key not in schema:
|
|
408
|
+
continue
|
|
409
|
+
raw = schema[key]
|
|
410
|
+
if not isinstance(raw, list) or not raw:
|
|
411
|
+
self.gaps.add("OPENAPI_SCHEMA_UNREADABLE")
|
|
412
|
+
complete = False
|
|
413
|
+
continue
|
|
414
|
+
children = []
|
|
415
|
+
for part in raw[:16]:
|
|
416
|
+
child, child_complete = self.expand(part, depth, seen, dialect)
|
|
417
|
+
children.append(child)
|
|
418
|
+
complete = complete and child_complete
|
|
419
|
+
if self._exhausted:
|
|
420
|
+
return {
|
|
421
|
+
"referenceState": "unsupported",
|
|
422
|
+
"omissionCodes": [_EXPANSION_LIMIT_GAP],
|
|
423
|
+
}, False
|
|
424
|
+
projected[key] = children
|
|
425
|
+
if len(raw) > 16:
|
|
426
|
+
self.gaps.add("OPENAPI_COMPOSITION_LIMIT")
|
|
427
|
+
complete = False
|
|
428
|
+
if not complete:
|
|
429
|
+
self.gaps.add("OPENAPI_SCHEMA_PARTIAL")
|
|
430
|
+
return projected, complete
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _path_item(value: Any, expander: _Expander) -> dict[str, Any]:
|
|
434
|
+
"""Keep disjoint PathItem fields; conflicting fields have no defined winner."""
|
|
435
|
+
fields: dict[str, Any] = {}
|
|
436
|
+
conflicts: set[str] = set()
|
|
437
|
+
seen: set[str] = set()
|
|
438
|
+
while True:
|
|
439
|
+
if not isinstance(value, dict):
|
|
440
|
+
expander.gaps.add("OPENAPI_PATH_UNREADABLE")
|
|
441
|
+
break
|
|
442
|
+
for key, child in value.items():
|
|
443
|
+
if not expander._consume():
|
|
444
|
+
expander.gaps.add("OPENAPI_PATH_UNREADABLE")
|
|
445
|
+
return fields
|
|
446
|
+
if key == "$ref":
|
|
447
|
+
continue
|
|
448
|
+
if key in fields or key in conflicts:
|
|
449
|
+
fields.pop(key, None)
|
|
450
|
+
conflicts.add(key)
|
|
451
|
+
expander.gaps.add("OPENAPI_PATH_FIELD_CONFLICT")
|
|
452
|
+
else:
|
|
453
|
+
fields[key] = child
|
|
454
|
+
if "$ref" not in value:
|
|
455
|
+
break
|
|
456
|
+
ref = value["$ref"]
|
|
457
|
+
if not isinstance(ref, str) or not ref.startswith("#/") or len(ref) > 512:
|
|
458
|
+
expander.gaps.update(
|
|
459
|
+
{
|
|
460
|
+
"REF_EXTERNAL_BLOCKED"
|
|
461
|
+
if isinstance(ref, str) and not ref.startswith("#")
|
|
462
|
+
else "REF_INVALID",
|
|
463
|
+
"OPENAPI_PATH_UNREADABLE",
|
|
464
|
+
}
|
|
465
|
+
)
|
|
466
|
+
break
|
|
467
|
+
if ref in seen or len(seen) >= 8 or not expander._consume():
|
|
468
|
+
expander.gaps.update(
|
|
469
|
+
{
|
|
470
|
+
"REF_CYCLE"
|
|
471
|
+
if ref in seen
|
|
472
|
+
else _EXPANSION_LIMIT_GAP
|
|
473
|
+
if expander._exhausted
|
|
474
|
+
else "REF_DEPTH_LIMIT",
|
|
475
|
+
"OPENAPI_PATH_UNREADABLE",
|
|
476
|
+
}
|
|
477
|
+
)
|
|
478
|
+
break
|
|
479
|
+
seen.add(ref)
|
|
480
|
+
value = expander.target(ref)
|
|
481
|
+
return fields
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _object(value: Any, expander: _Expander) -> dict[str, Any] | None:
|
|
485
|
+
"""Resolve bounded local Parameter/RequestBody references, never URLs."""
|
|
486
|
+
seen: set[str] = set()
|
|
487
|
+
while isinstance(value, dict) and "$ref" in value:
|
|
488
|
+
ref = value["$ref"]
|
|
489
|
+
if not isinstance(ref, str) or not ref.startswith("#/"):
|
|
490
|
+
expander.gaps.add("REF_EXTERNAL_BLOCKED")
|
|
491
|
+
return None
|
|
492
|
+
if ref in seen:
|
|
493
|
+
expander.gaps.add("REF_CYCLE")
|
|
494
|
+
return None
|
|
495
|
+
if len(seen) >= 8 or not expander._consume():
|
|
496
|
+
expander.gaps.add(
|
|
497
|
+
"REF_DEPTH_LIMIT" if not expander._exhausted else _EXPANSION_LIMIT_GAP
|
|
498
|
+
)
|
|
499
|
+
return None
|
|
500
|
+
seen.add(ref)
|
|
501
|
+
value = expander.target(ref)
|
|
502
|
+
if not isinstance(value, dict):
|
|
503
|
+
expander.gaps.add("OPENAPI_OBJECT_UNREADABLE")
|
|
504
|
+
return None
|
|
505
|
+
return value
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _parameters(
|
|
509
|
+
operation: dict[str, Any], expander: _Expander, inherited: Any
|
|
510
|
+
) -> list[dict[str, Any]]:
|
|
511
|
+
rows: list[dict[str, Any]] = []
|
|
512
|
+
raw = operation.get("parameters", [])
|
|
513
|
+
if not isinstance(raw, list) or not isinstance(inherited, list):
|
|
514
|
+
expander.gaps.add("OPENAPI_PARAMETERS_UNREADABLE")
|
|
515
|
+
return rows
|
|
516
|
+
selected: dict[tuple[str, str], dict[str, Any]] = {}
|
|
517
|
+
for value in [*inherited, *raw]:
|
|
518
|
+
item = _object(value, expander)
|
|
519
|
+
if not isinstance(item, dict) or not isinstance(item.get("name"), str):
|
|
520
|
+
expander.gaps.add("OPENAPI_PARAMETERS_UNREADABLE")
|
|
521
|
+
continue
|
|
522
|
+
location = item.get("in")
|
|
523
|
+
if location not in {"path", "query", "header", "cookie"}:
|
|
524
|
+
expander.gaps.add("OPENAPI_PARAMETERS_UNREADABLE")
|
|
525
|
+
continue
|
|
526
|
+
selected[(location, item["name"])] = item
|
|
527
|
+
if len(selected) > 16:
|
|
528
|
+
expander.gaps.add("OPENAPI_PARAMETER_LIMIT")
|
|
529
|
+
for (location, name), item in sorted(selected.items())[:16]:
|
|
530
|
+
schema, _ = expander.expand(item.get("schema"), 0, frozenset())
|
|
531
|
+
rows.append(
|
|
532
|
+
{
|
|
533
|
+
"name": name[:128],
|
|
534
|
+
"in": location,
|
|
535
|
+
"required": item.get("required") is True,
|
|
536
|
+
"schema": schema,
|
|
537
|
+
}
|
|
538
|
+
)
|
|
539
|
+
return rows
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _request_body(
|
|
543
|
+
operation: dict[str, Any], expander: _Expander
|
|
544
|
+
) -> list[dict[str, Any]]:
|
|
545
|
+
if "requestBody" not in operation:
|
|
546
|
+
return []
|
|
547
|
+
body = _object(operation["requestBody"], expander)
|
|
548
|
+
content = body.get("content") if body is not None else None
|
|
549
|
+
if not isinstance(content, dict) or not content:
|
|
550
|
+
expander.gaps.add("OPENAPI_REQUEST_BODY_UNREADABLE")
|
|
551
|
+
return []
|
|
552
|
+
if len(content) > 8:
|
|
553
|
+
expander.gaps.add("OPENAPI_MEDIA_LIMIT")
|
|
554
|
+
rows = []
|
|
555
|
+
for media in sorted(content)[:8]:
|
|
556
|
+
entry = content[media]
|
|
557
|
+
schema, _ = expander.expand(
|
|
558
|
+
entry.get("schema") if isinstance(entry, dict) else None, 0, frozenset()
|
|
559
|
+
)
|
|
560
|
+
rows.append({"mediaType": media[:128], "schema": schema})
|
|
561
|
+
return rows
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def normalize_openapi(
|
|
565
|
+
body: Any, query: OpenApiQuery
|
|
566
|
+
) -> tuple[str | None, str, list[dict[str, Any]], list[str], str | None]:
|
|
567
|
+
if not isinstance(body, dict) or not isinstance(body.get("paths"), dict):
|
|
568
|
+
raise ValueError("INVALID_OPENAPI_SHAPE")
|
|
569
|
+
version = (
|
|
570
|
+
body.get("openapi")
|
|
571
|
+
if isinstance(body.get("openapi"), str)
|
|
572
|
+
else body.get("swagger")
|
|
573
|
+
)
|
|
574
|
+
version = version if isinstance(version, str) else None
|
|
575
|
+
expander = _Expander(body, query.max_ref_depth)
|
|
576
|
+
items: list[dict[str, Any]] = []
|
|
577
|
+
if query.schema_name:
|
|
578
|
+
schemas = (
|
|
579
|
+
body.get("components", {}).get("schemas", {})
|
|
580
|
+
if isinstance(body.get("components"), dict)
|
|
581
|
+
else {}
|
|
582
|
+
)
|
|
583
|
+
schema = schemas.get(query.schema_name) if isinstance(schemas, dict) else None
|
|
584
|
+
if isinstance(schema, dict):
|
|
585
|
+
projected, complete = expander.expand(schema, 0, frozenset())
|
|
586
|
+
items.append(
|
|
587
|
+
{
|
|
588
|
+
"id": "openapi_000001",
|
|
589
|
+
"kind": "schema",
|
|
590
|
+
"path": None,
|
|
591
|
+
"httpMethod": None,
|
|
592
|
+
"operationId": None,
|
|
593
|
+
"schemaName": query.schema_name,
|
|
594
|
+
"parameters": [],
|
|
595
|
+
"requestBody": [],
|
|
596
|
+
"responses": [],
|
|
597
|
+
"schema": projected,
|
|
598
|
+
"expansionComplete": complete,
|
|
599
|
+
}
|
|
600
|
+
)
|
|
601
|
+
return version, "default", items, sorted(expander.gaps), expander.blocked_name
|
|
602
|
+
paths = body["paths"]
|
|
603
|
+
for path in sorted(paths):
|
|
604
|
+
if not isinstance(path, str) or not path.startswith("/"):
|
|
605
|
+
expander.gaps.add("OPENAPI_PATH_UNREADABLE")
|
|
606
|
+
continue
|
|
607
|
+
if query.path_pattern and not glob_matches(query.path_pattern, path):
|
|
608
|
+
continue
|
|
609
|
+
operations = _path_item(paths[path], expander)
|
|
610
|
+
for method in sorted(operations):
|
|
611
|
+
if method not in _METHODS:
|
|
612
|
+
continue
|
|
613
|
+
http_method = method.upper()
|
|
614
|
+
if query.http_method and http_method != query.http_method:
|
|
615
|
+
continue
|
|
616
|
+
operation = operations[method]
|
|
617
|
+
if not isinstance(operation, dict):
|
|
618
|
+
expander.gaps.add("OPENAPI_OPERATION_UNREADABLE")
|
|
619
|
+
continue
|
|
620
|
+
operation_id = operation.get("operationId")
|
|
621
|
+
if query.operation_id and operation_id != query.operation_id:
|
|
622
|
+
continue
|
|
623
|
+
responses: list[dict[str, Any]] = []
|
|
624
|
+
complete = True
|
|
625
|
+
raw_responses = operation.get("responses")
|
|
626
|
+
if isinstance(raw_responses, dict):
|
|
627
|
+
for status in sorted(raw_responses):
|
|
628
|
+
if len(responses) >= 16:
|
|
629
|
+
complete = False
|
|
630
|
+
break
|
|
631
|
+
entry = _object(raw_responses[status], expander)
|
|
632
|
+
if entry is None:
|
|
633
|
+
complete = False
|
|
634
|
+
content = entry.get("content") if isinstance(entry, dict) else None
|
|
635
|
+
if not isinstance(content, dict) or not content:
|
|
636
|
+
responses.append(
|
|
637
|
+
{
|
|
638
|
+
"status": str(status)[:16],
|
|
639
|
+
"mediaType": None,
|
|
640
|
+
"schema": None,
|
|
641
|
+
}
|
|
642
|
+
)
|
|
643
|
+
continue
|
|
644
|
+
media = min(content)
|
|
645
|
+
schema = (
|
|
646
|
+
content[media].get("schema")
|
|
647
|
+
if isinstance(content[media], dict)
|
|
648
|
+
else None
|
|
649
|
+
)
|
|
650
|
+
projected, schema_complete = expander.expand(schema, 0, frozenset())
|
|
651
|
+
complete = complete and schema_complete
|
|
652
|
+
responses.append(
|
|
653
|
+
{
|
|
654
|
+
"status": str(status)[:16],
|
|
655
|
+
"mediaType": media if isinstance(media, str) else None,
|
|
656
|
+
"schema": projected,
|
|
657
|
+
}
|
|
658
|
+
)
|
|
659
|
+
else:
|
|
660
|
+
complete = False
|
|
661
|
+
parameters = _parameters(
|
|
662
|
+
operation, expander, operations.get("parameters", [])
|
|
663
|
+
)
|
|
664
|
+
request_body = _request_body(operation, expander)
|
|
665
|
+
items.append(
|
|
666
|
+
{
|
|
667
|
+
"kind": "operation",
|
|
668
|
+
"path": path[:1024],
|
|
669
|
+
"httpMethod": http_method,
|
|
670
|
+
"operationId": operation_id
|
|
671
|
+
if isinstance(operation_id, str)
|
|
672
|
+
else None,
|
|
673
|
+
"schemaName": None,
|
|
674
|
+
"parameters": parameters,
|
|
675
|
+
"requestBody": request_body,
|
|
676
|
+
"responses": responses,
|
|
677
|
+
"schema": None,
|
|
678
|
+
"expansionComplete": complete and not expander.gaps,
|
|
679
|
+
}
|
|
680
|
+
)
|
|
681
|
+
for index, item in enumerate(items, start=1):
|
|
682
|
+
item["id"] = f"openapi_{index:06d}"
|
|
683
|
+
return version, "default", items, sorted(expander.gaps), expander.blocked_name
|