schemathesis 4.2.1__py3-none-any.whl → 4.3.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.
- schemathesis/config/__init__.py +8 -1
- schemathesis/config/_phases.py +14 -3
- schemathesis/config/schema.json +2 -1
- schemathesis/core/jsonschema/bundler.py +4 -3
- schemathesis/core/jsonschema/references.py +185 -85
- schemathesis/core/transforms.py +14 -6
- schemathesis/engine/context.py +35 -2
- schemathesis/generation/hypothesis/__init__.py +3 -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/examples.py +92 -50
- 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 +168 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +270 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +343 -0
- schemathesis/specs/openapi/stateful/inference.py +2 -1
- {schemathesis-4.2.1.dist-info → schemathesis-4.3.0.dist-info}/METADATA +1 -1
- {schemathesis-4.2.1.dist-info → schemathesis-4.3.0.dist-info}/RECORD +28 -21
- {schemathesis-4.2.1.dist-info → schemathesis-4.3.0.dist-info}/WHEEL +0 -0
- {schemathesis-4.2.1.dist-info → schemathesis-4.3.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.2.1.dist-info → schemathesis-4.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -5,6 +5,7 @@ from schemathesis.specs.openapi.adapter.protocol import (
|
|
5
5
|
BuildPathParameter,
|
6
6
|
ExtractHeaderSchema,
|
7
7
|
ExtractParameterSchema,
|
8
|
+
ExtractRawResponseSchema,
|
8
9
|
ExtractResponseSchema,
|
9
10
|
ExtractSecurityParameters,
|
10
11
|
IterParameters,
|
@@ -18,6 +19,7 @@ example_keyword = "x-example"
|
|
18
19
|
examples_container_keyword = "x-examples"
|
19
20
|
|
20
21
|
extract_parameter_schema: ExtractParameterSchema = parameters.extract_parameter_schema_v2
|
22
|
+
extract_raw_response_schema: ExtractRawResponseSchema = responses.extract_raw_response_schema_v2
|
21
23
|
extract_response_schema: ExtractResponseSchema = responses.extract_response_schema_v2
|
22
24
|
extract_header_schema: ExtractHeaderSchema = responses.extract_header_schema_v2
|
23
25
|
iter_parameters: IterParameters = parameters.iter_parameters_v2
|
@@ -5,6 +5,7 @@ from schemathesis.specs.openapi.adapter.protocol import (
|
|
5
5
|
BuildPathParameter,
|
6
6
|
ExtractHeaderSchema,
|
7
7
|
ExtractParameterSchema,
|
8
|
+
ExtractRawResponseSchema,
|
8
9
|
ExtractResponseSchema,
|
9
10
|
ExtractSecurityParameters,
|
10
11
|
IterParameters,
|
@@ -18,6 +19,7 @@ example_keyword = "example"
|
|
18
19
|
examples_container_keyword = "examples"
|
19
20
|
|
20
21
|
extract_parameter_schema: ExtractParameterSchema = parameters.extract_parameter_schema_v3
|
22
|
+
extract_raw_response_schema: ExtractRawResponseSchema = responses.extract_raw_response_schema_v3
|
21
23
|
extract_response_schema: ExtractResponseSchema = responses.extract_response_schema_v3
|
22
24
|
extract_header_schema: ExtractHeaderSchema = responses.extract_header_schema_v3
|
23
25
|
iter_parameters: IterParameters = parameters.iter_parameters_v3
|
@@ -5,6 +5,7 @@ from schemathesis.specs.openapi.adapter.protocol import (
|
|
5
5
|
BuildPathParameter,
|
6
6
|
ExtractHeaderSchema,
|
7
7
|
ExtractParameterSchema,
|
8
|
+
ExtractRawResponseSchema,
|
8
9
|
ExtractResponseSchema,
|
9
10
|
ExtractSecurityParameters,
|
10
11
|
IterParameters,
|
@@ -18,6 +19,7 @@ example_keyword = "example"
|
|
18
19
|
examples_container_keyword = "examples"
|
19
20
|
|
20
21
|
extract_parameter_schema: ExtractParameterSchema = parameters.extract_parameter_schema_v3
|
22
|
+
extract_raw_response_schema: ExtractRawResponseSchema = responses.extract_raw_response_schema_v3
|
21
23
|
extract_response_schema: ExtractResponseSchema = responses.extract_response_schema_v3
|
22
24
|
extract_header_schema: ExtractHeaderSchema = responses.extract_header_schema_v3
|
23
25
|
iter_parameters: IterParameters = parameters.iter_parameters_v3
|
@@ -109,15 +109,19 @@ def extract_top_level(
|
|
109
109
|
assert isinstance(operation.schema, BaseOpenAPISchema)
|
110
110
|
|
111
111
|
responses = list(operation.responses.iter_examples())
|
112
|
-
seen_references: set[str] = set()
|
113
112
|
for parameter in operation.iter_parameters():
|
114
113
|
if "schema" in parameter.definition:
|
115
114
|
schema = parameter.definition["schema"]
|
116
115
|
resolver = RefResolver.from_schema(schema)
|
117
|
-
|
116
|
+
reference_path: tuple[str, ...] = ()
|
118
117
|
definitions = [
|
119
118
|
parameter.definition,
|
120
|
-
*
|
119
|
+
*[
|
120
|
+
expanded_schema
|
121
|
+
for expanded_schema, _ in _expand_subschemas(
|
122
|
+
schema=schema, resolver=resolver, reference_path=reference_path
|
123
|
+
)
|
124
|
+
],
|
121
125
|
]
|
122
126
|
else:
|
123
127
|
definitions = [parameter.definition]
|
@@ -138,10 +142,15 @@ def extract_top_level(
|
|
138
142
|
if "schema" in parameter.definition:
|
139
143
|
schema = parameter.definition["schema"]
|
140
144
|
resolver = RefResolver.from_schema(schema)
|
141
|
-
|
142
|
-
for
|
143
|
-
|
144
|
-
|
145
|
+
reference_path = ()
|
146
|
+
for expanded_schema, _ in _expand_subschemas(
|
147
|
+
schema=schema, resolver=resolver, reference_path=reference_path
|
148
|
+
):
|
149
|
+
if (
|
150
|
+
isinstance(expanded_schema, dict)
|
151
|
+
and parameter.adapter.examples_container_keyword in expanded_schema
|
152
|
+
):
|
153
|
+
for value in expanded_schema[parameter.adapter.examples_container_keyword]:
|
145
154
|
yield ParameterExample(
|
146
155
|
container=parameter.location.container_name, name=parameter.name, value=value
|
147
156
|
)
|
@@ -152,10 +161,15 @@ def extract_top_level(
|
|
152
161
|
if "schema" in body.definition:
|
153
162
|
schema = body.definition["schema"]
|
154
163
|
resolver = RefResolver.from_schema(schema)
|
155
|
-
|
164
|
+
reference_path = ()
|
156
165
|
definitions = [
|
157
166
|
body.definition,
|
158
|
-
*
|
167
|
+
*[
|
168
|
+
expanded_schema
|
169
|
+
for expanded_schema, _ in _expand_subschemas(
|
170
|
+
schema=schema, resolver=resolver, reference_path=reference_path
|
171
|
+
)
|
172
|
+
],
|
159
173
|
]
|
160
174
|
else:
|
161
175
|
definitions = [body.definition]
|
@@ -172,58 +186,76 @@ def extract_top_level(
|
|
172
186
|
if "schema" in body.definition:
|
173
187
|
schema = body.definition["schema"]
|
174
188
|
resolver = RefResolver.from_schema(schema)
|
175
|
-
|
176
|
-
for
|
177
|
-
|
178
|
-
|
189
|
+
reference_path = ()
|
190
|
+
for expanded_schema, _ in _expand_subschemas(
|
191
|
+
schema=schema, resolver=resolver, reference_path=reference_path
|
192
|
+
):
|
193
|
+
if isinstance(expanded_schema, dict) and body.adapter.examples_container_keyword in expanded_schema:
|
194
|
+
for value in expanded_schema[body.adapter.examples_container_keyword]:
|
179
195
|
yield BodyExample(value=value, media_type=body.media_type)
|
180
196
|
|
181
197
|
|
182
198
|
@overload
|
183
199
|
def _resolve_bundled(
|
184
|
-
schema: dict[str, Any], resolver: RefResolver,
|
185
|
-
) -> dict[str, Any]
|
200
|
+
schema: dict[str, Any], resolver: RefResolver, reference_path: tuple[str, ...]
|
201
|
+
) -> tuple[dict[str, Any], tuple[str, ...]]: ...
|
186
202
|
|
187
203
|
|
188
204
|
@overload
|
189
|
-
def _resolve_bundled(
|
205
|
+
def _resolve_bundled(
|
206
|
+
schema: bool, resolver: RefResolver, reference_path: tuple[str, ...]
|
207
|
+
) -> tuple[bool, tuple[str, ...]]: ...
|
190
208
|
|
191
209
|
|
192
210
|
def _resolve_bundled(
|
193
|
-
schema: dict[str, Any] | bool, resolver: RefResolver,
|
194
|
-
) -> dict[str, Any] | bool:
|
211
|
+
schema: dict[str, Any] | bool, resolver: RefResolver, reference_path: tuple[str, ...]
|
212
|
+
) -> tuple[dict[str, Any] | bool, tuple[str, ...]]:
|
213
|
+
"""Resolve $ref if present."""
|
195
214
|
if isinstance(schema, dict):
|
196
215
|
reference = schema.get("$ref")
|
197
216
|
if isinstance(reference, str):
|
198
|
-
if reference in
|
217
|
+
# Check if this reference is already in the current path
|
218
|
+
if reference in reference_path:
|
199
219
|
# Try to remove recursive references to avoid infinite recursion
|
200
220
|
remaining_references = references.sanitize(schema)
|
201
221
|
if reference in remaining_references:
|
202
222
|
raise InfiniteRecursiveReference(reference)
|
203
|
-
|
223
|
+
|
224
|
+
new_path = reference_path + (reference,)
|
225
|
+
|
204
226
|
try:
|
205
|
-
_,
|
227
|
+
_, resolved_schema = resolver.resolve(reference)
|
206
228
|
except RefResolutionError as exc:
|
207
229
|
raise UnresolvableReference(reference) from exc
|
208
|
-
|
230
|
+
|
231
|
+
return resolved_schema, new_path
|
232
|
+
|
233
|
+
return schema, reference_path
|
209
234
|
|
210
235
|
|
211
236
|
def _expand_subschemas(
|
212
|
-
*, schema: dict[str, Any] | bool, resolver: RefResolver,
|
213
|
-
) -> Generator[dict[str, Any] | bool, None, None]:
|
214
|
-
schema
|
215
|
-
|
237
|
+
*, schema: dict[str, Any] | bool, resolver: RefResolver, reference_path: tuple[str, ...]
|
238
|
+
) -> Generator[tuple[dict[str, Any] | bool, tuple[str, ...]], None, None]:
|
239
|
+
"""Expand schema and all its subschemas."""
|
240
|
+
schema, current_path = _resolve_bundled(schema, resolver, reference_path)
|
241
|
+
yield (schema, current_path)
|
242
|
+
|
216
243
|
if isinstance(schema, dict):
|
244
|
+
# For anyOf/oneOf, yield each alternative with the same path
|
217
245
|
for key in ("anyOf", "oneOf"):
|
218
246
|
if key in schema:
|
219
247
|
for subschema in schema[key]:
|
220
|
-
|
248
|
+
# Each alternative starts with the current path
|
249
|
+
yield (subschema, current_path)
|
250
|
+
|
251
|
+
# For allOf, merge all alternatives
|
221
252
|
if "allOf" in schema:
|
222
253
|
subschema = deepclone(schema["allOf"][0])
|
223
|
-
subschema = _resolve_bundled(subschema, resolver,
|
254
|
+
subschema, _ = _resolve_bundled(subschema, resolver, current_path)
|
255
|
+
|
224
256
|
for sub in schema["allOf"][1:]:
|
225
257
|
if isinstance(sub, dict):
|
226
|
-
sub = _resolve_bundled(sub, resolver,
|
258
|
+
sub, _ = _resolve_bundled(sub, resolver, current_path)
|
227
259
|
for key, value in sub.items():
|
228
260
|
if key == "properties":
|
229
261
|
subschema.setdefault("properties", {}).update(value)
|
@@ -235,7 +267,8 @@ def _expand_subschemas(
|
|
235
267
|
subschema.setdefault("examples", []).append(value)
|
236
268
|
else:
|
237
269
|
subschema[key] = value
|
238
|
-
|
270
|
+
|
271
|
+
yield (subschema, current_path)
|
239
272
|
|
240
273
|
|
241
274
|
def extract_inner_examples(examples: dict[str, Any] | list, schema: BaseOpenAPISchema) -> Generator[Any, None, None]:
|
@@ -269,13 +302,12 @@ def extract_from_schemas(
|
|
269
302
|
operation: APIOperation[OpenApiParameter, OpenApiResponses, OpenApiSecurityParameters],
|
270
303
|
) -> Generator[Example, None, None]:
|
271
304
|
"""Extract examples from parameters' schema definitions."""
|
272
|
-
seen_references: set[str] = set()
|
273
305
|
for parameter in operation.iter_parameters():
|
274
306
|
schema = parameter.optimized_schema
|
275
307
|
if isinstance(schema, bool):
|
276
308
|
continue
|
277
309
|
resolver = RefResolver.from_schema(schema)
|
278
|
-
|
310
|
+
reference_path: tuple[str, ...] = ()
|
279
311
|
bundle_storage = schema.get(BUNDLE_STORAGE_KEY)
|
280
312
|
for value in extract_from_schema(
|
281
313
|
operation=operation,
|
@@ -283,7 +315,7 @@ def extract_from_schemas(
|
|
283
315
|
example_keyword=parameter.adapter.example_keyword,
|
284
316
|
examples_container_keyword=parameter.adapter.examples_container_keyword,
|
285
317
|
resolver=resolver,
|
286
|
-
|
318
|
+
reference_path=reference_path,
|
287
319
|
bundle_storage=bundle_storage,
|
288
320
|
):
|
289
321
|
yield ParameterExample(container=parameter.location.container_name, name=parameter.name, value=value)
|
@@ -295,14 +327,14 @@ def extract_from_schemas(
|
|
295
327
|
resolver = RefResolver.from_schema(schema)
|
296
328
|
bundle_storage = schema.get(BUNDLE_STORAGE_KEY)
|
297
329
|
for example_keyword, examples_container_keyword in (("example", "examples"), ("x-example", "x-examples")):
|
298
|
-
|
330
|
+
reference_path = ()
|
299
331
|
for value in extract_from_schema(
|
300
332
|
operation=operation,
|
301
333
|
schema=schema,
|
302
334
|
example_keyword=example_keyword,
|
303
335
|
examples_container_keyword=examples_container_keyword,
|
304
336
|
resolver=resolver,
|
305
|
-
|
337
|
+
reference_path=reference_path,
|
306
338
|
bundle_storage=bundle_storage,
|
307
339
|
):
|
308
340
|
yield BodyExample(value=value, media_type=body.media_type)
|
@@ -315,49 +347,57 @@ def extract_from_schema(
|
|
315
347
|
example_keyword: str,
|
316
348
|
examples_container_keyword: str,
|
317
349
|
resolver: RefResolver,
|
318
|
-
|
350
|
+
reference_path: tuple[str, ...],
|
319
351
|
bundle_storage: dict[str, Any] | None,
|
320
352
|
) -> Generator[Any, None, None]:
|
321
353
|
"""Extract all examples from a single schema definition."""
|
322
354
|
# This implementation supports only `properties` and `items`
|
323
|
-
schema = _resolve_bundled(schema, resolver,
|
355
|
+
schema, current_path = _resolve_bundled(schema, resolver, reference_path)
|
356
|
+
|
324
357
|
if "properties" in schema:
|
325
358
|
variants = {}
|
326
359
|
required = schema.get("required", [])
|
327
360
|
to_generate: dict[str, Any] = {}
|
361
|
+
|
328
362
|
for name, subschema in schema["properties"].items():
|
329
363
|
values = []
|
330
|
-
for
|
331
|
-
schema=subschema, resolver=resolver,
|
364
|
+
for expanded_schema, expanded_path in _expand_subschemas(
|
365
|
+
schema=subschema, resolver=resolver, reference_path=current_path
|
332
366
|
):
|
333
|
-
if isinstance(
|
334
|
-
to_generate[name] =
|
367
|
+
if isinstance(expanded_schema, bool):
|
368
|
+
to_generate[name] = expanded_schema
|
335
369
|
continue
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
370
|
+
|
371
|
+
if example_keyword in expanded_schema:
|
372
|
+
values.append(expanded_schema[example_keyword])
|
373
|
+
|
374
|
+
if examples_container_keyword in expanded_schema and isinstance(
|
375
|
+
expanded_schema[examples_container_keyword], list
|
340
376
|
):
|
341
377
|
# These are JSON Schema examples, which is an array of values
|
342
|
-
values.extend(
|
378
|
+
values.extend(expanded_schema[examples_container_keyword])
|
379
|
+
|
343
380
|
# Check nested examples as well
|
344
381
|
values.extend(
|
345
382
|
extract_from_schema(
|
346
383
|
operation=operation,
|
347
|
-
schema=
|
384
|
+
schema=expanded_schema,
|
348
385
|
example_keyword=example_keyword,
|
349
386
|
examples_container_keyword=examples_container_keyword,
|
350
387
|
resolver=resolver,
|
351
|
-
|
388
|
+
reference_path=expanded_path,
|
352
389
|
bundle_storage=bundle_storage,
|
353
390
|
)
|
354
391
|
)
|
392
|
+
|
355
393
|
if not values:
|
356
394
|
if name in required:
|
357
395
|
# Defer generation to only generate these variants if at least one property has examples
|
358
|
-
to_generate[name] =
|
396
|
+
to_generate[name] = expanded_schema
|
359
397
|
continue
|
398
|
+
|
360
399
|
variants[name] = values
|
400
|
+
|
361
401
|
if variants:
|
362
402
|
config = operation.schema.config.generation_for(operation=operation, phase="examples")
|
363
403
|
for name, subschema in to_generate.items():
|
@@ -369,6 +409,7 @@ def extract_from_schema(
|
|
369
409
|
subschema[BUNDLE_STORAGE_KEY] = bundle_storage
|
370
410
|
generated = _generate_single_example(subschema, config)
|
371
411
|
variants[name] = [generated]
|
412
|
+
|
372
413
|
# Calculate the maximum number of examples any property has
|
373
414
|
total_combos = max(len(examples) for examples in variants.values())
|
374
415
|
# Evenly distribute examples by cycling through them
|
@@ -377,6 +418,7 @@ def extract_from_schema(
|
|
377
418
|
name: next(islice(cycle(property_variants), idx, None))
|
378
419
|
for name, property_variants in variants.items()
|
379
420
|
}
|
421
|
+
|
380
422
|
elif "items" in schema and isinstance(schema["items"], dict):
|
381
423
|
# Each inner value should be wrapped in an array
|
382
424
|
for value in extract_from_schema(
|
@@ -385,7 +427,7 @@ def extract_from_schema(
|
|
385
427
|
example_keyword=example_keyword,
|
386
428
|
examples_container_keyword=examples_container_keyword,
|
387
429
|
resolver=resolver,
|
388
|
-
|
430
|
+
reference_path=current_path,
|
389
431
|
bundle_storage=bundle_storage,
|
390
432
|
):
|
391
433
|
yield [value]
|
@@ -0,0 +1,88 @@
|
|
1
|
+
"""Dependency detection between API operations for stateful testing.
|
2
|
+
|
3
|
+
Infers which operations must run before others by tracking resource creation and consumption across API operations.
|
4
|
+
"""
|
5
|
+
|
6
|
+
from __future__ import annotations
|
7
|
+
|
8
|
+
from typing import TYPE_CHECKING
|
9
|
+
|
10
|
+
from schemathesis.core.compat import RefResolutionError
|
11
|
+
from schemathesis.core.result import Ok
|
12
|
+
from schemathesis.specs.openapi.stateful.dependencies.inputs import extract_inputs, update_input_field_bindings
|
13
|
+
from schemathesis.specs.openapi.stateful.dependencies.models import (
|
14
|
+
CanonicalizationCache,
|
15
|
+
Cardinality,
|
16
|
+
DefinitionSource,
|
17
|
+
DependencyGraph,
|
18
|
+
InputSlot,
|
19
|
+
OperationMap,
|
20
|
+
OperationNode,
|
21
|
+
OutputSlot,
|
22
|
+
ResourceDefinition,
|
23
|
+
ResourceMap,
|
24
|
+
)
|
25
|
+
from schemathesis.specs.openapi.stateful.dependencies.outputs import extract_outputs
|
26
|
+
|
27
|
+
if TYPE_CHECKING:
|
28
|
+
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
29
|
+
|
30
|
+
__all__ = [
|
31
|
+
"analyze",
|
32
|
+
"DependencyGraph",
|
33
|
+
"InputSlot",
|
34
|
+
"OutputSlot",
|
35
|
+
"Cardinality",
|
36
|
+
"ResourceDefinition",
|
37
|
+
"DefinitionSource",
|
38
|
+
]
|
39
|
+
|
40
|
+
|
41
|
+
def analyze(schema: BaseOpenAPISchema) -> DependencyGraph:
|
42
|
+
"""Build a dependency graph by inferring resource producers and consumers from API operations."""
|
43
|
+
operations: OperationMap = {}
|
44
|
+
resources: ResourceMap = {}
|
45
|
+
# Track resources that got upgraded (e.g., from parameter inference to schema definition)
|
46
|
+
# to propagate better field information to existing input slots
|
47
|
+
updated_resources: set[str] = set()
|
48
|
+
# Cache for expensive canonicalize() calls - same schemas are often processed multiple times
|
49
|
+
canonicalization_cache: CanonicalizationCache = {}
|
50
|
+
|
51
|
+
for result in schema.get_all_operations():
|
52
|
+
if isinstance(result, Ok):
|
53
|
+
operation = result.ok()
|
54
|
+
try:
|
55
|
+
inputs = extract_inputs(
|
56
|
+
operation=operation,
|
57
|
+
resources=resources,
|
58
|
+
updated_resources=updated_resources,
|
59
|
+
resolver=schema.resolver,
|
60
|
+
canonicalization_cache=canonicalization_cache,
|
61
|
+
)
|
62
|
+
outputs = extract_outputs(
|
63
|
+
operation=operation,
|
64
|
+
resources=resources,
|
65
|
+
updated_resources=updated_resources,
|
66
|
+
resolver=schema.resolver,
|
67
|
+
canonicalization_cache=canonicalization_cache,
|
68
|
+
)
|
69
|
+
operations[operation.label] = OperationNode(
|
70
|
+
method=operation.method,
|
71
|
+
path=operation.path,
|
72
|
+
inputs=list(inputs),
|
73
|
+
outputs=list(outputs),
|
74
|
+
)
|
75
|
+
except RefResolutionError:
|
76
|
+
# Skip operations with unresolvable $refs (e.g., unavailable external references or references with typos)
|
77
|
+
# These won't participate in dependency detection
|
78
|
+
continue
|
79
|
+
|
80
|
+
# Update input slots with improved resource definitions discovered during extraction
|
81
|
+
#
|
82
|
+
# Example:
|
83
|
+
# - `DELETE /users/{userId}` initially inferred `User.fields=["userId"]`
|
84
|
+
# - then `POST /users` response revealed `User.fields=["id", "email"]`
|
85
|
+
for resource in updated_resources:
|
86
|
+
update_input_field_bindings(resource, operations)
|
87
|
+
|
88
|
+
return DependencyGraph(operations=operations, resources=resources)
|
@@ -0,0 +1,182 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Iterator
|
4
|
+
|
5
|
+
from schemathesis.core.parameters import ParameterLocation
|
6
|
+
from schemathesis.specs.openapi.stateful.dependencies import naming
|
7
|
+
from schemathesis.specs.openapi.stateful.dependencies.models import (
|
8
|
+
CanonicalizationCache,
|
9
|
+
DefinitionSource,
|
10
|
+
InputSlot,
|
11
|
+
OperationMap,
|
12
|
+
ResourceDefinition,
|
13
|
+
ResourceMap,
|
14
|
+
)
|
15
|
+
from schemathesis.specs.openapi.stateful.dependencies.resources import extract_resources_from_responses
|
16
|
+
|
17
|
+
if TYPE_CHECKING:
|
18
|
+
from schemathesis.core.compat import RefResolver
|
19
|
+
from schemathesis.specs.openapi.schemas import APIOperation
|
20
|
+
|
21
|
+
|
22
|
+
def extract_inputs(
|
23
|
+
*,
|
24
|
+
operation: APIOperation,
|
25
|
+
resources: ResourceMap,
|
26
|
+
updated_resources: set[str],
|
27
|
+
resolver: RefResolver,
|
28
|
+
canonicalization_cache: CanonicalizationCache,
|
29
|
+
) -> Iterator[InputSlot]:
|
30
|
+
"""Extract resource dependencies for an API operation from its input parameters.
|
31
|
+
|
32
|
+
Connects each parameter (e.g., `userId`) to its resource definition (`User`),
|
33
|
+
creating placeholder resources if not yet discovered from their schemas.
|
34
|
+
"""
|
35
|
+
# Note: Currently limited to path parameters. Query / header / body will be supported in future releases.
|
36
|
+
for param in operation.path_parameters:
|
37
|
+
input_slot = _resolve_parameter_dependency(
|
38
|
+
parameter_name=param.name,
|
39
|
+
parameter_location=param.location,
|
40
|
+
operation=operation,
|
41
|
+
resources=resources,
|
42
|
+
updated_resources=updated_resources,
|
43
|
+
resolver=resolver,
|
44
|
+
canonicalization_cache=canonicalization_cache,
|
45
|
+
)
|
46
|
+
if input_slot is not None:
|
47
|
+
yield input_slot
|
48
|
+
|
49
|
+
|
50
|
+
def _resolve_parameter_dependency(
|
51
|
+
*,
|
52
|
+
parameter_name: str,
|
53
|
+
parameter_location: ParameterLocation,
|
54
|
+
operation: APIOperation,
|
55
|
+
resources: ResourceMap,
|
56
|
+
updated_resources: set[str],
|
57
|
+
resolver: RefResolver,
|
58
|
+
canonicalization_cache: CanonicalizationCache,
|
59
|
+
) -> InputSlot | None:
|
60
|
+
"""Connect a parameter to its resource definition, creating placeholder if needed.
|
61
|
+
|
62
|
+
Strategy:
|
63
|
+
1. Infer resource name from parameter (`userId` -> `User`)
|
64
|
+
2. Use existing resource if high-quality definition exists
|
65
|
+
3. Try discovering from operation's response schemas
|
66
|
+
4. Fall back to creating placeholder with a single field
|
67
|
+
"""
|
68
|
+
resource_name = naming.from_parameter(parameter=parameter_name, path=operation.path)
|
69
|
+
|
70
|
+
if resource_name is None:
|
71
|
+
return None
|
72
|
+
|
73
|
+
resource = resources.get(resource_name)
|
74
|
+
|
75
|
+
# Upgrade low-quality resource definitions (e.g., from parameter inference)
|
76
|
+
# by searching this operation's responses for actual schema
|
77
|
+
if resource is None or resource.source < DefinitionSource.SCHEMA_WITH_PROPERTIES:
|
78
|
+
resource = _find_resource_in_responses(
|
79
|
+
operation=operation,
|
80
|
+
resource_name=resource_name,
|
81
|
+
resources=resources,
|
82
|
+
updated_resources=updated_resources,
|
83
|
+
resolver=resolver,
|
84
|
+
canonicalization_cache=canonicalization_cache,
|
85
|
+
)
|
86
|
+
if resource is not None:
|
87
|
+
resources[resource_name] = resource
|
88
|
+
|
89
|
+
# Determine resource and its field
|
90
|
+
if resource is None:
|
91
|
+
# No schema found - create placeholder resource with inferred field
|
92
|
+
#
|
93
|
+
# Example: `DELETE /users/{userId}` with no response body -> `User` resource with "userId" field
|
94
|
+
#
|
95
|
+
# Later operations with schemas will upgrade this placeholder
|
96
|
+
if resource_name in resources:
|
97
|
+
# Resource exists but was empty - update with parameter field
|
98
|
+
resources[resource_name].fields = [parameter_name]
|
99
|
+
resources[resource_name].source = DefinitionSource.PARAMETER_INFERENCE
|
100
|
+
updated_resources.add(resource_name)
|
101
|
+
resource = resources[resource_name]
|
102
|
+
else:
|
103
|
+
resource = ResourceDefinition.inferred_from_parameter(
|
104
|
+
name=resource_name,
|
105
|
+
parameter_name=parameter_name,
|
106
|
+
)
|
107
|
+
resources[resource_name] = resource
|
108
|
+
field = parameter_name
|
109
|
+
else:
|
110
|
+
# Match parameter to resource field (`userId` → `id`, `Id` → `ChannelId`, etc.)
|
111
|
+
field = (
|
112
|
+
naming.find_matching_field(
|
113
|
+
parameter=parameter_name,
|
114
|
+
resource=resource_name,
|
115
|
+
fields=resource.fields,
|
116
|
+
)
|
117
|
+
or "id"
|
118
|
+
)
|
119
|
+
|
120
|
+
return InputSlot(
|
121
|
+
resource=resource,
|
122
|
+
resource_field=field,
|
123
|
+
parameter_name=parameter_name,
|
124
|
+
parameter_location=parameter_location,
|
125
|
+
)
|
126
|
+
|
127
|
+
|
128
|
+
def _find_resource_in_responses(
|
129
|
+
*,
|
130
|
+
operation: APIOperation,
|
131
|
+
resource_name: str,
|
132
|
+
resources: ResourceMap,
|
133
|
+
updated_resources: set[str],
|
134
|
+
resolver: RefResolver,
|
135
|
+
canonicalization_cache: CanonicalizationCache,
|
136
|
+
) -> ResourceDefinition | None:
|
137
|
+
"""Search operation's successful responses for a specific resource definition.
|
138
|
+
|
139
|
+
Used when a parameter references a resource not yet discovered. Scans this
|
140
|
+
operation's response schemas hoping to find the resource definition.
|
141
|
+
"""
|
142
|
+
for _, extracted in extract_resources_from_responses(
|
143
|
+
operation=operation,
|
144
|
+
resources=resources,
|
145
|
+
updated_resources=updated_resources,
|
146
|
+
resolver=resolver,
|
147
|
+
canonicalization_cache=canonicalization_cache,
|
148
|
+
):
|
149
|
+
if extracted.resource.name == resource_name:
|
150
|
+
return extracted.resource
|
151
|
+
|
152
|
+
return None
|
153
|
+
|
154
|
+
|
155
|
+
def update_input_field_bindings(resource_name: str, operations: OperationMap) -> None:
|
156
|
+
"""Update input slots field bindings after resource definition was upgraded.
|
157
|
+
|
158
|
+
When a resource's fields change (e.g., `User` upgraded from `["userId"]` to `["id", "email"]`),
|
159
|
+
existing input slots may reference stale field names. This re-evaluates field matching
|
160
|
+
for all operations using this resource.
|
161
|
+
|
162
|
+
Example:
|
163
|
+
`DELETE /users/{userId}` created `InputSlot(resource_field="userId")`
|
164
|
+
`POST /users` revealed actual fields `["id", "email"]`
|
165
|
+
This updates DELETE's `InputSlot` to use `resource_field="id"`
|
166
|
+
|
167
|
+
"""
|
168
|
+
# Re-evaluate field matching for all operations referencing this resource
|
169
|
+
for operation in operations.values():
|
170
|
+
for input_slot in operation.inputs:
|
171
|
+
# Skip inputs not using this resource
|
172
|
+
if input_slot.resource.name != resource_name:
|
173
|
+
continue
|
174
|
+
|
175
|
+
# Re-match parameter to upgraded resource fields
|
176
|
+
new_field = naming.find_matching_field(
|
177
|
+
parameter=input_slot.parameter_name,
|
178
|
+
resource=resource_name,
|
179
|
+
fields=input_slot.resource.fields,
|
180
|
+
)
|
181
|
+
if new_field is not None:
|
182
|
+
input_slot.resource_field = new_field
|