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
@@ -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]]
|
@@ -0,0 +1,168 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
|
4
|
+
def from_parameter(parameter: str, path: str) -> str | None:
|
5
|
+
# TODO: support other naming patterns
|
6
|
+
# Named like "userId" -> look for "User" resource
|
7
|
+
if parameter.endswith("Id"):
|
8
|
+
return to_pascal_case(parameter[:-2])
|
9
|
+
# Named like "user_id" -> look for "User" resource
|
10
|
+
elif parameter.endswith("_id"):
|
11
|
+
return to_pascal_case(parameter[:-3])
|
12
|
+
# Just "id" -> infer from path context
|
13
|
+
elif parameter == "id":
|
14
|
+
return from_path(path)
|
15
|
+
return None
|
16
|
+
|
17
|
+
|
18
|
+
def from_path(path: str) -> str | None:
|
19
|
+
segments = [s for s in path.split("/") if s and "{" not in s]
|
20
|
+
|
21
|
+
if not segments:
|
22
|
+
# API Root
|
23
|
+
return None
|
24
|
+
|
25
|
+
singular = to_singular(segments[-1])
|
26
|
+
return to_pascal_case(singular)
|
27
|
+
|
28
|
+
|
29
|
+
def to_singular(word: str) -> str:
|
30
|
+
if word.endswith("ies"):
|
31
|
+
return word[:-3] + "y"
|
32
|
+
if word.endswith("sses"):
|
33
|
+
return word[:-2]
|
34
|
+
if word.endswith(("ses", "xes", "zes", "ches", "shes")):
|
35
|
+
return word[:-2]
|
36
|
+
if word.endswith("s"):
|
37
|
+
return word[:-1]
|
38
|
+
return word
|
39
|
+
|
40
|
+
|
41
|
+
def to_plural(word: str) -> str:
|
42
|
+
# party -> parties (inverse of ies -> y)
|
43
|
+
if word.endswith("y"):
|
44
|
+
return word[:-1] + "ies"
|
45
|
+
# class -> classes
|
46
|
+
if word.endswith("ss"):
|
47
|
+
return word + "es"
|
48
|
+
# words that normally take -es: box -> boxes
|
49
|
+
if word.endswith(("s", "x", "z", "ch", "sh")):
|
50
|
+
return word + "es"
|
51
|
+
# just add 's' (car -> cars)
|
52
|
+
return word + "s"
|
53
|
+
|
54
|
+
|
55
|
+
def to_pascal_case(text: str) -> str:
|
56
|
+
parts = text.replace("-", "_").split("_")
|
57
|
+
return "".join(word.capitalize() for word in parts if word)
|
58
|
+
|
59
|
+
|
60
|
+
def to_snake_case(text: str) -> str:
|
61
|
+
text = text.replace("-", "_")
|
62
|
+
# Insert underscores before uppercase letters
|
63
|
+
result = []
|
64
|
+
for i, char in enumerate(text):
|
65
|
+
# Add underscore before uppercase (except at start)
|
66
|
+
if i > 0 and char.isupper():
|
67
|
+
result.append("_")
|
68
|
+
result.append(char.lower())
|
69
|
+
return "".join(result)
|
70
|
+
|
71
|
+
|
72
|
+
def find_matching_field(*, parameter: str, resource: str, fields: list[str]) -> str | None:
|
73
|
+
"""Find which resource field matches the parameter name."""
|
74
|
+
if not fields:
|
75
|
+
return None
|
76
|
+
|
77
|
+
# Exact match
|
78
|
+
if parameter in fields:
|
79
|
+
return parameter
|
80
|
+
|
81
|
+
# Normalize for fuzzy matching
|
82
|
+
parameter_normalized = _normalize_for_matching(parameter)
|
83
|
+
resource_normalized = _normalize_for_matching(resource)
|
84
|
+
|
85
|
+
# Normalized exact match
|
86
|
+
# `brandId` -> `Brand.BrandId`
|
87
|
+
for field in fields:
|
88
|
+
if _normalize_for_matching(field) == parameter_normalized:
|
89
|
+
return field
|
90
|
+
|
91
|
+
# Extract parameter components
|
92
|
+
parameter_prefix, param_suffix = _split_parameter_name(parameter)
|
93
|
+
parameter_prefix_normalized = _normalize_for_matching(parameter_prefix)
|
94
|
+
|
95
|
+
# Parameter has resource prefix, field might not
|
96
|
+
# Example: `channelId` - `Channel.id`
|
97
|
+
if parameter_prefix and parameter_prefix_normalized == resource_normalized:
|
98
|
+
suffix_normalized = _normalize_for_matching(param_suffix)
|
99
|
+
|
100
|
+
for field in fields:
|
101
|
+
field_normalized = _normalize_for_matching(field)
|
102
|
+
if field_normalized == suffix_normalized:
|
103
|
+
return field
|
104
|
+
|
105
|
+
# Parameter has no prefix, field might have resource prefix
|
106
|
+
# Example: `id` - `Channel.channelId`
|
107
|
+
if not parameter_prefix and param_suffix:
|
108
|
+
expected_field_normalized = resource_normalized + _normalize_for_matching(param_suffix)
|
109
|
+
|
110
|
+
for field in fields:
|
111
|
+
field_normalized = _normalize_for_matching(field)
|
112
|
+
if field_normalized == expected_field_normalized:
|
113
|
+
return field
|
114
|
+
|
115
|
+
return None
|
116
|
+
|
117
|
+
|
118
|
+
def _normalize_for_matching(text: str) -> str:
|
119
|
+
"""Normalize text for case-insensitive, separator-insensitive matching.
|
120
|
+
|
121
|
+
Examples:
|
122
|
+
"channelId" -> "channelid"
|
123
|
+
"channel_id" -> "channelid"
|
124
|
+
"ChannelId" -> "channelid"
|
125
|
+
"Channel" -> "channel"
|
126
|
+
|
127
|
+
"""
|
128
|
+
return text.lower().replace("_", "").replace("-", "")
|
129
|
+
|
130
|
+
|
131
|
+
def _split_parameter_name(param_name: str) -> tuple[str, str]:
|
132
|
+
"""Split parameter into (prefix, suffix) components.
|
133
|
+
|
134
|
+
Examples:
|
135
|
+
"channelId" -> ("channel", "Id")
|
136
|
+
"userId" -> ("user", "Id")
|
137
|
+
"user_id" -> ("user", "_id")
|
138
|
+
"id" -> ("", "id")
|
139
|
+
"channel_id" -> ("channel", "_id")
|
140
|
+
|
141
|
+
"""
|
142
|
+
if param_name.endswith("Id") and len(param_name) > 2:
|
143
|
+
return (param_name[:-2], "Id")
|
144
|
+
|
145
|
+
if param_name.endswith("_id") and len(param_name) > 3:
|
146
|
+
return (param_name[:-3], "_id")
|
147
|
+
|
148
|
+
return ("", param_name)
|
149
|
+
|
150
|
+
|
151
|
+
def strip_affixes(name: str, prefixes: list[str], suffixes: list[str]) -> str:
|
152
|
+
"""Remove common prefixes and suffixes from a name (case-insensitive)."""
|
153
|
+
result = name.strip()
|
154
|
+
name_lower = result.lower()
|
155
|
+
|
156
|
+
# Remove one matching prefix
|
157
|
+
for prefix in prefixes:
|
158
|
+
if name_lower.startswith(prefix):
|
159
|
+
result = result[len(prefix) :]
|
160
|
+
break
|
161
|
+
|
162
|
+
# Remove one matching suffix
|
163
|
+
for suffix in suffixes:
|
164
|
+
if name_lower.endswith(suffix):
|
165
|
+
result = result[: -len(suffix)]
|
166
|
+
break
|
167
|
+
|
168
|
+
return result.strip()
|
@@ -0,0 +1,34 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Iterator
|
4
|
+
|
5
|
+
from schemathesis.specs.openapi.stateful.dependencies.models import CanonicalizationCache, OutputSlot, ResourceMap
|
6
|
+
from schemathesis.specs.openapi.stateful.dependencies.resources import extract_resources_from_responses
|
7
|
+
|
8
|
+
if TYPE_CHECKING:
|
9
|
+
from schemathesis.core.compat import RefResolver
|
10
|
+
from schemathesis.specs.openapi.schemas import APIOperation
|
11
|
+
|
12
|
+
|
13
|
+
def extract_outputs(
|
14
|
+
*,
|
15
|
+
operation: APIOperation,
|
16
|
+
resources: ResourceMap,
|
17
|
+
updated_resources: set[str],
|
18
|
+
resolver: RefResolver,
|
19
|
+
canonicalization_cache: CanonicalizationCache,
|
20
|
+
) -> Iterator[OutputSlot]:
|
21
|
+
"""Extract resources from API operation's responses."""
|
22
|
+
for response, extracted in extract_resources_from_responses(
|
23
|
+
operation=operation,
|
24
|
+
resources=resources,
|
25
|
+
updated_resources=updated_resources,
|
26
|
+
resolver=resolver,
|
27
|
+
canonicalization_cache=canonicalization_cache,
|
28
|
+
):
|
29
|
+
yield OutputSlot(
|
30
|
+
resource=extracted.resource,
|
31
|
+
pointer=extracted.pointer,
|
32
|
+
cardinality=extracted.cardinality,
|
33
|
+
status_code=response.status_code,
|
34
|
+
)
|