schemathesis 4.2.2__py3-none-any.whl → 4.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemathesis/config/__init__.py +8 -1
- schemathesis/config/_phases.py +14 -3
- schemathesis/config/schema.json +2 -1
- schemathesis/core/jsonschema/bundler.py +3 -2
- schemathesis/core/transforms.py +14 -6
- schemathesis/engine/context.py +35 -2
- schemathesis/generation/hypothesis/__init__.py +3 -1
- schemathesis/generation/hypothesis/builder.py +10 -2
- schemathesis/openapi/checks.py +13 -1
- schemathesis/specs/openapi/adapter/parameters.py +3 -3
- schemathesis/specs/openapi/adapter/protocol.py +2 -0
- schemathesis/specs/openapi/adapter/responses.py +29 -7
- schemathesis/specs/openapi/adapter/v2.py +2 -0
- schemathesis/specs/openapi/adapter/v3_0.py +2 -0
- schemathesis/specs/openapi/adapter/v3_1.py +2 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +88 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +182 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +270 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +345 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +282 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +420 -0
- schemathesis/specs/openapi/stateful/inference.py +2 -1
- {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/METADATA +1 -1
- {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/RECORD +28 -21
- {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/WHEEL +0 -0
- {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,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
|
@@ -0,0 +1,270 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import difflib
|
4
|
+
import enum
|
5
|
+
from dataclasses import asdict, dataclass
|
6
|
+
from typing import Any, Iterator, Mapping
|
7
|
+
|
8
|
+
from typing_extensions import TypeAlias
|
9
|
+
|
10
|
+
from schemathesis.core.parameters import ParameterLocation
|
11
|
+
from schemathesis.core.transforms import encode_pointer
|
12
|
+
|
13
|
+
|
14
|
+
@dataclass
|
15
|
+
class DependencyGraph:
|
16
|
+
"""Graph of API operations and their resource dependencies."""
|
17
|
+
|
18
|
+
operations: OperationMap
|
19
|
+
resources: ResourceMap
|
20
|
+
|
21
|
+
__slots__ = ("operations", "resources")
|
22
|
+
|
23
|
+
def serialize(self) -> dict[str, Any]:
|
24
|
+
serialized = asdict(self)
|
25
|
+
|
26
|
+
for operation in serialized["operations"].values():
|
27
|
+
del operation["method"]
|
28
|
+
del operation["path"]
|
29
|
+
for input in operation["inputs"]:
|
30
|
+
input["resource"] = input["resource"]["name"]
|
31
|
+
for output in operation["outputs"]:
|
32
|
+
output["resource"] = output["resource"]["name"]
|
33
|
+
|
34
|
+
for resource in serialized["resources"].values():
|
35
|
+
del resource["name"]
|
36
|
+
del resource["source"]
|
37
|
+
|
38
|
+
return serialized
|
39
|
+
|
40
|
+
def iter_links(self) -> Iterator[ResponseLinks]:
|
41
|
+
"""Generate OpenAPI Links connecting producer and consumer operations.
|
42
|
+
|
43
|
+
Creates links from operations that produce resources to operations that
|
44
|
+
consume them. For example: `POST /users` (creates `User`) -> `GET /users/{id}`
|
45
|
+
(needs `User.id` parameter).
|
46
|
+
"""
|
47
|
+
# Connect each producer output to matching consumer inputs
|
48
|
+
for producer in self.operations.values():
|
49
|
+
producer_path = encode_pointer(producer.path)
|
50
|
+
for output_slot in producer.outputs:
|
51
|
+
for consumer in self.operations.values():
|
52
|
+
# Skip self-references
|
53
|
+
if producer is consumer:
|
54
|
+
continue
|
55
|
+
|
56
|
+
consumer_path = encode_pointer(consumer.path)
|
57
|
+
links: dict[str, LinkDefinition] = {}
|
58
|
+
for input_slot in consumer.inputs:
|
59
|
+
if input_slot.resource is output_slot.resource:
|
60
|
+
body_pointer = build_response_body_pointer(
|
61
|
+
output_slot.pointer, input_slot.resource_field, output_slot.cardinality
|
62
|
+
)
|
63
|
+
link_name = f"{consumer.method.capitalize()}{input_slot.resource.name}"
|
64
|
+
links[link_name] = LinkDefinition(
|
65
|
+
operation_ref=f"#/paths/{consumer_path}/{consumer.method}",
|
66
|
+
parameters={
|
67
|
+
# Data is extracted from response body
|
68
|
+
f"{input_slot.parameter_location.value}.{input_slot.parameter_name}": f"$response.body#{body_pointer}",
|
69
|
+
},
|
70
|
+
)
|
71
|
+
if links:
|
72
|
+
yield ResponseLinks(
|
73
|
+
producer_operation_ref=f"#/paths/{producer_path}/{producer.method}",
|
74
|
+
status_code=output_slot.status_code,
|
75
|
+
links=links,
|
76
|
+
)
|
77
|
+
|
78
|
+
def assert_fieldless_resources(self, key: str, known: dict[str, frozenset[str]]) -> None: # pragma: no cover
|
79
|
+
"""Verify all resources have at least one field."""
|
80
|
+
# Fieldless resources usually indicate failed schema extraction, which can be caused by a bug
|
81
|
+
known_fieldless = known.get(key, frozenset())
|
82
|
+
|
83
|
+
for name, resource in self.resources.items():
|
84
|
+
if not resource.fields and name not in known_fieldless:
|
85
|
+
raise AssertionError(f"Resource {name} has no fields")
|
86
|
+
|
87
|
+
def assert_incorrect_field_mappings(self, key: str, known: dict[str, frozenset[str]]) -> None:
|
88
|
+
"""Verify all input slots reference valid fields in their resources."""
|
89
|
+
known_mismatches = known.get(key, frozenset())
|
90
|
+
|
91
|
+
for operation in self.operations.values():
|
92
|
+
for input in operation.inputs:
|
93
|
+
# Skip unreliable definition sources
|
94
|
+
if input.resource.source < DefinitionSource.SCHEMA_WITH_PROPERTIES:
|
95
|
+
continue
|
96
|
+
resource = self.resources[input.resource.name]
|
97
|
+
if (
|
98
|
+
input.resource_field not in resource.fields and resource.name not in known_mismatches
|
99
|
+
): # pragma: no cover
|
100
|
+
message = (
|
101
|
+
f"Operation '{operation.method.upper()} {operation.path}': "
|
102
|
+
f"InputSlot references field '{input.resource_field}' "
|
103
|
+
f"not found in resource '{resource.name}'"
|
104
|
+
)
|
105
|
+
matches = difflib.get_close_matches(input.resource_field, resource.fields, n=1, cutoff=0.6)
|
106
|
+
if matches:
|
107
|
+
message += f". Closest field - `{matches[0]}`"
|
108
|
+
elif resource.fields:
|
109
|
+
message += f". Available fields - {', '.join(resource.fields)}"
|
110
|
+
else:
|
111
|
+
message += ". Resource has no fields"
|
112
|
+
raise AssertionError(message)
|
113
|
+
|
114
|
+
|
115
|
+
def build_response_body_pointer(pointer: str, field: str, cardinality: Cardinality) -> str:
|
116
|
+
if not pointer.endswith("/"):
|
117
|
+
pointer += "/"
|
118
|
+
if cardinality == Cardinality.MANY:
|
119
|
+
# For arrays, reference first element: /data → /data/0
|
120
|
+
pointer += "0/"
|
121
|
+
pointer += encode_pointer(field)
|
122
|
+
return pointer
|
123
|
+
|
124
|
+
|
125
|
+
@dataclass
|
126
|
+
class LinkDefinition:
|
127
|
+
"""OpenAPI Link Object definition.
|
128
|
+
|
129
|
+
Represents a single link from a producer operation's response to a
|
130
|
+
consumer operation's input parameter.
|
131
|
+
"""
|
132
|
+
|
133
|
+
operation_ref: str
|
134
|
+
"""Reference to target operation (e.g., '#/paths/~1users~1{id}/get')"""
|
135
|
+
|
136
|
+
parameters: dict[str, str]
|
137
|
+
"""Parameter mappings (e.g., {'path.id': '$response.body#/id'})"""
|
138
|
+
|
139
|
+
__slots__ = ("operation_ref", "parameters")
|
140
|
+
|
141
|
+
def to_openapi(self) -> dict[str, Any]:
|
142
|
+
"""Convert to OpenAPI Links format."""
|
143
|
+
return {
|
144
|
+
"operationRef": self.operation_ref,
|
145
|
+
"parameters": self.parameters,
|
146
|
+
}
|
147
|
+
|
148
|
+
|
149
|
+
@dataclass
|
150
|
+
class ResponseLinks:
|
151
|
+
"""Collection of OpenAPI Links for a producer operation's response.
|
152
|
+
|
153
|
+
Represents all links from a single response (e.g., POST /users -> 201)
|
154
|
+
to consumer operations that can use the produced resource.
|
155
|
+
|
156
|
+
Example:
|
157
|
+
POST /users -> 201 might have links to:
|
158
|
+
- GET /users/{id}
|
159
|
+
- PATCH /users/{id}
|
160
|
+
- DELETE /users/{id}
|
161
|
+
|
162
|
+
"""
|
163
|
+
|
164
|
+
producer_operation_ref: str
|
165
|
+
"""Reference to producer operation (e.g., '#/paths/~1users/post')"""
|
166
|
+
|
167
|
+
status_code: str
|
168
|
+
"""Response status code (e.g., '201', '200', 'default')"""
|
169
|
+
|
170
|
+
links: dict[str, LinkDefinition]
|
171
|
+
"""Named links (e.g., {'GetUserById': LinkDefinition(...)})"""
|
172
|
+
|
173
|
+
__slots__ = ("producer_operation_ref", "status_code", "links")
|
174
|
+
|
175
|
+
def to_openapi(self) -> dict[str, Any]:
|
176
|
+
"""Convert to OpenAPI response links format."""
|
177
|
+
return {name: link_def.to_openapi() for name, link_def in self.links.items()}
|
178
|
+
|
179
|
+
|
180
|
+
class Cardinality(str, enum.Enum):
|
181
|
+
"""Whether there is one or many resources in a slot."""
|
182
|
+
|
183
|
+
ONE = "ONE"
|
184
|
+
MANY = "MANY"
|
185
|
+
|
186
|
+
|
187
|
+
@dataclass
|
188
|
+
class OperationNode:
|
189
|
+
"""An API operation with its input/output dependencies."""
|
190
|
+
|
191
|
+
method: str
|
192
|
+
path: str
|
193
|
+
# What this operation NEEDS
|
194
|
+
inputs: list[InputSlot]
|
195
|
+
# What this operation PRODUCES
|
196
|
+
outputs: list[OutputSlot]
|
197
|
+
|
198
|
+
__slots__ = ("method", "path", "inputs", "outputs")
|
199
|
+
|
200
|
+
|
201
|
+
@dataclass
|
202
|
+
class InputSlot:
|
203
|
+
"""A required input for an operation."""
|
204
|
+
|
205
|
+
# Which resource is needed
|
206
|
+
resource: ResourceDefinition
|
207
|
+
# Which field from that resource (e.g., "id")
|
208
|
+
resource_field: str
|
209
|
+
# Where it goes in the request (e.g., "userId")
|
210
|
+
parameter_name: str
|
211
|
+
parameter_location: ParameterLocation
|
212
|
+
|
213
|
+
__slots__ = ("resource", "resource_field", "parameter_name", "parameter_location")
|
214
|
+
|
215
|
+
|
216
|
+
@dataclass
|
217
|
+
class OutputSlot:
|
218
|
+
"""Describes how to extract a resource from an operation's response."""
|
219
|
+
|
220
|
+
# Which resource type
|
221
|
+
resource: ResourceDefinition
|
222
|
+
# Where in response body (JSON pointer)
|
223
|
+
pointer: str
|
224
|
+
# Is this a single resource or an array?
|
225
|
+
cardinality: Cardinality
|
226
|
+
# HTTP status code
|
227
|
+
status_code: str
|
228
|
+
|
229
|
+
__slots__ = ("resource", "pointer", "cardinality", "status_code")
|
230
|
+
|
231
|
+
|
232
|
+
@dataclass
|
233
|
+
class ResourceDefinition:
|
234
|
+
"""A minimal description of a resource structure."""
|
235
|
+
|
236
|
+
name: str
|
237
|
+
# A sorted list of resource fields
|
238
|
+
fields: list[str]
|
239
|
+
# How this resource was created
|
240
|
+
source: DefinitionSource
|
241
|
+
|
242
|
+
__slots__ = ("name", "fields", "source")
|
243
|
+
|
244
|
+
@classmethod
|
245
|
+
def without_properties(cls, name: str) -> ResourceDefinition:
|
246
|
+
return cls(name=name, fields=[], source=DefinitionSource.SCHEMA_WITHOUT_PROPERTIES)
|
247
|
+
|
248
|
+
@classmethod
|
249
|
+
def inferred_from_parameter(cls, name: str, parameter_name: str) -> ResourceDefinition:
|
250
|
+
return cls(name=name, fields=[parameter_name], source=DefinitionSource.PARAMETER_INFERENCE)
|
251
|
+
|
252
|
+
|
253
|
+
class DefinitionSource(enum.IntEnum):
|
254
|
+
"""Quality level of resource information.
|
255
|
+
|
256
|
+
Lower values are less reliable and should be replaced by higher values.
|
257
|
+
Same values should be merged (union of fields).
|
258
|
+
"""
|
259
|
+
|
260
|
+
# From spec but no structural information
|
261
|
+
SCHEMA_WITHOUT_PROPERTIES = 0
|
262
|
+
# Guessed from parameter names (not in spec)
|
263
|
+
PARAMETER_INFERENCE = 1
|
264
|
+
# From spec with actual field definitions
|
265
|
+
SCHEMA_WITH_PROPERTIES = 2
|
266
|
+
|
267
|
+
|
268
|
+
OperationMap: TypeAlias = dict[str, OperationNode]
|
269
|
+
ResourceMap: TypeAlias = dict[str, ResourceDefinition]
|
270
|
+
CanonicalizationCache: TypeAlias = dict[str, Mapping[str, Any]]
|