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.
Files changed (28) hide show
  1. schemathesis/config/__init__.py +8 -1
  2. schemathesis/config/_phases.py +14 -3
  3. schemathesis/config/schema.json +2 -1
  4. schemathesis/core/jsonschema/bundler.py +3 -2
  5. schemathesis/core/transforms.py +14 -6
  6. schemathesis/engine/context.py +35 -2
  7. schemathesis/generation/hypothesis/__init__.py +3 -1
  8. schemathesis/generation/hypothesis/builder.py +10 -2
  9. schemathesis/openapi/checks.py +13 -1
  10. schemathesis/specs/openapi/adapter/parameters.py +3 -3
  11. schemathesis/specs/openapi/adapter/protocol.py +2 -0
  12. schemathesis/specs/openapi/adapter/responses.py +29 -7
  13. schemathesis/specs/openapi/adapter/v2.py +2 -0
  14. schemathesis/specs/openapi/adapter/v3_0.py +2 -0
  15. schemathesis/specs/openapi/adapter/v3_1.py +2 -0
  16. schemathesis/specs/openapi/stateful/dependencies/__init__.py +88 -0
  17. schemathesis/specs/openapi/stateful/dependencies/inputs.py +182 -0
  18. schemathesis/specs/openapi/stateful/dependencies/models.py +270 -0
  19. schemathesis/specs/openapi/stateful/dependencies/naming.py +345 -0
  20. schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
  21. schemathesis/specs/openapi/stateful/dependencies/resources.py +282 -0
  22. schemathesis/specs/openapi/stateful/dependencies/schemas.py +420 -0
  23. schemathesis/specs/openapi/stateful/inference.py +2 -1
  24. {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/METADATA +1 -1
  25. {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/RECORD +28 -21
  26. {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/WHEEL +0 -0
  27. {schemathesis-4.2.2.dist-info → schemathesis-4.3.1.dist-info}/entry_points.txt +0 -0
  28. {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]]