schemathesis 4.2.2__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 +3 -2
- 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/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.2.dist-info → schemathesis-4.3.0.dist-info}/METADATA +1 -1
- {schemathesis-4.2.2.dist-info → schemathesis-4.3.0.dist-info}/RECORD +26 -19
- {schemathesis-4.2.2.dist-info → schemathesis-4.3.0.dist-info}/WHEEL +0 -0
- {schemathesis-4.2.2.dist-info → schemathesis-4.3.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.2.2.dist-info → schemathesis-4.3.0.dist-info}/licenses/LICENSE +0 -0
schemathesis/config/__init__.py
CHANGED
@@ -19,7 +19,13 @@ from schemathesis.config._error import ConfigError
|
|
19
19
|
from schemathesis.config._generation import GenerationConfig
|
20
20
|
from schemathesis.config._health_check import HealthCheck
|
21
21
|
from schemathesis.config._output import OutputConfig, SanitizationConfig, TruncationConfig
|
22
|
-
from schemathesis.config._phases import
|
22
|
+
from schemathesis.config._phases import (
|
23
|
+
CoveragePhaseConfig,
|
24
|
+
InferenceAlgorithm,
|
25
|
+
PhaseConfig,
|
26
|
+
PhasesConfig,
|
27
|
+
StatefulPhaseConfig,
|
28
|
+
)
|
23
29
|
from schemathesis.config._projects import ProjectConfig, ProjectsConfig, SchemathesisWarning, get_workers_count
|
24
30
|
from schemathesis.config._report import DEFAULT_REPORT_DIRECTORY, ReportConfig, ReportFormat, ReportsConfig
|
25
31
|
|
@@ -44,6 +50,7 @@ __all__ = [
|
|
44
50
|
"PhasesConfig",
|
45
51
|
"CoveragePhaseConfig",
|
46
52
|
"StatefulPhaseConfig",
|
53
|
+
"InferenceAlgorithm",
|
47
54
|
"ProjectsConfig",
|
48
55
|
"ProjectConfig",
|
49
56
|
"get_workers_count",
|
schemathesis/config/_phases.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
from dataclasses import dataclass
|
4
|
+
from enum import Enum
|
4
5
|
from typing import Any
|
5
6
|
|
6
7
|
from schemathesis.config._checks import ChecksConfig
|
@@ -125,9 +126,14 @@ class CoveragePhaseConfig(DiffBase):
|
|
125
126
|
)
|
126
127
|
|
127
128
|
|
129
|
+
class InferenceAlgorithm(str, Enum):
|
130
|
+
LOCATION_HEADERS = "location-headers"
|
131
|
+
DEPENDENCY_ANALYSIS = "dependency-analysis"
|
132
|
+
|
133
|
+
|
128
134
|
@dataclass(repr=False)
|
129
135
|
class InferenceConfig(DiffBase):
|
130
|
-
algorithms: list[
|
136
|
+
algorithms: list[InferenceAlgorithm]
|
131
137
|
|
132
138
|
__slots__ = ("algorithms",)
|
133
139
|
|
@@ -136,12 +142,14 @@ class InferenceConfig(DiffBase):
|
|
136
142
|
*,
|
137
143
|
algorithms: list[str] | None = None,
|
138
144
|
) -> None:
|
139
|
-
self.algorithms =
|
145
|
+
self.algorithms = (
|
146
|
+
[InferenceAlgorithm(a) for a in algorithms] if algorithms is not None else list(InferenceAlgorithm)
|
147
|
+
)
|
140
148
|
|
141
149
|
@classmethod
|
142
150
|
def from_dict(cls, data: dict[str, Any]) -> InferenceConfig:
|
143
151
|
return cls(
|
144
|
-
algorithms=data.get("algorithms",
|
152
|
+
algorithms=data.get("algorithms", list(InferenceAlgorithm)),
|
145
153
|
)
|
146
154
|
|
147
155
|
@property
|
@@ -149,6 +157,9 @@ class InferenceConfig(DiffBase):
|
|
149
157
|
"""Inference is enabled if any algorithms are configured."""
|
150
158
|
return bool(self.algorithms)
|
151
159
|
|
160
|
+
def is_algorithm_enabled(self, algorithm: InferenceAlgorithm) -> bool:
|
161
|
+
return algorithm in self.algorithms
|
162
|
+
|
152
163
|
|
153
164
|
@dataclass(repr=False)
|
154
165
|
class StatefulPhaseConfig(DiffBase):
|
schemathesis/config/schema.json
CHANGED
@@ -97,8 +97,9 @@ class Bundler:
|
|
97
97
|
|
98
98
|
result = {key: _bundle_recursive(value) for key, value in current.items() if key != "$ref"}
|
99
99
|
# Recursive references need `$ref` to be in them, which is only possible with `dict`
|
100
|
-
|
101
|
-
|
100
|
+
bundled_clone = _bundle_recursive(cloned)
|
101
|
+
assert isinstance(bundled_clone, dict)
|
102
|
+
result.update(bundled_clone)
|
102
103
|
return result
|
103
104
|
elif resolved_uri not in visited:
|
104
105
|
# Bundle only new schemas
|
schemathesis/core/transforms.py
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
from typing import Any, Callable, Dict, List, Mapping, TypeVar, Union, overload
|
3
|
+
from typing import Any, Callable, Dict, Iterator, List, Mapping, TypeVar, Union, overload
|
4
4
|
|
5
5
|
T = TypeVar("T")
|
6
6
|
|
@@ -106,6 +106,18 @@ class Unresolvable: ...
|
|
106
106
|
UNRESOLVABLE = Unresolvable()
|
107
107
|
|
108
108
|
|
109
|
+
def encode_pointer(pointer: str) -> str:
|
110
|
+
return pointer.replace("~", "~0").replace("/", "~1")
|
111
|
+
|
112
|
+
|
113
|
+
def decode_pointer(value: str) -> str:
|
114
|
+
return value.replace("~1", "/").replace("~0", "~")
|
115
|
+
|
116
|
+
|
117
|
+
def iter_decoded_pointer_segments(pointer: str) -> Iterator[str]:
|
118
|
+
return map(decode_pointer, pointer.split("/")[1:])
|
119
|
+
|
120
|
+
|
109
121
|
def resolve_pointer(document: Any, pointer: str) -> dict | list | str | int | float | None | Unresolvable:
|
110
122
|
"""Implementation is adapted from Rust's `serde-json` crate.
|
111
123
|
|
@@ -116,12 +128,8 @@ def resolve_pointer(document: Any, pointer: str) -> dict | list | str | int | fl
|
|
116
128
|
if not pointer.startswith("/"):
|
117
129
|
return UNRESOLVABLE
|
118
130
|
|
119
|
-
def replace(value: str) -> str:
|
120
|
-
return value.replace("~1", "/").replace("~0", "~")
|
121
|
-
|
122
|
-
tokens = map(replace, pointer.split("/")[1:])
|
123
131
|
target = document
|
124
|
-
for token in
|
132
|
+
for token in iter_decoded_pointer_segments(pointer):
|
125
133
|
if isinstance(target, dict):
|
126
134
|
target = target.get(token, UNRESOLVABLE)
|
127
135
|
if target is UNRESOLVABLE:
|
schemathesis/engine/context.py
CHANGED
@@ -4,12 +4,13 @@ import time
|
|
4
4
|
from dataclasses import dataclass
|
5
5
|
from typing import TYPE_CHECKING, Any
|
6
6
|
|
7
|
-
from schemathesis.config import ProjectConfig
|
7
|
+
from schemathesis.config import InferenceAlgorithm, ProjectConfig
|
8
8
|
from schemathesis.core import NOT_SET, NotSet
|
9
9
|
from schemathesis.engine.control import ExecutionControl
|
10
10
|
from schemathesis.engine.observations import Observations
|
11
11
|
from schemathesis.generation.case import Case
|
12
12
|
from schemathesis.schemas import APIOperation, BaseSchema
|
13
|
+
from schemathesis.specs.openapi.stateful import dependencies
|
13
14
|
|
14
15
|
if TYPE_CHECKING:
|
15
16
|
import threading
|
@@ -85,9 +86,10 @@ class EngineContext:
|
|
85
86
|
|
86
87
|
def inject_links(self) -> int:
|
87
88
|
"""Inject inferred OpenAPI links into API operations based on collected observations."""
|
89
|
+
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
90
|
+
|
88
91
|
injected = 0
|
89
92
|
if self.observations is not None and self.observations.location_headers:
|
90
|
-
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
91
93
|
from schemathesis.specs.openapi.stateful.inference import LinkInferencer
|
92
94
|
|
93
95
|
assert isinstance(self.schema, BaseOpenAPISchema)
|
@@ -96,6 +98,24 @@ class EngineContext:
|
|
96
98
|
inferencer = LinkInferencer.from_schema(self.schema)
|
97
99
|
for operation, entries in self.observations.location_headers.items():
|
98
100
|
injected += inferencer.inject_links(operation.responses, entries)
|
101
|
+
if (
|
102
|
+
isinstance(self.schema, BaseOpenAPISchema)
|
103
|
+
and self.schema.config.phases.stateful.enabled
|
104
|
+
and self.schema.config.phases.stateful.inference.is_algorithm_enabled(
|
105
|
+
InferenceAlgorithm.DEPENDENCY_ANALYSIS
|
106
|
+
)
|
107
|
+
):
|
108
|
+
graph = dependencies.analyze(self.schema)
|
109
|
+
for response_links in graph.iter_links():
|
110
|
+
operation = self.schema.get_operation_by_reference(response_links.producer_operation_ref)
|
111
|
+
response = operation.responses.get(response_links.status_code)
|
112
|
+
links = response.definition.setdefault(self.schema.adapter.links_keyword, {})
|
113
|
+
|
114
|
+
for link_name, definition in response_links.links.items():
|
115
|
+
# Find unique name if collision exists
|
116
|
+
final_name = _resolve_link_name_collision(link_name, links)
|
117
|
+
links[final_name] = definition.to_openapi()
|
118
|
+
injected += 1
|
99
119
|
|
100
120
|
return injected
|
101
121
|
|
@@ -151,3 +171,16 @@ class EngineContext:
|
|
151
171
|
kwargs["proxies"] = {"all": proxy}
|
152
172
|
self._transport_kwargs_cache[key] = kwargs
|
153
173
|
return kwargs
|
174
|
+
|
175
|
+
|
176
|
+
def _resolve_link_name_collision(proposed_name: str, existing_links: dict[str, Any]) -> str:
|
177
|
+
if proposed_name not in existing_links:
|
178
|
+
return proposed_name
|
179
|
+
|
180
|
+
# Name collision - find next available suffix
|
181
|
+
suffix = 0
|
182
|
+
while True:
|
183
|
+
candidate = f"{proposed_name}_{suffix}"
|
184
|
+
if candidate not in existing_links:
|
185
|
+
return candidate
|
186
|
+
suffix += 1
|
@@ -90,7 +90,9 @@ def setup() -> None:
|
|
90
90
|
url, resolved = resolver.resolve(ref)
|
91
91
|
resolver.push_scope(url)
|
92
92
|
try:
|
93
|
-
return merged(
|
93
|
+
return merged(
|
94
|
+
[_resolve_all_refs(s, resolver=resolver), _resolve_all_refs(deepclone(resolved), resolver=resolver)]
|
95
|
+
) # type: ignore
|
94
96
|
finally:
|
95
97
|
resolver.pop_scope()
|
96
98
|
|
@@ -324,7 +324,7 @@ def iter_parameters_v2(
|
|
324
324
|
_, param = maybe_resolve(param, resolver, "")
|
325
325
|
if param.get("in") == ParameterLocation.BODY:
|
326
326
|
if "$ref" in param["schema"]:
|
327
|
-
resource_name =
|
327
|
+
resource_name = resource_name_from_ref(param["schema"]["$ref"])
|
328
328
|
for media_type in body_media_types:
|
329
329
|
yield OpenApiBody.from_definition(
|
330
330
|
definition=parameter,
|
@@ -375,7 +375,7 @@ def iter_parameters_v3(
|
|
375
375
|
if isinstance(schema, dict):
|
376
376
|
content = dict(content)
|
377
377
|
if "$ref" in schema:
|
378
|
-
resource_name =
|
378
|
+
resource_name = resource_name_from_ref(schema["$ref"])
|
379
379
|
try:
|
380
380
|
to_bundle = cast(dict[str, Any], schema)
|
381
381
|
bundled = bundler.bundle(to_bundle, resolver, inline_recursive=True)
|
@@ -391,7 +391,7 @@ def iter_parameters_v3(
|
|
391
391
|
)
|
392
392
|
|
393
393
|
|
394
|
-
def
|
394
|
+
def resource_name_from_ref(reference: str) -> str:
|
395
395
|
return reference.rsplit("/", maxsplit=1)[1]
|
396
396
|
|
397
397
|
|
@@ -10,6 +10,7 @@ if TYPE_CHECKING:
|
|
10
10
|
from schemathesis.core.jsonschema.types import JsonSchema
|
11
11
|
|
12
12
|
IterResponseExamples = Callable[[Mapping[str, Any], str], Iterator[tuple[str, object]]]
|
13
|
+
ExtractRawResponseSchema = Callable[[Mapping[str, Any]], Union["JsonSchema", None]]
|
13
14
|
ExtractResponseSchema = Callable[[Mapping[str, Any], "RefResolver", str, str], Union["JsonSchema", None]]
|
14
15
|
ExtractHeaderSchema = Callable[[Mapping[str, Any], "RefResolver", str, str], "JsonSchema"]
|
15
16
|
ExtractParameterSchema = Callable[[Mapping[str, Any]], "JsonSchema"]
|
@@ -41,6 +42,7 @@ class SpecificationAdapter(Protocol):
|
|
41
42
|
# Function to extract schema from parameter definition
|
42
43
|
extract_parameter_schema: ExtractParameterSchema
|
43
44
|
# Function to extract response schema from specification
|
45
|
+
extract_raw_response_schema: ExtractRawResponseSchema
|
44
46
|
extract_response_schema: ExtractResponseSchema
|
45
47
|
# Function to extract header schema from specification
|
46
48
|
extract_header_schema: ExtractHeaderSchema
|
@@ -48,6 +48,10 @@ class OpenApiResponse:
|
|
48
48
|
assert not isinstance(self._schema, NotSet)
|
49
49
|
return self._schema
|
50
50
|
|
51
|
+
def get_raw_schema(self) -> JsonSchema | None:
|
52
|
+
"""Raw and unresolved response schema."""
|
53
|
+
return self.adapter.extract_raw_response_schema(self.definition)
|
54
|
+
|
51
55
|
@property
|
52
56
|
def validator(self) -> Validator | None:
|
53
57
|
"""JSON Schema validator for this response."""
|
@@ -118,6 +122,9 @@ class OpenApiResponses:
|
|
118
122
|
def items(self) -> ItemsView[str, OpenApiResponse]:
|
119
123
|
return self._inner.items()
|
120
124
|
|
125
|
+
def get(self, key: str) -> OpenApiResponse | None:
|
126
|
+
return self._inner.get(key)
|
127
|
+
|
121
128
|
def add(self, status_code: str, definition: dict[str, Any]) -> OpenApiResponse:
|
122
129
|
instance = OpenApiResponse(
|
123
130
|
status_code=status_code,
|
@@ -153,12 +160,16 @@ class OpenApiResponses:
|
|
153
160
|
# The default response has the lowest priority
|
154
161
|
return responses.get("default")
|
155
162
|
|
156
|
-
def
|
157
|
-
"""Iterate over all
|
163
|
+
def iter_successful_responses(self) -> Iterator[OpenApiResponse]:
|
164
|
+
"""Iterate over all response definitions for successful responses."""
|
158
165
|
for response in self._inner.values():
|
159
|
-
# Check only 2xx responses
|
160
166
|
if response.status_code.startswith("2"):
|
161
|
-
yield
|
167
|
+
yield response
|
168
|
+
|
169
|
+
def iter_examples(self) -> Iterator[tuple[str, object]]:
|
170
|
+
"""Iterate over all examples for all responses."""
|
171
|
+
for response in self.iter_successful_responses():
|
172
|
+
yield from response.iter_examples()
|
162
173
|
|
163
174
|
|
164
175
|
def _iter_resolved_responses(
|
@@ -178,20 +189,31 @@ def _iter_resolved_responses(
|
|
178
189
|
def extract_response_schema_v2(
|
179
190
|
response: Mapping[str, Any], resolver: RefResolver, scope: str, nullable_keyword: str
|
180
191
|
) -> JsonSchema | None:
|
181
|
-
schema = response
|
192
|
+
schema = extract_raw_response_schema_v2(response)
|
182
193
|
if schema is not None:
|
183
194
|
return _prepare_schema(schema, resolver, scope, nullable_keyword)
|
184
195
|
return None
|
185
196
|
|
186
197
|
|
198
|
+
def extract_raw_response_schema_v2(response: Mapping[str, Any]) -> JsonSchema | None:
|
199
|
+
return response.get("schema")
|
200
|
+
|
201
|
+
|
187
202
|
def extract_response_schema_v3(
|
188
203
|
response: Mapping[str, Any], resolver: RefResolver, scope: str, nullable_keyword: str
|
189
204
|
) -> JsonSchema | None:
|
205
|
+
schema = extract_raw_response_schema_v3(response)
|
206
|
+
if schema is not None:
|
207
|
+
return _prepare_schema(schema, resolver, scope, nullable_keyword)
|
208
|
+
return None
|
209
|
+
|
210
|
+
|
211
|
+
def extract_raw_response_schema_v3(response: Mapping[str, Any]) -> JsonSchema | None:
|
190
212
|
options = iter(response.get("content", {}).values())
|
191
213
|
media_type = next(options, None)
|
192
214
|
# "schema" is an optional key in the `MediaType` object
|
193
|
-
if media_type
|
194
|
-
return
|
215
|
+
if media_type is not None:
|
216
|
+
return media_type.get("schema")
|
195
217
|
return None
|
196
218
|
|
197
219
|
|
@@ -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
|
@@ -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
|