schemathesis 3.15.4__py3-none-any.whl → 4.4.2__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/__init__.py +53 -25
- schemathesis/auths.py +507 -0
- schemathesis/checks.py +190 -25
- schemathesis/cli/__init__.py +27 -1219
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +133 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +602 -0
- schemathesis/cli/commands/run/context.py +228 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +45 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
- schemathesis/cli/commands/run/handlers/output.py +1750 -0
- schemathesis/cli/commands/run/loaders.py +118 -0
- schemathesis/cli/commands/run/validation.py +256 -0
- schemathesis/cli/constants.py +5 -0
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +203 -0
- schemathesis/cli/ext/options.py +81 -0
- schemathesis/config/__init__.py +202 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +101 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +163 -0
- schemathesis/config/_generation.py +157 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +335 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +253 -0
- schemathesis/config/_projects.py +543 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +89 -0
- schemathesis/config/schema.json +975 -0
- schemathesis/core/__init__.py +72 -0
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +100 -0
- schemathesis/core/deserialization.py +210 -0
- schemathesis/core/errors.py +588 -0
- schemathesis/core/failures.py +316 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +183 -0
- schemathesis/core/jsonschema/keywords.py +40 -0
- schemathesis/core/jsonschema/references.py +222 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/lazy_import.py +15 -0
- schemathesis/core/loaders.py +107 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/core/media_types.py +79 -0
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/core/parameters.py +45 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +34 -0
- schemathesis/core/result.py +27 -0
- schemathesis/core/schema_analysis.py +17 -0
- schemathesis/core/shell.py +203 -0
- schemathesis/core/transforms.py +144 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +73 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +152 -0
- schemathesis/engine/control.py +44 -0
- schemathesis/engine/core.py +201 -0
- schemathesis/engine/errors.py +446 -0
- schemathesis/engine/events.py +284 -0
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +108 -0
- schemathesis/engine/phases/analysis.py +28 -0
- schemathesis/engine/phases/probes.py +172 -0
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +364 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +220 -0
- schemathesis/engine/phases/unit/_executor.py +459 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +254 -0
- schemathesis/errors.py +47 -0
- schemathesis/filters.py +395 -0
- schemathesis/generation/__init__.py +25 -0
- schemathesis/generation/case.py +478 -0
- schemathesis/generation/coverage.py +1528 -0
- schemathesis/generation/hypothesis/__init__.py +121 -0
- schemathesis/generation/hypothesis/builder.py +992 -0
- schemathesis/generation/hypothesis/examples.py +56 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +285 -0
- schemathesis/generation/meta.py +227 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +127 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +294 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +285 -0
- schemathesis/hooks.py +270 -91
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +467 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +315 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +341 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/pytest/plugin.py +357 -0
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +682 -257
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/nodes.py +26 -2
- schemathesis/specs/graphql/scalars.py +77 -12
- schemathesis/specs/graphql/schemas.py +367 -148
- schemathesis/specs/graphql/validation.py +33 -0
- schemathesis/specs/openapi/__init__.py +9 -1
- schemathesis/specs/openapi/_hypothesis.py +555 -318
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +729 -0
- schemathesis/specs/openapi/adapter/protocol.py +59 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +368 -0
- schemathesis/specs/openapi/adapter/security.py +144 -0
- schemathesis/specs/openapi/adapter/v2.py +30 -0
- schemathesis/specs/openapi/adapter/v3_0.py +30 -0
- schemathesis/specs/openapi/adapter/v3_1.py +30 -0
- schemathesis/specs/openapi/analysis.py +96 -0
- schemathesis/specs/openapi/checks.py +748 -82
- schemathesis/specs/openapi/converter.py +176 -37
- schemathesis/specs/openapi/definitions.py +599 -4
- schemathesis/specs/openapi/examples.py +581 -165
- schemathesis/specs/openapi/expressions/__init__.py +52 -5
- schemathesis/specs/openapi/expressions/extractors.py +25 -0
- schemathesis/specs/openapi/expressions/lexer.py +34 -31
- schemathesis/specs/openapi/expressions/nodes.py +97 -46
- schemathesis/specs/openapi/expressions/parser.py +35 -13
- schemathesis/specs/openapi/formats.py +122 -0
- schemathesis/specs/openapi/media_types.py +75 -0
- schemathesis/specs/openapi/negative/__init__.py +93 -73
- schemathesis/specs/openapi/negative/mutations.py +294 -103
- schemathesis/specs/openapi/negative/utils.py +0 -9
- schemathesis/specs/openapi/patterns.py +458 -0
- schemathesis/specs/openapi/references.py +60 -81
- schemathesis/specs/openapi/schemas.py +647 -666
- schemathesis/specs/openapi/serialization.py +53 -30
- schemathesis/specs/openapi/stateful/__init__.py +403 -68
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
- schemathesis/specs/openapi/stateful/inference.py +254 -0
- schemathesis/specs/openapi/stateful/links.py +219 -78
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/common.py +23 -0
- schemathesis/specs/openapi/types/v2.py +129 -0
- schemathesis/specs/openapi/types/v3.py +134 -0
- schemathesis/specs/openapi/utils.py +7 -6
- schemathesis/specs/openapi/warnings.py +75 -0
- schemathesis/transport/__init__.py +224 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +126 -0
- schemathesis/transport/requests.py +278 -0
- schemathesis/transport/serialization.py +329 -0
- schemathesis/transport/wsgi.py +175 -0
- schemathesis-4.4.2.dist-info/METADATA +213 -0
- schemathesis-4.4.2.dist-info/RECORD +192 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
- schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
- schemathesis/_compat.py +0 -57
- schemathesis/_hypothesis.py +0 -123
- schemathesis/auth.py +0 -214
- schemathesis/cli/callbacks.py +0 -240
- schemathesis/cli/cassettes.py +0 -351
- schemathesis/cli/context.py +0 -38
- schemathesis/cli/debug.py +0 -21
- schemathesis/cli/handlers.py +0 -11
- schemathesis/cli/junitxml.py +0 -41
- schemathesis/cli/options.py +0 -70
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -521
- schemathesis/cli/output/short.py +0 -40
- schemathesis/constants.py +0 -88
- schemathesis/exceptions.py +0 -257
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -16
- schemathesis/extra/pytest_plugin.py +0 -251
- schemathesis/failures.py +0 -145
- schemathesis/fixups/__init__.py +0 -29
- schemathesis/fixups/fast_api.py +0 -30
- schemathesis/graphql.py +0 -5
- schemathesis/internal.py +0 -6
- schemathesis/lazy.py +0 -301
- schemathesis/models.py +0 -1113
- schemathesis/parameters.py +0 -91
- schemathesis/runner/__init__.py +0 -470
- schemathesis/runner/events.py +0 -242
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -791
- schemathesis/runner/impl/solo.py +0 -85
- schemathesis/runner/impl/threadpool.py +0 -367
- schemathesis/runner/serialization.py +0 -206
- schemathesis/serializers.py +0 -253
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -10
- schemathesis/service/client.py +0 -62
- schemathesis/service/constants.py +0 -25
- schemathesis/service/events.py +0 -39
- schemathesis/service/handler.py +0 -46
- schemathesis/service/hosts.py +0 -74
- schemathesis/service/metadata.py +0 -42
- schemathesis/service/models.py +0 -21
- schemathesis/service/serialization.py +0 -184
- schemathesis/service/worker.py +0 -39
- schemathesis/specs/graphql/loaders.py +0 -215
- schemathesis/specs/openapi/constants.py +0 -7
- schemathesis/specs/openapi/expressions/context.py +0 -12
- schemathesis/specs/openapi/expressions/pointers.py +0 -29
- schemathesis/specs/openapi/filters.py +0 -44
- schemathesis/specs/openapi/links.py +0 -303
- schemathesis/specs/openapi/loaders.py +0 -453
- schemathesis/specs/openapi/parameters.py +0 -430
- schemathesis/specs/openapi/security.py +0 -129
- schemathesis/specs/openapi/validation.py +0 -24
- schemathesis/stateful.py +0 -358
- schemathesis/targets.py +0 -32
- schemathesis/types.py +0 -38
- schemathesis/utils.py +0 -475
- schemathesis-3.15.4.dist-info/METADATA +0 -202
- schemathesis-3.15.4.dist-info/RECORD +0 -99
- schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
|
@@ -0,0 +1,232 @@
|
|
|
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, Any
|
|
9
|
+
|
|
10
|
+
from schemathesis.core import NOT_SET
|
|
11
|
+
from schemathesis.core.compat import RefResolutionError
|
|
12
|
+
from schemathesis.core.errors import InvalidSchema
|
|
13
|
+
from schemathesis.core.result import Ok
|
|
14
|
+
from schemathesis.specs.openapi.stateful.dependencies.inputs import (
|
|
15
|
+
extract_inputs,
|
|
16
|
+
merge_related_resources,
|
|
17
|
+
update_input_field_bindings,
|
|
18
|
+
)
|
|
19
|
+
from schemathesis.specs.openapi.stateful.dependencies.models import (
|
|
20
|
+
CanonicalizationCache,
|
|
21
|
+
Cardinality,
|
|
22
|
+
DefinitionSource,
|
|
23
|
+
DependencyGraph,
|
|
24
|
+
InputSlot,
|
|
25
|
+
NormalizedLink,
|
|
26
|
+
OperationMap,
|
|
27
|
+
OperationNode,
|
|
28
|
+
OutputSlot,
|
|
29
|
+
ResourceDefinition,
|
|
30
|
+
ResourceMap,
|
|
31
|
+
)
|
|
32
|
+
from schemathesis.specs.openapi.stateful.dependencies.outputs import extract_outputs
|
|
33
|
+
from schemathesis.specs.openapi.stateful.dependencies.resources import remove_unused_resources
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from schemathesis.schemas import APIOperation
|
|
37
|
+
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"analyze",
|
|
41
|
+
"inject_links",
|
|
42
|
+
"DependencyGraph",
|
|
43
|
+
"InputSlot",
|
|
44
|
+
"OutputSlot",
|
|
45
|
+
"Cardinality",
|
|
46
|
+
"ResourceDefinition",
|
|
47
|
+
"DefinitionSource",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def analyze(schema: BaseOpenAPISchema) -> DependencyGraph:
|
|
52
|
+
"""Build a dependency graph by inferring resource producers and consumers from API operations."""
|
|
53
|
+
operations: OperationMap = {}
|
|
54
|
+
resources: ResourceMap = {}
|
|
55
|
+
# Track resources that got upgraded (e.g., from parameter inference to schema definition)
|
|
56
|
+
# to propagate better field information to existing input slots
|
|
57
|
+
updated_resources: set[str] = set()
|
|
58
|
+
# Cache for expensive canonicalize() calls - same schemas are often processed multiple times
|
|
59
|
+
canonicalization_cache: CanonicalizationCache = {}
|
|
60
|
+
|
|
61
|
+
for result in schema.get_all_operations():
|
|
62
|
+
if isinstance(result, Ok):
|
|
63
|
+
operation = result.ok()
|
|
64
|
+
try:
|
|
65
|
+
inputs = extract_inputs(
|
|
66
|
+
operation=operation,
|
|
67
|
+
resources=resources,
|
|
68
|
+
updated_resources=updated_resources,
|
|
69
|
+
resolver=schema.resolver,
|
|
70
|
+
canonicalization_cache=canonicalization_cache,
|
|
71
|
+
)
|
|
72
|
+
outputs = extract_outputs(
|
|
73
|
+
operation=operation,
|
|
74
|
+
resources=resources,
|
|
75
|
+
updated_resources=updated_resources,
|
|
76
|
+
resolver=schema.resolver,
|
|
77
|
+
canonicalization_cache=canonicalization_cache,
|
|
78
|
+
)
|
|
79
|
+
operations[operation.label] = OperationNode(
|
|
80
|
+
method=operation.method,
|
|
81
|
+
path=operation.path,
|
|
82
|
+
inputs=list(inputs),
|
|
83
|
+
outputs=list(outputs),
|
|
84
|
+
)
|
|
85
|
+
except RefResolutionError:
|
|
86
|
+
# Skip operations with unresolvable $refs (e.g., unavailable external references or references with typos)
|
|
87
|
+
# These won't participate in dependency detection
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
# Update input slots with improved resource definitions discovered during extraction
|
|
91
|
+
#
|
|
92
|
+
# Example:
|
|
93
|
+
# - `DELETE /users/{userId}` initially inferred `User.fields=["userId"]`
|
|
94
|
+
# - then `POST /users` response revealed `User.fields=["id", "email"]`
|
|
95
|
+
for resource in updated_resources:
|
|
96
|
+
update_input_field_bindings(resource, operations)
|
|
97
|
+
|
|
98
|
+
# Merge parameter-inferred resources with schema-defined ones
|
|
99
|
+
merge_related_resources(operations, resources)
|
|
100
|
+
|
|
101
|
+
# Clean up orphaned resources
|
|
102
|
+
remove_unused_resources(operations, resources)
|
|
103
|
+
|
|
104
|
+
return DependencyGraph(operations=operations, resources=resources)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def inject_links(schema: BaseOpenAPISchema) -> int:
|
|
108
|
+
graph = analyze(schema)
|
|
109
|
+
return _inject_links(schema, graph)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _inject_links(schema: BaseOpenAPISchema, graph: DependencyGraph) -> int:
|
|
113
|
+
injected = 0
|
|
114
|
+
for response_links in graph.iter_links():
|
|
115
|
+
operation = schema.find_operation_by_reference(response_links.producer_operation_ref)
|
|
116
|
+
response = operation.responses.get(response_links.status_code)
|
|
117
|
+
links = response.definition.setdefault(schema.adapter.links_keyword, {})
|
|
118
|
+
|
|
119
|
+
# Normalize existing links once
|
|
120
|
+
if links:
|
|
121
|
+
normalized_existing = [_normalize_link(link, schema) for link in links.values()]
|
|
122
|
+
else:
|
|
123
|
+
normalized_existing = []
|
|
124
|
+
|
|
125
|
+
for link_name, definition in response_links.links.items():
|
|
126
|
+
inferred_link = definition.to_openapi()
|
|
127
|
+
|
|
128
|
+
# Check if duplicate / subsets exists
|
|
129
|
+
if normalized_existing:
|
|
130
|
+
normalized = _normalize_link(inferred_link, schema)
|
|
131
|
+
if any(_is_subset_link(normalized, existing) for existing in normalized_existing):
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# Find unique name if collision exists
|
|
135
|
+
final_name = _resolve_link_name_collision(link_name, links)
|
|
136
|
+
links[final_name] = inferred_link
|
|
137
|
+
injected += 1
|
|
138
|
+
return injected
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _normalize_link(link: dict[str, Any], schema: BaseOpenAPISchema) -> NormalizedLink:
|
|
142
|
+
"""Normalize a link definition for comparison."""
|
|
143
|
+
operation = _resolve_link_operation(link, schema)
|
|
144
|
+
|
|
145
|
+
normalized_params = _normalize_parameter_keys(link.get("parameters", {}), operation)
|
|
146
|
+
|
|
147
|
+
return NormalizedLink(
|
|
148
|
+
path=operation.path,
|
|
149
|
+
method=operation.method,
|
|
150
|
+
parameters=normalized_params,
|
|
151
|
+
request_body=link.get("requestBody", {}),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _normalize_parameter_keys(parameters: dict, operation: APIOperation) -> set[str]:
|
|
156
|
+
"""Normalize parameter keys to location.name format."""
|
|
157
|
+
normalized = set()
|
|
158
|
+
|
|
159
|
+
for parameter_name in parameters.keys():
|
|
160
|
+
# If already has location prefix, use as-is
|
|
161
|
+
if "." in parameter_name:
|
|
162
|
+
normalized.add(parameter_name)
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# Find the parameter and prepend location
|
|
166
|
+
for parameter in operation.iter_parameters():
|
|
167
|
+
if parameter.name == parameter_name:
|
|
168
|
+
normalized.add(f"{parameter.location.value}.{parameter_name}")
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
return normalized
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _resolve_link_operation(link: dict, schema: BaseOpenAPISchema) -> APIOperation:
|
|
175
|
+
"""Resolve link to operation."""
|
|
176
|
+
if "operationRef" in link:
|
|
177
|
+
return schema.find_operation_by_reference(link["operationRef"])
|
|
178
|
+
if "operationId" in link:
|
|
179
|
+
return schema.find_operation_by_id(link["operationId"])
|
|
180
|
+
raise InvalidSchema(
|
|
181
|
+
"Link definition is missing both 'operationRef' and 'operationId'. "
|
|
182
|
+
"At least one of these fields must be present to identify the target operation."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _resolve_link_name_collision(proposed_name: str, existing_links: dict[str, Any]) -> str:
|
|
187
|
+
"""Find unique link name if collision exists."""
|
|
188
|
+
if proposed_name not in existing_links:
|
|
189
|
+
return proposed_name
|
|
190
|
+
|
|
191
|
+
suffix = 0
|
|
192
|
+
while True:
|
|
193
|
+
candidate = f"{proposed_name}_{suffix}"
|
|
194
|
+
if candidate not in existing_links:
|
|
195
|
+
return candidate
|
|
196
|
+
suffix += 1
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _is_subset_link(inferred: NormalizedLink, existing: NormalizedLink) -> bool:
|
|
200
|
+
"""Check if inferred link is a subset of existing link."""
|
|
201
|
+
# Must target the same operation
|
|
202
|
+
if inferred.path != existing.path or inferred.method != existing.method:
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
# Inferred parameters must be subset of existing parameters
|
|
206
|
+
if not inferred.parameters.issubset(existing.parameters):
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
# Inferred request body must be subset of existing body
|
|
210
|
+
return _is_request_body_subset(inferred.request_body, existing.request_body)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _is_request_body_subset(inferred_body: Any, existing_body: Any) -> bool:
|
|
214
|
+
"""Check if inferred body is a subset of existing body."""
|
|
215
|
+
# Empty inferred body is always a subset
|
|
216
|
+
if not inferred_body:
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
# If existing is empty but inferred isn't, not a subset
|
|
220
|
+
if not existing_body:
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
# Both must be dicts for subset comparison, otherwise check for equality
|
|
224
|
+
if not isinstance(inferred_body, dict) or not isinstance(existing_body, dict):
|
|
225
|
+
return inferred_body == existing_body
|
|
226
|
+
|
|
227
|
+
# Check if all inferred fields exist in existing with same values
|
|
228
|
+
for key, value in inferred_body.items():
|
|
229
|
+
if existing_body.get(key, NOT_SET) != value:
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
return True
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Iterator
|
|
4
|
+
|
|
5
|
+
from schemathesis.core import media_types
|
|
6
|
+
from schemathesis.core.errors import MalformedMediaType
|
|
7
|
+
from schemathesis.core.jsonschema.bundler import BUNDLE_STORAGE_KEY
|
|
8
|
+
from schemathesis.core.jsonschema.types import get_type
|
|
9
|
+
from schemathesis.core.parameters import ParameterLocation
|
|
10
|
+
from schemathesis.specs.openapi.adapter.parameters import resource_name_from_ref
|
|
11
|
+
from schemathesis.specs.openapi.stateful.dependencies import naming
|
|
12
|
+
from schemathesis.specs.openapi.stateful.dependencies.models import (
|
|
13
|
+
CanonicalizationCache,
|
|
14
|
+
DefinitionSource,
|
|
15
|
+
InputSlot,
|
|
16
|
+
OperationMap,
|
|
17
|
+
OutputSlot,
|
|
18
|
+
ResourceDefinition,
|
|
19
|
+
ResourceMap,
|
|
20
|
+
)
|
|
21
|
+
from schemathesis.specs.openapi.stateful.dependencies.resources import extract_resources_from_responses
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from schemathesis.core.compat import RefResolver
|
|
25
|
+
from schemathesis.specs.openapi.adapter.parameters import OpenApiBody
|
|
26
|
+
from schemathesis.specs.openapi.schemas import APIOperation
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract_inputs(
|
|
30
|
+
*,
|
|
31
|
+
operation: APIOperation,
|
|
32
|
+
resources: ResourceMap,
|
|
33
|
+
updated_resources: set[str],
|
|
34
|
+
resolver: RefResolver,
|
|
35
|
+
canonicalization_cache: CanonicalizationCache,
|
|
36
|
+
) -> Iterator[InputSlot]:
|
|
37
|
+
"""Extract resource dependencies for an API operation from its input parameters.
|
|
38
|
+
|
|
39
|
+
Connects each parameter (e.g., `userId`) to its resource definition (`User`),
|
|
40
|
+
creating placeholder resources if not yet discovered from their schemas.
|
|
41
|
+
"""
|
|
42
|
+
known_dependencies = set()
|
|
43
|
+
for param in operation.iter_parameters():
|
|
44
|
+
input_slot = _resolve_parameter_dependency(
|
|
45
|
+
parameter_name=param.name,
|
|
46
|
+
parameter_location=param.location,
|
|
47
|
+
operation=operation,
|
|
48
|
+
resources=resources,
|
|
49
|
+
updated_resources=updated_resources,
|
|
50
|
+
resolver=resolver,
|
|
51
|
+
canonicalization_cache=canonicalization_cache,
|
|
52
|
+
)
|
|
53
|
+
if input_slot is not None:
|
|
54
|
+
if input_slot.resource.source >= DefinitionSource.SCHEMA_WITH_PROPERTIES:
|
|
55
|
+
known_dependencies.add(input_slot.resource.name)
|
|
56
|
+
yield input_slot
|
|
57
|
+
|
|
58
|
+
for body in operation.body:
|
|
59
|
+
try:
|
|
60
|
+
if media_types.is_json(body.media_type):
|
|
61
|
+
yield from _resolve_body_dependencies(
|
|
62
|
+
body=body, operation=operation, resources=resources, known_dependencies=known_dependencies
|
|
63
|
+
)
|
|
64
|
+
except MalformedMediaType:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolve_parameter_dependency(
|
|
69
|
+
*,
|
|
70
|
+
parameter_name: str,
|
|
71
|
+
parameter_location: ParameterLocation,
|
|
72
|
+
operation: APIOperation,
|
|
73
|
+
resources: ResourceMap,
|
|
74
|
+
updated_resources: set[str],
|
|
75
|
+
resolver: RefResolver,
|
|
76
|
+
canonicalization_cache: CanonicalizationCache,
|
|
77
|
+
) -> InputSlot | None:
|
|
78
|
+
"""Connect a parameter to its resource definition, creating placeholder if needed.
|
|
79
|
+
|
|
80
|
+
Strategy:
|
|
81
|
+
1. Infer resource name from parameter (`userId` -> `User`)
|
|
82
|
+
2. Use existing resource if high-quality definition exists
|
|
83
|
+
3. Try discovering from operation's response schemas
|
|
84
|
+
4. Fall back to creating placeholder with a single field
|
|
85
|
+
"""
|
|
86
|
+
resource_name = naming.from_parameter(parameter=parameter_name, path=operation.path)
|
|
87
|
+
|
|
88
|
+
if resource_name is None:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
resource = resources.get(resource_name)
|
|
92
|
+
|
|
93
|
+
# Upgrade low-quality resource definitions (e.g., from parameter inference)
|
|
94
|
+
# by searching this operation's responses for actual schema
|
|
95
|
+
if resource is None or resource.source < DefinitionSource.SCHEMA_WITH_PROPERTIES:
|
|
96
|
+
resource = _find_resource_in_responses(
|
|
97
|
+
operation=operation,
|
|
98
|
+
resource_name=resource_name,
|
|
99
|
+
resources=resources,
|
|
100
|
+
updated_resources=updated_resources,
|
|
101
|
+
resolver=resolver,
|
|
102
|
+
canonicalization_cache=canonicalization_cache,
|
|
103
|
+
)
|
|
104
|
+
if resource is not None:
|
|
105
|
+
resources[resource_name] = resource
|
|
106
|
+
|
|
107
|
+
# Determine resource and its field
|
|
108
|
+
if resource is None:
|
|
109
|
+
# No schema found - create placeholder resource with inferred field
|
|
110
|
+
#
|
|
111
|
+
# Example: `DELETE /users/{userId}` with no response body -> `User` resource with "userId" field
|
|
112
|
+
#
|
|
113
|
+
# Later operations with schemas will upgrade this placeholder
|
|
114
|
+
if resource_name in resources:
|
|
115
|
+
# Resource exists but was empty - update with parameter field
|
|
116
|
+
resources[resource_name].fields = [parameter_name]
|
|
117
|
+
resources[resource_name].source = DefinitionSource.PARAMETER_INFERENCE
|
|
118
|
+
updated_resources.add(resource_name)
|
|
119
|
+
resource = resources[resource_name]
|
|
120
|
+
else:
|
|
121
|
+
resource = ResourceDefinition.inferred_from_parameter(
|
|
122
|
+
name=resource_name,
|
|
123
|
+
parameter_name=parameter_name,
|
|
124
|
+
)
|
|
125
|
+
resources[resource_name] = resource
|
|
126
|
+
field = parameter_name
|
|
127
|
+
else:
|
|
128
|
+
# Match parameter to resource field (`userId` → `id`, `Id` → `ChannelId`, etc.)
|
|
129
|
+
field = (
|
|
130
|
+
naming.find_matching_field(
|
|
131
|
+
parameter=parameter_name,
|
|
132
|
+
resource=resource_name,
|
|
133
|
+
fields=resource.fields,
|
|
134
|
+
)
|
|
135
|
+
or "id"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return InputSlot(
|
|
139
|
+
resource=resource,
|
|
140
|
+
resource_field=field,
|
|
141
|
+
parameter_name=parameter_name,
|
|
142
|
+
parameter_location=parameter_location,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _find_resource_in_responses(
|
|
147
|
+
*,
|
|
148
|
+
operation: APIOperation,
|
|
149
|
+
resource_name: str,
|
|
150
|
+
resources: ResourceMap,
|
|
151
|
+
updated_resources: set[str],
|
|
152
|
+
resolver: RefResolver,
|
|
153
|
+
canonicalization_cache: CanonicalizationCache,
|
|
154
|
+
) -> ResourceDefinition | None:
|
|
155
|
+
"""Search operation's successful responses for a specific resource definition.
|
|
156
|
+
|
|
157
|
+
Used when a parameter references a resource not yet discovered. Scans this
|
|
158
|
+
operation's response schemas hoping to find the resource definition.
|
|
159
|
+
"""
|
|
160
|
+
for _, extracted in extract_resources_from_responses(
|
|
161
|
+
operation=operation,
|
|
162
|
+
resources=resources,
|
|
163
|
+
updated_resources=updated_resources,
|
|
164
|
+
resolver=resolver,
|
|
165
|
+
canonicalization_cache=canonicalization_cache,
|
|
166
|
+
):
|
|
167
|
+
if extracted.resource.name == resource_name:
|
|
168
|
+
return extracted.resource
|
|
169
|
+
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
GENERIC_FIELD_NAMES = frozenset(
|
|
174
|
+
{
|
|
175
|
+
"body",
|
|
176
|
+
"text",
|
|
177
|
+
"content",
|
|
178
|
+
"message",
|
|
179
|
+
"description",
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _maybe_resolve_bundled(root: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
|
|
185
|
+
# Right now, the body schema comes bundled to dependency analysis
|
|
186
|
+
if BUNDLE_STORAGE_KEY in root and "$ref" in schema:
|
|
187
|
+
key = schema["$ref"].split("/")[-1]
|
|
188
|
+
return root[BUNDLE_STORAGE_KEY][key]
|
|
189
|
+
return schema
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _resolve_body_dependencies(
|
|
193
|
+
*,
|
|
194
|
+
body: OpenApiBody,
|
|
195
|
+
operation: APIOperation,
|
|
196
|
+
resources: ResourceMap,
|
|
197
|
+
known_dependencies: set[str],
|
|
198
|
+
) -> Iterator[InputSlot]:
|
|
199
|
+
schema = body.raw_schema
|
|
200
|
+
if not isinstance(schema, dict):
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
resolved = _maybe_resolve_bundled(schema, schema)
|
|
204
|
+
|
|
205
|
+
# For `items`, we'll inject an array with extracted resource
|
|
206
|
+
items = resolved.get("items", {})
|
|
207
|
+
if items is not None:
|
|
208
|
+
resource_name = naming.from_path(operation.path)
|
|
209
|
+
|
|
210
|
+
if "$ref" in items:
|
|
211
|
+
schema_key = items["$ref"].split("/")[-1]
|
|
212
|
+
original_ref = body.name_to_uri[schema_key]
|
|
213
|
+
resource_name = resource_name_from_ref(original_ref)
|
|
214
|
+
resource = resources.get(resource_name)
|
|
215
|
+
if resource is None:
|
|
216
|
+
resource = ResourceDefinition.inferred_from_parameter(name=resource_name, parameter_name=None)
|
|
217
|
+
resources[resource_name] = resource
|
|
218
|
+
field = None
|
|
219
|
+
else:
|
|
220
|
+
field = None
|
|
221
|
+
yield InputSlot(
|
|
222
|
+
resource=resource,
|
|
223
|
+
resource_field=field,
|
|
224
|
+
parameter_name=0,
|
|
225
|
+
parameter_location=ParameterLocation.BODY,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Inspect each property that could be a part of some other resource
|
|
229
|
+
properties = resolved.get("properties", {})
|
|
230
|
+
required = resolved.get("required", [])
|
|
231
|
+
path = operation.path
|
|
232
|
+
for property_name, subschema in properties.items():
|
|
233
|
+
resource_name = naming.from_parameter(property_name, path)
|
|
234
|
+
if resource_name is not None:
|
|
235
|
+
resource = resources.get(resource_name)
|
|
236
|
+
if resource is None:
|
|
237
|
+
resource = ResourceDefinition.inferred_from_parameter(
|
|
238
|
+
name=resource_name,
|
|
239
|
+
parameter_name=property_name,
|
|
240
|
+
)
|
|
241
|
+
resources[resource_name] = resource
|
|
242
|
+
field = property_name
|
|
243
|
+
else:
|
|
244
|
+
field = (
|
|
245
|
+
naming.find_matching_field(
|
|
246
|
+
parameter=property_name,
|
|
247
|
+
resource=resource_name,
|
|
248
|
+
fields=resource.fields,
|
|
249
|
+
)
|
|
250
|
+
or "id"
|
|
251
|
+
)
|
|
252
|
+
yield InputSlot(
|
|
253
|
+
resource=resource,
|
|
254
|
+
resource_field=field,
|
|
255
|
+
parameter_name=property_name,
|
|
256
|
+
parameter_location=ParameterLocation.BODY,
|
|
257
|
+
)
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# Skip generic property names & optional fields (at least for now)
|
|
261
|
+
if property_name in GENERIC_FIELD_NAMES or property_name not in required:
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
# Find candidate resources among known dependencies that actually have this field
|
|
265
|
+
candidates = [
|
|
266
|
+
resources[dep] for dep in known_dependencies if dep in resources and property_name in resources[dep].fields
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
# Skip ambiguous cases when multiple resources have same field name
|
|
270
|
+
if len(candidates) != 1:
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
resource = candidates[0]
|
|
274
|
+
# Ensure the target field supports the same type
|
|
275
|
+
if not resource.types[property_name] & set(get_type(subschema)):
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
yield InputSlot(
|
|
279
|
+
resource=resource,
|
|
280
|
+
resource_field=property_name,
|
|
281
|
+
parameter_name=property_name,
|
|
282
|
+
parameter_location=ParameterLocation.BODY,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def update_input_field_bindings(resource_name: str, operations: OperationMap) -> None:
|
|
287
|
+
"""Update input slots field bindings after resource definition was upgraded.
|
|
288
|
+
|
|
289
|
+
When a resource's fields change (e.g., `User` upgraded from `["userId"]` to `["id", "email"]`),
|
|
290
|
+
existing input slots may reference stale field names. This re-evaluates field matching
|
|
291
|
+
for all operations using this resource.
|
|
292
|
+
|
|
293
|
+
Example:
|
|
294
|
+
`DELETE /users/{userId}` created `InputSlot(resource_field="userId")`
|
|
295
|
+
`POST /users` revealed actual fields `["id", "email"]`
|
|
296
|
+
This updates DELETE's `InputSlot` to use `resource_field="id"`
|
|
297
|
+
|
|
298
|
+
"""
|
|
299
|
+
# Re-evaluate field matching for all operations referencing this resource
|
|
300
|
+
for operation in operations.values():
|
|
301
|
+
for input_slot in operation.inputs:
|
|
302
|
+
# Skip inputs not using this resource
|
|
303
|
+
if input_slot.resource.name != resource_name or isinstance(input_slot.parameter_name, int):
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
# Re-match parameter to upgraded resource fields
|
|
307
|
+
new_field = naming.find_matching_field(
|
|
308
|
+
parameter=input_slot.parameter_name,
|
|
309
|
+
resource=resource_name,
|
|
310
|
+
fields=input_slot.resource.fields,
|
|
311
|
+
)
|
|
312
|
+
if new_field is not None:
|
|
313
|
+
input_slot.resource_field = new_field
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def merge_related_resources(operations: OperationMap, resources: ResourceMap) -> None:
|
|
317
|
+
"""Merge parameter-inferred resources with schema-defined resources from related operations."""
|
|
318
|
+
candidates = find_producer_consumer_candidates(operations)
|
|
319
|
+
|
|
320
|
+
for producer_name, consumer_name in candidates:
|
|
321
|
+
producer = operations[producer_name]
|
|
322
|
+
consumer = operations[consumer_name]
|
|
323
|
+
|
|
324
|
+
# Try to upgrade each input slot
|
|
325
|
+
for input_slot in consumer.inputs:
|
|
326
|
+
result = try_merge_input_resource(input_slot, producer.outputs, resources)
|
|
327
|
+
|
|
328
|
+
if result is not None:
|
|
329
|
+
new_resource_name, new_field_name = result
|
|
330
|
+
# Update input slot to use the better resource definition
|
|
331
|
+
input_slot.resource = resources[new_resource_name]
|
|
332
|
+
input_slot.resource_field = new_field_name
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def try_merge_input_resource(
|
|
336
|
+
input_slot: InputSlot,
|
|
337
|
+
producer_outputs: list[OutputSlot],
|
|
338
|
+
resources: ResourceMap,
|
|
339
|
+
) -> tuple[str, str] | None:
|
|
340
|
+
"""Try to upgrade an input's resource to a producer's resource."""
|
|
341
|
+
consumer_resource = input_slot.resource
|
|
342
|
+
|
|
343
|
+
# Only upgrade parameter-inferred resources (low confidence)
|
|
344
|
+
if consumer_resource.source != DefinitionSource.PARAMETER_INFERENCE:
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
# Try each producer output
|
|
348
|
+
for output in producer_outputs:
|
|
349
|
+
producer_resource = resources[output.resource.name]
|
|
350
|
+
|
|
351
|
+
# Only merge to schema-defined resources (high confidence)
|
|
352
|
+
if producer_resource.source != DefinitionSource.SCHEMA_WITH_PROPERTIES:
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
# Try to match the input parameter to producer's fields
|
|
356
|
+
param_name = input_slot.parameter_name
|
|
357
|
+
if not isinstance(param_name, str):
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
for resource_name in (input_slot.resource.name, producer_resource.name):
|
|
361
|
+
matched_field = naming.find_matching_field(
|
|
362
|
+
parameter=param_name,
|
|
363
|
+
resource=resource_name,
|
|
364
|
+
fields=producer_resource.fields,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
if matched_field is not None:
|
|
368
|
+
return (producer_resource.name, matched_field)
|
|
369
|
+
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def find_producer_consumer_candidates(operations: OperationMap) -> list[tuple[str, str]]:
|
|
374
|
+
"""Find operation pairs that might produce/consume the same resource via REST patterns."""
|
|
375
|
+
candidates = []
|
|
376
|
+
|
|
377
|
+
# Group by base path to reduce comparisons
|
|
378
|
+
paths: dict[str, list[str]] = {}
|
|
379
|
+
for name, node in operations.items():
|
|
380
|
+
base = _extract_base_path(node.path)
|
|
381
|
+
paths.setdefault(base, []).append(name)
|
|
382
|
+
|
|
383
|
+
# Within each path group, find POST/PUT → GET/DELETE/PATCH patterns
|
|
384
|
+
for names in paths.values():
|
|
385
|
+
for producer_name in names:
|
|
386
|
+
producer = operations[producer_name]
|
|
387
|
+
# Producer must create/update and return data
|
|
388
|
+
if producer.method not in ("post", "put") or not producer.outputs:
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
for consumer_name in names:
|
|
392
|
+
consumer = operations[consumer_name]
|
|
393
|
+
# Consumer must have path parameters
|
|
394
|
+
if not consumer.inputs:
|
|
395
|
+
continue
|
|
396
|
+
# Paths must be related (collection + item pattern)
|
|
397
|
+
if _is_collection_item_pattern(producer.path, consumer.path):
|
|
398
|
+
candidates.append((producer_name, consumer_name))
|
|
399
|
+
|
|
400
|
+
return candidates
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _extract_base_path(path: str) -> str:
|
|
404
|
+
"""Extract collection path: /blog/posts/{id} -> /blog/posts."""
|
|
405
|
+
parts = [p for p in path.split("/") if not p.startswith("{")]
|
|
406
|
+
return "/".join(parts).rstrip("/")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _is_collection_item_pattern(collection_path: str, item_path: str) -> bool:
|
|
410
|
+
"""Check if paths follow REST collection/item pattern."""
|
|
411
|
+
# /blog/posts + /blog/posts/{postId}
|
|
412
|
+
normalized_collection = collection_path.rstrip("/")
|
|
413
|
+
normalized_item = item_path.rstrip("/")
|
|
414
|
+
|
|
415
|
+
# Must start with collection path
|
|
416
|
+
if not normalized_item.startswith(normalized_collection + "/"):
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
# Extract the segment after collection path
|
|
420
|
+
remainder = normalized_item[len(normalized_collection) + 1 :]
|
|
421
|
+
|
|
422
|
+
# Must be a single path parameter: {paramName} with no slashes
|
|
423
|
+
return (
|
|
424
|
+
remainder.startswith("{")
|
|
425
|
+
and remainder.endswith("}")
|
|
426
|
+
and len(remainder) > 2 # Not empty {}
|
|
427
|
+
and "/" not in remainder
|
|
428
|
+
)
|