schemathesis 4.2.2__py3-none-any.whl → 4.3.1__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.
- schemathesis/config/__init__.py +8 -1
- schemathesis/config/_phases.py +14 -3
- schemathesis/config/schema.json +2 -1
- schemathesis/core/jsonschema/bundler.py +3 -2
- schemathesis/core/transforms.py +14 -6
- schemathesis/engine/context.py +35 -2
- schemathesis/generation/hypothesis/__init__.py +3 -1
- schemathesis/generation/hypothesis/builder.py +10 -2
- schemathesis/openapi/checks.py +13 -1
- schemathesis/specs/openapi/adapter/parameters.py +3 -3
- schemathesis/specs/openapi/adapter/protocol.py +2 -0
- schemathesis/specs/openapi/adapter/responses.py +29 -7
- schemathesis/specs/openapi/adapter/v2.py +2 -0
- schemathesis/specs/openapi/adapter/v3_0.py +2 -0
- schemathesis/specs/openapi/adapter/v3_1.py +2 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +88 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +182 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +270 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +345 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +282 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +420 -0
- schemathesis/specs/openapi/stateful/inference.py +2 -1
- {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/METADATA +1 -1
- {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/RECORD +28 -21
- {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/WHEEL +0 -0
- {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,345 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
|
4
|
+
def from_parameter(parameter: str, path: str) -> str | None:
|
5
|
+
# TODO: support other naming patterns
|
6
|
+
# Named like "userId" -> look for "User" resource
|
7
|
+
if parameter.endswith("Id"):
|
8
|
+
return to_pascal_case(parameter[:-2])
|
9
|
+
# Named like "user_id" -> look for "User" resource
|
10
|
+
elif parameter.endswith("_id"):
|
11
|
+
return to_pascal_case(parameter[:-3])
|
12
|
+
# Just "id" -> infer from path context
|
13
|
+
elif parameter == "id":
|
14
|
+
return from_path(path)
|
15
|
+
return None
|
16
|
+
|
17
|
+
|
18
|
+
def from_path(path: str) -> str | None:
|
19
|
+
segments = [s for s in path.split("/") if s and "{" not in s]
|
20
|
+
|
21
|
+
if not segments:
|
22
|
+
# API Root
|
23
|
+
return None
|
24
|
+
|
25
|
+
singular = to_singular(segments[-1])
|
26
|
+
return to_pascal_case(singular)
|
27
|
+
|
28
|
+
|
29
|
+
IRREGULAR_TO_PLURAL = {
|
30
|
+
"echo": "echoes",
|
31
|
+
"dingo": "dingoes",
|
32
|
+
"volcano": "volcanoes",
|
33
|
+
"tornado": "tornadoes",
|
34
|
+
"torpedo": "torpedoes",
|
35
|
+
"genus": "genera",
|
36
|
+
"viscus": "viscera",
|
37
|
+
"stigma": "stigmata",
|
38
|
+
"stoma": "stomata",
|
39
|
+
"dogma": "dogmata",
|
40
|
+
"lemma": "lemmata",
|
41
|
+
"anathema": "anathemata",
|
42
|
+
"ox": "oxen",
|
43
|
+
"axe": "axes",
|
44
|
+
"die": "dice",
|
45
|
+
"yes": "yeses",
|
46
|
+
"foot": "feet",
|
47
|
+
"eave": "eaves",
|
48
|
+
"goose": "geese",
|
49
|
+
"tooth": "teeth",
|
50
|
+
"quiz": "quizzes",
|
51
|
+
"human": "humans",
|
52
|
+
"proof": "proofs",
|
53
|
+
"carve": "carves",
|
54
|
+
"valve": "valves",
|
55
|
+
"looey": "looies",
|
56
|
+
"thief": "thieves",
|
57
|
+
"groove": "grooves",
|
58
|
+
"pickaxe": "pickaxes",
|
59
|
+
"passerby": "passersby",
|
60
|
+
"canvas": "canvases",
|
61
|
+
"use": "uses",
|
62
|
+
"case": "cases",
|
63
|
+
"vase": "vases",
|
64
|
+
"house": "houses",
|
65
|
+
"mouse": "mice",
|
66
|
+
"reuse": "reuses",
|
67
|
+
"abuse": "abuses",
|
68
|
+
"excuse": "excuses",
|
69
|
+
"cause": "causes",
|
70
|
+
"pause": "pauses",
|
71
|
+
"base": "bases",
|
72
|
+
"phase": "phases",
|
73
|
+
"rose": "roses",
|
74
|
+
"dose": "doses",
|
75
|
+
"nose": "noses",
|
76
|
+
"horse": "horses",
|
77
|
+
"course": "courses",
|
78
|
+
"purpose": "purposes",
|
79
|
+
"response": "responses",
|
80
|
+
"sense": "senses",
|
81
|
+
"tense": "tenses",
|
82
|
+
"expense": "expenses",
|
83
|
+
"license": "licenses",
|
84
|
+
"defense": "defenses",
|
85
|
+
}
|
86
|
+
IRREGULAR_TO_SINGULAR = {v: k for k, v in IRREGULAR_TO_PLURAL.items()}
|
87
|
+
UNCOUNTABLE = frozenset(
|
88
|
+
[
|
89
|
+
"adulthood",
|
90
|
+
"advice",
|
91
|
+
"agenda",
|
92
|
+
"aid",
|
93
|
+
"aircraft",
|
94
|
+
"alcohol",
|
95
|
+
"ammo",
|
96
|
+
"analytics",
|
97
|
+
"anime",
|
98
|
+
"athletics",
|
99
|
+
"audio",
|
100
|
+
"bison",
|
101
|
+
"blood",
|
102
|
+
"bream",
|
103
|
+
"buffalo",
|
104
|
+
"butter",
|
105
|
+
"carp",
|
106
|
+
"cash",
|
107
|
+
"chassis",
|
108
|
+
"chess",
|
109
|
+
"clothing",
|
110
|
+
"cod",
|
111
|
+
"commerce",
|
112
|
+
"cooperation",
|
113
|
+
"corps",
|
114
|
+
"debris",
|
115
|
+
"diabetes",
|
116
|
+
"digestion",
|
117
|
+
"elk",
|
118
|
+
"energy",
|
119
|
+
"equipment",
|
120
|
+
"excretion",
|
121
|
+
"expertise",
|
122
|
+
"firmware",
|
123
|
+
"flounder",
|
124
|
+
"fun",
|
125
|
+
"gallows",
|
126
|
+
"garbage",
|
127
|
+
"graffiti",
|
128
|
+
"hardware",
|
129
|
+
"headquarters",
|
130
|
+
"health",
|
131
|
+
"herpes",
|
132
|
+
"highjinks",
|
133
|
+
"homework",
|
134
|
+
"housework",
|
135
|
+
"information",
|
136
|
+
"jeans",
|
137
|
+
"justice",
|
138
|
+
"kudos",
|
139
|
+
"labour",
|
140
|
+
"literature",
|
141
|
+
"machinery",
|
142
|
+
"mackerel",
|
143
|
+
"mail",
|
144
|
+
"media",
|
145
|
+
"mews",
|
146
|
+
"moose",
|
147
|
+
"music",
|
148
|
+
"mud",
|
149
|
+
"manga",
|
150
|
+
"news",
|
151
|
+
"only",
|
152
|
+
"personnel",
|
153
|
+
"pike",
|
154
|
+
"plankton",
|
155
|
+
"pliers",
|
156
|
+
"police",
|
157
|
+
"pollution",
|
158
|
+
"premises",
|
159
|
+
"rain",
|
160
|
+
"research",
|
161
|
+
"rice",
|
162
|
+
"salmon",
|
163
|
+
"scissors",
|
164
|
+
"series",
|
165
|
+
"sewage",
|
166
|
+
"shambles",
|
167
|
+
"shrimp",
|
168
|
+
"software",
|
169
|
+
"staff",
|
170
|
+
"swine",
|
171
|
+
"tennis",
|
172
|
+
"traffic",
|
173
|
+
"transportation",
|
174
|
+
"trout",
|
175
|
+
"tuna",
|
176
|
+
"wealth",
|
177
|
+
"welfare",
|
178
|
+
"whiting",
|
179
|
+
"wildebeest",
|
180
|
+
"wildlife",
|
181
|
+
"you",
|
182
|
+
"sheep",
|
183
|
+
"deer",
|
184
|
+
"species",
|
185
|
+
"series",
|
186
|
+
"means",
|
187
|
+
]
|
188
|
+
)
|
189
|
+
|
190
|
+
|
191
|
+
def to_singular(word: str) -> str:
|
192
|
+
if word in UNCOUNTABLE:
|
193
|
+
return word
|
194
|
+
known = IRREGULAR_TO_SINGULAR.get(word)
|
195
|
+
if known is not None:
|
196
|
+
return known
|
197
|
+
if word.endswith("ies") and len(word) > 3 and word[-4] not in "aeiou":
|
198
|
+
return word[:-3] + "y"
|
199
|
+
if word.endswith("sses"):
|
200
|
+
return word[:-2]
|
201
|
+
if word.endswith(("xes", "zes", "ches", "shes")):
|
202
|
+
return word[:-2]
|
203
|
+
# Handle "ses" ending: check if it was "se" + "s" or "s" + "es"
|
204
|
+
if word.endswith("ses") and len(word) > 3:
|
205
|
+
# "gases" has 's' at position -3, formed from "gas" + "es"
|
206
|
+
# "statuses" has 's' at position -3, formed from "status" + "es"
|
207
|
+
return word[:-2]
|
208
|
+
if word.endswith("s"):
|
209
|
+
return word[:-1]
|
210
|
+
return word
|
211
|
+
|
212
|
+
|
213
|
+
def to_plural(word: str) -> str:
|
214
|
+
if word in UNCOUNTABLE:
|
215
|
+
return word
|
216
|
+
known = IRREGULAR_TO_PLURAL.get(word)
|
217
|
+
if known is not None:
|
218
|
+
return known
|
219
|
+
# Only change y -> ies after consonants (party -> parties, not day -> days)
|
220
|
+
if word.endswith("y") and len(word) > 1 and word[-2] not in "aeiou":
|
221
|
+
return word[:-1] + "ies"
|
222
|
+
# class -> classes
|
223
|
+
if word.endswith("ss"):
|
224
|
+
return word + "es"
|
225
|
+
# words that normally take -es: box -> boxes
|
226
|
+
if word.endswith(("s", "x", "z", "ch", "sh")):
|
227
|
+
return word + "es"
|
228
|
+
# just add 's' (car -> cars)
|
229
|
+
return word + "s"
|
230
|
+
|
231
|
+
|
232
|
+
def to_pascal_case(text: str) -> str:
|
233
|
+
parts = text.replace("-", "_").split("_")
|
234
|
+
return "".join(word.capitalize() for word in parts if word)
|
235
|
+
|
236
|
+
|
237
|
+
def to_snake_case(text: str) -> str:
|
238
|
+
text = text.replace("-", "_")
|
239
|
+
# Insert underscores before uppercase letters
|
240
|
+
result = []
|
241
|
+
for i, char in enumerate(text):
|
242
|
+
# Add underscore before uppercase (except at start)
|
243
|
+
if i > 0 and char.isupper():
|
244
|
+
result.append("_")
|
245
|
+
result.append(char.lower())
|
246
|
+
return "".join(result)
|
247
|
+
|
248
|
+
|
249
|
+
def find_matching_field(*, parameter: str, resource: str, fields: list[str]) -> str | None:
|
250
|
+
"""Find which resource field matches the parameter name."""
|
251
|
+
if not fields:
|
252
|
+
return None
|
253
|
+
|
254
|
+
# Exact match
|
255
|
+
if parameter in fields:
|
256
|
+
return parameter
|
257
|
+
|
258
|
+
# Normalize for fuzzy matching
|
259
|
+
parameter_normalized = _normalize_for_matching(parameter)
|
260
|
+
resource_normalized = _normalize_for_matching(resource)
|
261
|
+
|
262
|
+
# Normalized exact match
|
263
|
+
# `brandId` -> `Brand.BrandId`
|
264
|
+
for field in fields:
|
265
|
+
if _normalize_for_matching(field) == parameter_normalized:
|
266
|
+
return field
|
267
|
+
|
268
|
+
# Extract parameter components
|
269
|
+
parameter_prefix, param_suffix = _split_parameter_name(parameter)
|
270
|
+
parameter_prefix_normalized = _normalize_for_matching(parameter_prefix)
|
271
|
+
|
272
|
+
# Parameter has resource prefix, field might not
|
273
|
+
# Example: `channelId` - `Channel.id`
|
274
|
+
if parameter_prefix and parameter_prefix_normalized == resource_normalized:
|
275
|
+
suffix_normalized = _normalize_for_matching(param_suffix)
|
276
|
+
|
277
|
+
for field in fields:
|
278
|
+
field_normalized = _normalize_for_matching(field)
|
279
|
+
if field_normalized == suffix_normalized:
|
280
|
+
return field
|
281
|
+
|
282
|
+
# Parameter has no prefix, field might have resource prefix
|
283
|
+
# Example: `id` - `Channel.channelId`
|
284
|
+
if not parameter_prefix and param_suffix:
|
285
|
+
expected_field_normalized = resource_normalized + _normalize_for_matching(param_suffix)
|
286
|
+
|
287
|
+
for field in fields:
|
288
|
+
field_normalized = _normalize_for_matching(field)
|
289
|
+
if field_normalized == expected_field_normalized:
|
290
|
+
return field
|
291
|
+
|
292
|
+
return None
|
293
|
+
|
294
|
+
|
295
|
+
def _normalize_for_matching(text: str) -> str:
|
296
|
+
"""Normalize text for case-insensitive, separator-insensitive matching.
|
297
|
+
|
298
|
+
Examples:
|
299
|
+
"channelId" -> "channelid"
|
300
|
+
"channel_id" -> "channelid"
|
301
|
+
"ChannelId" -> "channelid"
|
302
|
+
"Channel" -> "channel"
|
303
|
+
|
304
|
+
"""
|
305
|
+
return text.lower().replace("_", "").replace("-", "")
|
306
|
+
|
307
|
+
|
308
|
+
def _split_parameter_name(param_name: str) -> tuple[str, str]:
|
309
|
+
"""Split parameter into (prefix, suffix) components.
|
310
|
+
|
311
|
+
Examples:
|
312
|
+
"channelId" -> ("channel", "Id")
|
313
|
+
"userId" -> ("user", "Id")
|
314
|
+
"user_id" -> ("user", "_id")
|
315
|
+
"id" -> ("", "id")
|
316
|
+
"channel_id" -> ("channel", "_id")
|
317
|
+
|
318
|
+
"""
|
319
|
+
if param_name.endswith("Id") and len(param_name) > 2:
|
320
|
+
return (param_name[:-2], "Id")
|
321
|
+
|
322
|
+
if param_name.endswith("_id") and len(param_name) > 3:
|
323
|
+
return (param_name[:-3], "_id")
|
324
|
+
|
325
|
+
return ("", param_name)
|
326
|
+
|
327
|
+
|
328
|
+
def strip_affixes(name: str, prefixes: list[str], suffixes: list[str]) -> str:
|
329
|
+
"""Remove common prefixes and suffixes from a name (case-insensitive)."""
|
330
|
+
result = name.strip()
|
331
|
+
name_lower = result.lower()
|
332
|
+
|
333
|
+
# Remove one matching prefix
|
334
|
+
for prefix in prefixes:
|
335
|
+
if name_lower.startswith(prefix):
|
336
|
+
result = result[len(prefix) :]
|
337
|
+
break
|
338
|
+
|
339
|
+
# Remove one matching suffix
|
340
|
+
for suffix in suffixes:
|
341
|
+
if name_lower.endswith(suffix):
|
342
|
+
result = result[: -len(suffix)]
|
343
|
+
break
|
344
|
+
|
345
|
+
return result.strip()
|
@@ -0,0 +1,34 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Iterator
|
4
|
+
|
5
|
+
from schemathesis.specs.openapi.stateful.dependencies.models import CanonicalizationCache, OutputSlot, ResourceMap
|
6
|
+
from schemathesis.specs.openapi.stateful.dependencies.resources import extract_resources_from_responses
|
7
|
+
|
8
|
+
if TYPE_CHECKING:
|
9
|
+
from schemathesis.core.compat import RefResolver
|
10
|
+
from schemathesis.specs.openapi.schemas import APIOperation
|
11
|
+
|
12
|
+
|
13
|
+
def extract_outputs(
|
14
|
+
*,
|
15
|
+
operation: APIOperation,
|
16
|
+
resources: ResourceMap,
|
17
|
+
updated_resources: set[str],
|
18
|
+
resolver: RefResolver,
|
19
|
+
canonicalization_cache: CanonicalizationCache,
|
20
|
+
) -> Iterator[OutputSlot]:
|
21
|
+
"""Extract resources from API operation's responses."""
|
22
|
+
for response, extracted in extract_resources_from_responses(
|
23
|
+
operation=operation,
|
24
|
+
resources=resources,
|
25
|
+
updated_resources=updated_resources,
|
26
|
+
resolver=resolver,
|
27
|
+
canonicalization_cache=canonicalization_cache,
|
28
|
+
):
|
29
|
+
yield OutputSlot(
|
30
|
+
resource=extracted.resource,
|
31
|
+
pointer=extracted.pointer,
|
32
|
+
cardinality=extracted.cardinality,
|
33
|
+
status_code=response.status_code,
|
34
|
+
)
|
@@ -0,0 +1,282 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from typing import TYPE_CHECKING, Any, Iterator, Mapping, cast
|
5
|
+
|
6
|
+
from schemathesis.core.errors import InfiniteRecursiveReference
|
7
|
+
from schemathesis.core.jsonschema.bundler import BundleError
|
8
|
+
from schemathesis.specs.openapi.adapter.parameters import resource_name_from_ref
|
9
|
+
from schemathesis.specs.openapi.adapter.references import maybe_resolve
|
10
|
+
from schemathesis.specs.openapi.stateful.dependencies import naming
|
11
|
+
from schemathesis.specs.openapi.stateful.dependencies.models import (
|
12
|
+
CanonicalizationCache,
|
13
|
+
Cardinality,
|
14
|
+
DefinitionSource,
|
15
|
+
ResourceDefinition,
|
16
|
+
ResourceMap,
|
17
|
+
)
|
18
|
+
from schemathesis.specs.openapi.stateful.dependencies.naming import from_path
|
19
|
+
from schemathesis.specs.openapi.stateful.dependencies.schemas import (
|
20
|
+
ROOT_POINTER,
|
21
|
+
canonicalize,
|
22
|
+
try_unwrap_composition,
|
23
|
+
unwrap_schema,
|
24
|
+
)
|
25
|
+
|
26
|
+
if TYPE_CHECKING:
|
27
|
+
from schemathesis.core.compat import RefResolver
|
28
|
+
from schemathesis.schemas import APIOperation
|
29
|
+
from schemathesis.specs.openapi.adapter.responses import OpenApiResponse
|
30
|
+
|
31
|
+
|
32
|
+
@dataclass
|
33
|
+
class ExtractedResource:
|
34
|
+
"""How a resource was extracted from a response."""
|
35
|
+
|
36
|
+
resource: ResourceDefinition
|
37
|
+
# Where in response body (JSON pointer)
|
38
|
+
pointer: str
|
39
|
+
# Is this a single resource or an array?
|
40
|
+
cardinality: Cardinality
|
41
|
+
|
42
|
+
__slots__ = ("resource", "pointer", "cardinality")
|
43
|
+
|
44
|
+
|
45
|
+
def extract_resources_from_responses(
|
46
|
+
*,
|
47
|
+
operation: APIOperation,
|
48
|
+
resources: ResourceMap,
|
49
|
+
updated_resources: set[str],
|
50
|
+
resolver: RefResolver,
|
51
|
+
canonicalization_cache: CanonicalizationCache,
|
52
|
+
) -> Iterator[tuple[OpenApiResponse, ExtractedResource]]:
|
53
|
+
"""Extract resource definitions from operation's successful responses.
|
54
|
+
|
55
|
+
Processes each 2xx response, unwrapping pagination wrappers,
|
56
|
+
handling `allOf` / `oneOf` / `anyOf` composition, and determining cardinality.
|
57
|
+
Updates the global resource registry as resources are discovered.
|
58
|
+
"""
|
59
|
+
for response in operation.responses.iter_successful_responses():
|
60
|
+
for extracted in iter_resources_from_response(
|
61
|
+
path=operation.path,
|
62
|
+
response=response,
|
63
|
+
resources=resources,
|
64
|
+
updated_resources=updated_resources,
|
65
|
+
resolver=resolver,
|
66
|
+
canonicalization_cache=canonicalization_cache,
|
67
|
+
):
|
68
|
+
yield response, extracted
|
69
|
+
|
70
|
+
|
71
|
+
def iter_resources_from_response(
|
72
|
+
*,
|
73
|
+
path: str,
|
74
|
+
response: OpenApiResponse,
|
75
|
+
resources: ResourceMap,
|
76
|
+
updated_resources: set[str],
|
77
|
+
resolver: RefResolver,
|
78
|
+
canonicalization_cache: CanonicalizationCache,
|
79
|
+
) -> Iterator[ExtractedResource]:
|
80
|
+
schema = response.get_raw_schema()
|
81
|
+
|
82
|
+
if isinstance(schema, bool):
|
83
|
+
boolean_resource = _resource_from_boolean_schema(path=path, resources=resources)
|
84
|
+
if boolean_resource is not None:
|
85
|
+
yield boolean_resource
|
86
|
+
return None
|
87
|
+
elif not isinstance(schema, dict):
|
88
|
+
# Ignore invalid schemas
|
89
|
+
return None
|
90
|
+
|
91
|
+
parent_ref = schema.get("$ref")
|
92
|
+
_, resolved = maybe_resolve(schema, resolver, "")
|
93
|
+
|
94
|
+
# Sometimes data is wrapped in `data` field
|
95
|
+
pointer = None
|
96
|
+
properties = resolved.get("properties", {})
|
97
|
+
if properties and list(properties) == ["data"]:
|
98
|
+
pointer = "/data"
|
99
|
+
resolved = properties["data"]
|
100
|
+
|
101
|
+
resolved = try_unwrap_composition(resolved, resolver)
|
102
|
+
|
103
|
+
if "allOf" in resolved:
|
104
|
+
if parent_ref is not None and parent_ref in canonicalization_cache:
|
105
|
+
canonicalized = canonicalization_cache[parent_ref]
|
106
|
+
else:
|
107
|
+
try:
|
108
|
+
canonicalized = canonicalize(cast(dict, resolved), resolver)
|
109
|
+
except (InfiniteRecursiveReference, BundleError):
|
110
|
+
canonicalized = resolved
|
111
|
+
if parent_ref is not None:
|
112
|
+
canonicalization_cache[parent_ref] = canonicalized
|
113
|
+
else:
|
114
|
+
canonicalized = resolved
|
115
|
+
|
116
|
+
# Detect wrapper pattern and navigate to data
|
117
|
+
unwrapped = unwrap_schema(schema=canonicalized, path=path, parent_ref=parent_ref, resolver=resolver)
|
118
|
+
|
119
|
+
# Recover $ref lost during allOf canonicalization
|
120
|
+
recovered_ref = None
|
121
|
+
if unwrapped.pointer != ROOT_POINTER and "allOf" in resolved:
|
122
|
+
recovered_ref = _recover_ref_from_allof(
|
123
|
+
branches=resolved["allOf"],
|
124
|
+
pointer=unwrapped.pointer,
|
125
|
+
resolver=resolver,
|
126
|
+
)
|
127
|
+
|
128
|
+
# Extract resource and determine cardinality
|
129
|
+
result = _extract_resource_and_cardinality(
|
130
|
+
schema=unwrapped.schema,
|
131
|
+
path=path,
|
132
|
+
resources=resources,
|
133
|
+
updated_resources=updated_resources,
|
134
|
+
resolver=resolver,
|
135
|
+
parent_ref=recovered_ref or unwrapped.ref or parent_ref,
|
136
|
+
)
|
137
|
+
|
138
|
+
if result is not None:
|
139
|
+
resource, cardinality = result
|
140
|
+
if pointer:
|
141
|
+
if unwrapped.pointer != ROOT_POINTER:
|
142
|
+
pointer += unwrapped.pointer
|
143
|
+
else:
|
144
|
+
pointer = unwrapped.pointer
|
145
|
+
yield ExtractedResource(resource=resource, cardinality=cardinality, pointer=pointer)
|
146
|
+
|
147
|
+
|
148
|
+
def _recover_ref_from_allof(*, branches: list[dict], pointer: str, resolver: RefResolver) -> str | None:
|
149
|
+
"""Recover original $ref from allOf branches after canonicalization.
|
150
|
+
|
151
|
+
Canonicalization inlines all $refs, losing resource name information.
|
152
|
+
This searches original allOf branches to find which one defined the
|
153
|
+
property at the given pointer.
|
154
|
+
"""
|
155
|
+
# Parse pointer segments (e.g., "/data" -> ["data"])
|
156
|
+
segments = [s for s in pointer.strip("/").split("/") if s]
|
157
|
+
|
158
|
+
# Search each branch for the property
|
159
|
+
for branch in branches:
|
160
|
+
_, resolved_branch = maybe_resolve(branch, resolver, "")
|
161
|
+
properties = resolved_branch.get("properties", {})
|
162
|
+
|
163
|
+
# Check if this branch defines the target property
|
164
|
+
if segments[-1] in properties:
|
165
|
+
# Navigate to property in original (unresolved) branch
|
166
|
+
original_properties = branch.get("properties", {})
|
167
|
+
if segments[-1] in original_properties:
|
168
|
+
prop_schema = original_properties[segments[-1]]
|
169
|
+
# Extract $ref from property or its items
|
170
|
+
return prop_schema.get("$ref") or prop_schema.get("items", {}).get("$ref")
|
171
|
+
|
172
|
+
return None
|
173
|
+
|
174
|
+
|
175
|
+
def _resource_from_boolean_schema(*, path: str, resources: ResourceMap) -> ExtractedResource | None:
|
176
|
+
name = from_path(path)
|
177
|
+
if name is None:
|
178
|
+
return None
|
179
|
+
resource = resources.get(name)
|
180
|
+
if resource is None:
|
181
|
+
resource = ResourceDefinition.without_properties(name)
|
182
|
+
resources[name] = resource
|
183
|
+
# Do not update existing resource as if it is inferred, it will have at least one field
|
184
|
+
return ExtractedResource(resource=resource, cardinality=Cardinality.ONE, pointer=ROOT_POINTER)
|
185
|
+
|
186
|
+
|
187
|
+
def _extract_resource_and_cardinality(
|
188
|
+
*,
|
189
|
+
schema: Mapping[str, Any],
|
190
|
+
path: str,
|
191
|
+
resources: ResourceMap,
|
192
|
+
updated_resources: set[str],
|
193
|
+
resolver: RefResolver,
|
194
|
+
parent_ref: str | None = None,
|
195
|
+
) -> tuple[ResourceDefinition, Cardinality] | None:
|
196
|
+
"""Extract resource from schema and determine cardinality."""
|
197
|
+
# Check if it's an array
|
198
|
+
if schema.get("type") == "array" or "items" in schema:
|
199
|
+
items = schema.get("items")
|
200
|
+
if not isinstance(items, dict):
|
201
|
+
return None
|
202
|
+
|
203
|
+
# Resolve items if it's a $ref
|
204
|
+
_, resolved_items = maybe_resolve(items, resolver, "")
|
205
|
+
|
206
|
+
# Extract resource from items
|
207
|
+
resource = _extract_resource_from_schema(
|
208
|
+
schema=resolved_items,
|
209
|
+
path=path,
|
210
|
+
resources=resources,
|
211
|
+
updated_resources=updated_resources,
|
212
|
+
resolver=resolver,
|
213
|
+
# Prefer items $ref for name
|
214
|
+
parent_ref=items.get("$ref") or parent_ref,
|
215
|
+
)
|
216
|
+
|
217
|
+
if resource is None:
|
218
|
+
return None
|
219
|
+
|
220
|
+
return resource, Cardinality.MANY
|
221
|
+
|
222
|
+
# Single object
|
223
|
+
resource = _extract_resource_from_schema(
|
224
|
+
schema=schema,
|
225
|
+
path=path,
|
226
|
+
resources=resources,
|
227
|
+
updated_resources=updated_resources,
|
228
|
+
resolver=resolver,
|
229
|
+
parent_ref=parent_ref,
|
230
|
+
)
|
231
|
+
|
232
|
+
if resource is None:
|
233
|
+
return None
|
234
|
+
|
235
|
+
return resource, Cardinality.ONE
|
236
|
+
|
237
|
+
|
238
|
+
def _extract_resource_from_schema(
|
239
|
+
*,
|
240
|
+
schema: Mapping[str, Any],
|
241
|
+
path: str,
|
242
|
+
resources: ResourceMap,
|
243
|
+
updated_resources: set[str],
|
244
|
+
resolver: RefResolver,
|
245
|
+
parent_ref: str | None = None,
|
246
|
+
) -> ResourceDefinition | None:
|
247
|
+
"""Extract resource definition from a schema."""
|
248
|
+
resource_name: str | None = None
|
249
|
+
|
250
|
+
ref = schema.get("$ref")
|
251
|
+
if ref is not None:
|
252
|
+
resource_name = resource_name_from_ref(ref)
|
253
|
+
elif parent_ref is not None:
|
254
|
+
resource_name = resource_name_from_ref(parent_ref)
|
255
|
+
else:
|
256
|
+
resource_name = naming.from_path(path)
|
257
|
+
|
258
|
+
if resource_name is None:
|
259
|
+
return None
|
260
|
+
|
261
|
+
resource = resources.get(resource_name)
|
262
|
+
|
263
|
+
if resource is None or resource.source < DefinitionSource.SCHEMA_WITH_PROPERTIES:
|
264
|
+
_, resolved = maybe_resolve(schema, resolver, "")
|
265
|
+
|
266
|
+
properties = resolved.get("properties")
|
267
|
+
if properties:
|
268
|
+
fields = list(properties)
|
269
|
+
source = DefinitionSource.SCHEMA_WITH_PROPERTIES
|
270
|
+
else:
|
271
|
+
fields = []
|
272
|
+
source = DefinitionSource.SCHEMA_WITHOUT_PROPERTIES
|
273
|
+
if resource is not None:
|
274
|
+
if resource.source < source:
|
275
|
+
resource.source = source
|
276
|
+
resource.fields = fields
|
277
|
+
updated_resources.add(resource_name)
|
278
|
+
else:
|
279
|
+
resource = ResourceDefinition(name=resource_name, fields=fields, source=source)
|
280
|
+
resources[resource_name] = resource
|
281
|
+
|
282
|
+
return resource
|