schemathesis 4.3.11__py3-none-any.whl → 4.3.13__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.
Potentially problematic release.
This version of schemathesis might be problematic. Click here for more details.
- schemathesis/core/errors.py +80 -11
- schemathesis/engine/phases/unit/_executor.py +4 -0
- schemathesis/generation/stateful/state_machine.py +13 -5
- schemathesis/specs/openapi/schemas.py +18 -5
- schemathesis/specs/openapi/stateful/__init__.py +82 -37
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +12 -1
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +117 -1
- schemathesis/specs/openapi/stateful/dependencies/naming.py +13 -10
- schemathesis/specs/openapi/stateful/dependencies/resources.py +23 -1
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +25 -9
- schemathesis/specs/openapi/stateful/links.py +19 -6
- {schemathesis-4.3.11.dist-info → schemathesis-4.3.13.dist-info}/METADATA +1 -1
- {schemathesis-4.3.11.dist-info → schemathesis-4.3.13.dist-info}/RECORD +16 -16
- {schemathesis-4.3.11.dist-info → schemathesis-4.3.13.dist-info}/WHEEL +0 -0
- {schemathesis-4.3.11.dist-info → schemathesis-4.3.13.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.3.11.dist-info → schemathesis-4.3.13.dist-info}/licenses/LICENSE +0 -0
schemathesis/core/errors.py
CHANGED
|
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
import enum
|
|
6
6
|
import re
|
|
7
7
|
import traceback
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from textwrap import indent
|
|
8
10
|
from types import TracebackType
|
|
9
11
|
from typing import TYPE_CHECKING, Any, Callable, NoReturn
|
|
10
12
|
|
|
@@ -33,6 +35,60 @@ class SchemathesisError(Exception):
|
|
|
33
35
|
"""Base exception class for all Schemathesis errors."""
|
|
34
36
|
|
|
35
37
|
|
|
38
|
+
class DefinitionKind(str, enum.Enum):
|
|
39
|
+
SCHEMA = "Schema Object"
|
|
40
|
+
SECURITY_SCHEME = "Security Scheme Object"
|
|
41
|
+
RESPONSES = "Responses Object"
|
|
42
|
+
PARAMETER = "Parameter Object"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SchemaLocation:
|
|
47
|
+
kind: DefinitionKind
|
|
48
|
+
# Hint about where the definition is located
|
|
49
|
+
hint: str | None
|
|
50
|
+
# Open API spec version
|
|
51
|
+
version: str
|
|
52
|
+
|
|
53
|
+
__slots__ = ("kind", "hint", "version")
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def response_schema(cls, version: str) -> SchemaLocation:
|
|
57
|
+
return cls(kind=DefinitionKind.SCHEMA, hint="in response definition", version=version)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def maybe_from_error_path(cls, path: list[str | int], version: str) -> SchemaLocation | None:
|
|
61
|
+
if len(path) == 3 and path[:2] == ["components", "securitySchemes"]:
|
|
62
|
+
return cls(kind=DefinitionKind.SECURITY_SCHEME, hint=f"definition for `{path[2]}`", version=version)
|
|
63
|
+
if len(path) == 3 and path[:2] == ["components", "schemas"]:
|
|
64
|
+
return cls(kind=DefinitionKind.SCHEMA, hint=f"definition for `{path[2]}`", version=version)
|
|
65
|
+
if len(path) == 4 and path[0] == "paths" and path[-1] == "responses":
|
|
66
|
+
return cls(kind=DefinitionKind.RESPONSES, hint=None, version=version)
|
|
67
|
+
if len(path) == 5 and path[0] == "paths" and path[3] == "parameters":
|
|
68
|
+
return cls(kind=DefinitionKind.PARAMETER, hint=f"at index {path[4]}", version=version)
|
|
69
|
+
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def message(self) -> str:
|
|
74
|
+
message = f"Invalid {self.kind.value}"
|
|
75
|
+
if self.hint is not None:
|
|
76
|
+
message += f" {self.hint}"
|
|
77
|
+
else:
|
|
78
|
+
message += " definition"
|
|
79
|
+
return message
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def specification_url(self) -> str:
|
|
83
|
+
anchor = {
|
|
84
|
+
DefinitionKind.SCHEMA: "schema-object",
|
|
85
|
+
DefinitionKind.SECURITY_SCHEME: "security-scheme-object",
|
|
86
|
+
DefinitionKind.RESPONSES: "responses-object",
|
|
87
|
+
DefinitionKind.PARAMETER: "parameter-object",
|
|
88
|
+
}[self.kind]
|
|
89
|
+
return f"https://spec.openapis.org/oas/v{self.version}#{anchor}"
|
|
90
|
+
|
|
91
|
+
|
|
36
92
|
class InvalidSchema(SchemathesisError):
|
|
37
93
|
"""Indicates errors in API schema validation or processing."""
|
|
38
94
|
|
|
@@ -56,9 +112,16 @@ class InvalidSchema(SchemathesisError):
|
|
|
56
112
|
|
|
57
113
|
@classmethod
|
|
58
114
|
def from_jsonschema_error(
|
|
59
|
-
cls,
|
|
115
|
+
cls,
|
|
116
|
+
error: ValidationError | JsonSchemaError,
|
|
117
|
+
path: str | None,
|
|
118
|
+
method: str | None,
|
|
119
|
+
config: OutputConfig,
|
|
120
|
+
location: SchemaLocation | None = None,
|
|
60
121
|
) -> InvalidSchema:
|
|
61
|
-
if
|
|
122
|
+
if location is not None:
|
|
123
|
+
message = location.message
|
|
124
|
+
elif error.absolute_path:
|
|
62
125
|
part = error.absolute_path[-1]
|
|
63
126
|
if isinstance(part, int) and len(error.absolute_path) > 1:
|
|
64
127
|
parent = error.absolute_path[-2]
|
|
@@ -70,14 +133,18 @@ class InvalidSchema(SchemathesisError):
|
|
|
70
133
|
error_path = " -> ".join(str(entry) for entry in error.path) or "[root]"
|
|
71
134
|
message += f"\n\nLocation:\n {error_path}"
|
|
72
135
|
instance = truncate_json(error.instance, config=config)
|
|
73
|
-
message += f"\n\nProblematic definition:\n{instance}"
|
|
136
|
+
message += f"\n\nProblematic definition:\n{indent(instance, ' ')}"
|
|
74
137
|
message += "\n\nError details:\n "
|
|
75
138
|
# This default message contains the instance which we already printed
|
|
76
139
|
if "is not valid under any of the given schemas" in error.message:
|
|
77
140
|
message += "The provided definition doesn't match any of the expected formats or types."
|
|
78
141
|
else:
|
|
79
142
|
message += error.message
|
|
80
|
-
message +=
|
|
143
|
+
message += "\n\n"
|
|
144
|
+
if location is not None:
|
|
145
|
+
message += f"See: {location.specification_url}"
|
|
146
|
+
else:
|
|
147
|
+
message += SCHEMA_ERROR_SUGGESTION
|
|
81
148
|
return cls(message, path=path, method=method)
|
|
82
149
|
|
|
83
150
|
@classmethod
|
|
@@ -86,12 +153,14 @@ class InvalidSchema(SchemathesisError):
|
|
|
86
153
|
) -> InvalidSchema:
|
|
87
154
|
notes = getattr(error, "__notes__", [])
|
|
88
155
|
# Some exceptions don't have the actual reference in them, hence we add it manually via notes
|
|
89
|
-
|
|
90
|
-
message = "Unresolvable
|
|
156
|
+
reference = str(notes[0])
|
|
157
|
+
message = "Unresolvable reference in the schema"
|
|
91
158
|
# Get the pointer value from "Unresolvable JSON pointer: 'components/UnknownParameter'"
|
|
92
|
-
message += f"\n\nError details:\n
|
|
93
|
-
|
|
94
|
-
|
|
159
|
+
message += f"\n\nError details:\n Reference: {reference}"
|
|
160
|
+
if not reference.startswith(("http://", "https://", "#/")):
|
|
161
|
+
message += "\n File reference could not be resolved. Check that the file exists."
|
|
162
|
+
elif reference.startswith(("#/components", "#/definitions")):
|
|
163
|
+
message += "\n Component does not exist in the schema."
|
|
95
164
|
return cls(message, path=path, method=method)
|
|
96
165
|
|
|
97
166
|
def as_failing_test_function(self) -> Callable:
|
|
@@ -154,13 +223,13 @@ class InvalidStateMachine(SchemathesisError):
|
|
|
154
223
|
for source, target_groups in by_source.items():
|
|
155
224
|
for (target, status), transitions in target_groups.items():
|
|
156
225
|
for transition in transitions:
|
|
157
|
-
result += f"\n\n {
|
|
226
|
+
result += f"\n\n {format_transition(source, status, transition.name, target)}\n"
|
|
158
227
|
for error in transition.errors:
|
|
159
228
|
result += f"\n - {error.message}"
|
|
160
229
|
return result
|
|
161
230
|
|
|
162
231
|
|
|
163
|
-
def
|
|
232
|
+
def format_transition(source: str, status: str, transition: str, target: str) -> str:
|
|
164
233
|
return f"{source} -> [{status}] {transition} -> {target}"
|
|
165
234
|
|
|
166
235
|
|
|
@@ -25,6 +25,7 @@ from schemathesis.core.errors import (
|
|
|
25
25
|
InvalidRegexType,
|
|
26
26
|
InvalidSchema,
|
|
27
27
|
MalformedMediaType,
|
|
28
|
+
SchemaLocation,
|
|
28
29
|
SerializationNotPossible,
|
|
29
30
|
)
|
|
30
31
|
from schemathesis.core.failures import Failure, FailureGroup
|
|
@@ -198,6 +199,9 @@ def run_test(
|
|
|
198
199
|
path=operation.path,
|
|
199
200
|
method=operation.method,
|
|
200
201
|
config=ctx.config.output,
|
|
202
|
+
location=SchemaLocation.maybe_from_error_path(
|
|
203
|
+
list(exc.absolute_path), ctx.schema.specification.version
|
|
204
|
+
),
|
|
201
205
|
)
|
|
202
206
|
)
|
|
203
207
|
except InvalidArgument as exc:
|
|
@@ -37,16 +37,23 @@ class StepInput:
|
|
|
37
37
|
|
|
38
38
|
case: Case
|
|
39
39
|
transition: Transition | None # None for initial steps
|
|
40
|
-
#
|
|
40
|
+
# What parameters were actually applied
|
|
41
41
|
# Data extraction failures can prevent it, as well as transitions can be skipped in some cases
|
|
42
42
|
# to improve discovery of bugs triggered by non-stateful inputs during stateful testing
|
|
43
|
-
|
|
43
|
+
applied_parameters: list[str]
|
|
44
44
|
|
|
45
|
-
__slots__ = ("case", "transition", "
|
|
45
|
+
__slots__ = ("case", "transition", "applied_parameters")
|
|
46
46
|
|
|
47
47
|
@classmethod
|
|
48
48
|
def initial(cls, case: Case) -> StepInput:
|
|
49
|
-
return cls(case=case, transition=None,
|
|
49
|
+
return cls(case=case, transition=None, applied_parameters=[])
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_applied(self) -> bool:
|
|
53
|
+
# If the transition has no parameters or body, count it as applied
|
|
54
|
+
if self.transition is not None and not self.transition.parameters and self.transition.request_body is None:
|
|
55
|
+
return True
|
|
56
|
+
return bool(self.applied_parameters)
|
|
50
57
|
|
|
51
58
|
|
|
52
59
|
@dataclass
|
|
@@ -69,8 +76,9 @@ class ExtractedParam:
|
|
|
69
76
|
|
|
70
77
|
definition: Any
|
|
71
78
|
value: Result[Any, Exception]
|
|
79
|
+
is_required: bool
|
|
72
80
|
|
|
73
|
-
__slots__ = ("definition", "value")
|
|
81
|
+
__slots__ = ("definition", "value", "is_required")
|
|
74
82
|
|
|
75
83
|
|
|
76
84
|
@dataclass
|
|
@@ -27,7 +27,13 @@ from requests.structures import CaseInsensitiveDict
|
|
|
27
27
|
from schemathesis.core import INJECTED_PATH_PARAMETER_KEY, NOT_SET, NotSet, Specification, deserialization, media_types
|
|
28
28
|
from schemathesis.core.adapter import OperationParameter, ResponsesContainer
|
|
29
29
|
from schemathesis.core.compat import RefResolutionError
|
|
30
|
-
from schemathesis.core.errors import
|
|
30
|
+
from schemathesis.core.errors import (
|
|
31
|
+
SCHEMA_ERROR_SUGGESTION,
|
|
32
|
+
InfiniteRecursiveReference,
|
|
33
|
+
InvalidSchema,
|
|
34
|
+
OperationNotFound,
|
|
35
|
+
SchemaLocation,
|
|
36
|
+
)
|
|
31
37
|
from schemathesis.core.failures import Failure, FailureGroup, MalformedJson
|
|
32
38
|
from schemathesis.core.result import Err, Ok, Result
|
|
33
39
|
from schemathesis.core.transport import Response
|
|
@@ -61,7 +67,6 @@ if TYPE_CHECKING:
|
|
|
61
67
|
from schemathesis.generation.stateful import APIStateMachine
|
|
62
68
|
|
|
63
69
|
HTTP_METHODS = frozenset({"get", "put", "post", "delete", "options", "head", "patch", "trace"})
|
|
64
|
-
SCHEMA_ERROR_MESSAGE = "Ensure that the definition complies with the OpenAPI specification"
|
|
65
70
|
SCHEMA_PARSING_ERRORS = (KeyError, AttributeError, RefResolutionError, InvalidSchema, InfiniteRecursiveReference)
|
|
66
71
|
|
|
67
72
|
|
|
@@ -317,9 +322,13 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
317
322
|
self.validate()
|
|
318
323
|
except jsonschema.ValidationError as exc:
|
|
319
324
|
raise InvalidSchema.from_jsonschema_error(
|
|
320
|
-
exc,
|
|
325
|
+
exc,
|
|
326
|
+
path=path,
|
|
327
|
+
method=method,
|
|
328
|
+
config=self.config.output,
|
|
329
|
+
location=SchemaLocation.maybe_from_error_path(list(exc.absolute_path), self.specification.version),
|
|
321
330
|
) from None
|
|
322
|
-
raise InvalidSchema(
|
|
331
|
+
raise InvalidSchema(SCHEMA_ERROR_SUGGESTION, path=path, method=method) from error
|
|
323
332
|
|
|
324
333
|
def validate(self) -> None:
|
|
325
334
|
with suppress(TypeError):
|
|
@@ -540,7 +549,11 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
540
549
|
definition.validator.validate(data)
|
|
541
550
|
except jsonschema.SchemaError as exc:
|
|
542
551
|
raise InvalidSchema.from_jsonschema_error(
|
|
543
|
-
exc,
|
|
552
|
+
exc,
|
|
553
|
+
path=operation.path,
|
|
554
|
+
method=operation.method,
|
|
555
|
+
config=self.config.output,
|
|
556
|
+
location=SchemaLocation.response_schema(self.specification.version),
|
|
544
557
|
) from exc
|
|
545
558
|
except jsonschema.ValidationError as exc:
|
|
546
559
|
failures.append(
|
|
@@ -8,6 +8,7 @@ import jsonschema
|
|
|
8
8
|
from hypothesis import strategies as st
|
|
9
9
|
from hypothesis.stateful import Bundle, Rule, precondition, rule
|
|
10
10
|
|
|
11
|
+
from schemathesis.core import NOT_SET
|
|
11
12
|
from schemathesis.core.errors import InvalidStateMachine, InvalidTransition
|
|
12
13
|
from schemathesis.core.parameters import ParameterLocation
|
|
13
14
|
from schemathesis.core.result import Ok
|
|
@@ -49,7 +50,7 @@ class OpenAPIStateMachine(APIStateMachine):
|
|
|
49
50
|
# The proportion of negative tests generated for "root" transitions
|
|
50
51
|
NEGATIVE_TEST_CASES_THRESHOLD = 10
|
|
51
52
|
# How often some transition is skipped
|
|
52
|
-
|
|
53
|
+
BASE_EXPLORATION_RATE = 0.15
|
|
53
54
|
|
|
54
55
|
|
|
55
56
|
@dataclass
|
|
@@ -162,14 +163,12 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
|
|
|
162
163
|
for target in operations:
|
|
163
164
|
if target.label in transitions.operations:
|
|
164
165
|
incoming = transitions.operations[target.label].incoming
|
|
166
|
+
config = schema.config.generation_for(operation=target, phase="stateful")
|
|
165
167
|
if incoming:
|
|
166
168
|
for link in incoming:
|
|
167
169
|
bundle_name = f"{link.source.label} -> {link.status_code}"
|
|
168
|
-
name = _normalize_name(
|
|
169
|
-
f"{link.source.label} -> {link.status_code} -> {link.name} -> {target.label}"
|
|
170
|
-
)
|
|
170
|
+
name = _normalize_name(link.full_name)
|
|
171
171
|
assert name not in rules, name
|
|
172
|
-
config = schema.config.generation_for(operation=target, phase="stateful")
|
|
173
172
|
rules[name] = precondition(is_transition_allowed(bundle_name, link.source.label, target.label))(
|
|
174
173
|
transition(
|
|
175
174
|
name=name,
|
|
@@ -181,7 +180,6 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
|
|
|
181
180
|
)
|
|
182
181
|
if target.label in roots.reliable or (not roots.reliable and target.label in roots.fallback):
|
|
183
182
|
name = _normalize_name(f"RANDOM -> {target.label}")
|
|
184
|
-
config = schema.config.generation_for(operation=target, phase="stateful")
|
|
185
183
|
if len(config.modes) == 1:
|
|
186
184
|
case_strategy = target.as_strategy(generation_mode=config.modes[0], phase=TestPhase.STATEFUL)
|
|
187
185
|
else:
|
|
@@ -237,8 +235,8 @@ def classify_root_transitions(operations: list[APIOperation], transitions: ApiTr
|
|
|
237
235
|
|
|
238
236
|
def is_likely_root_transition(operation: APIOperation) -> bool:
|
|
239
237
|
"""Check if operation is likely to succeed as a root transition."""
|
|
240
|
-
# POST operations
|
|
241
|
-
if operation.method == "post"
|
|
238
|
+
# POST operations are likely to create resources
|
|
239
|
+
if operation.method == "post":
|
|
242
240
|
return True
|
|
243
241
|
|
|
244
242
|
# GET operations without path parameters are likely to return lists
|
|
@@ -249,50 +247,97 @@ def is_likely_root_transition(operation: APIOperation) -> bool:
|
|
|
249
247
|
|
|
250
248
|
|
|
251
249
|
def into_step_input(
|
|
252
|
-
target: APIOperation, link: OpenApiLink, modes: list[GenerationMode]
|
|
250
|
+
*, target: APIOperation, link: OpenApiLink, modes: list[GenerationMode]
|
|
253
251
|
) -> Callable[[StepOutput], st.SearchStrategy[StepInput]]:
|
|
252
|
+
"""A single transition between API operations."""
|
|
253
|
+
|
|
254
254
|
def builder(_output: StepOutput) -> st.SearchStrategy[StepInput]:
|
|
255
255
|
@st.composite # type: ignore[misc]
|
|
256
256
|
def inner(draw: st.DrawFn, output: StepOutput) -> StepInput:
|
|
257
|
+
random = draw(st.randoms(use_true_random=True))
|
|
258
|
+
|
|
259
|
+
def biased_coin(p: float) -> bool:
|
|
260
|
+
return random.random() < p
|
|
261
|
+
|
|
262
|
+
# Extract transition data from previous operation's output
|
|
257
263
|
transition = link.extract(output)
|
|
258
264
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
overrides: dict[str, Any] = {}
|
|
266
|
+
applied_parameters = []
|
|
267
|
+
for container, data in transition.parameters.items():
|
|
268
|
+
overrides[container] = {}
|
|
269
|
+
|
|
270
|
+
for name, extracted in data.items():
|
|
271
|
+
# Skip if extraction failed or returned unusable value
|
|
272
|
+
if not isinstance(extracted.value, Ok) or extracted.value.ok() in (None, UNRESOLVABLE):
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
param_key = f"{container}.{name}"
|
|
276
|
+
|
|
277
|
+
# Calculate exploration rate based on parameter characteristics
|
|
278
|
+
exploration_rate = BASE_EXPLORATION_RATE
|
|
279
|
+
|
|
280
|
+
# Path parameters are critical for routing - use link values more often
|
|
281
|
+
if container == "path_parameters":
|
|
282
|
+
exploration_rate *= 0.5
|
|
283
|
+
|
|
284
|
+
# Required parameters should follow links more often, optional ones explored more
|
|
285
|
+
# Path params are always required, so they get both multipliers
|
|
286
|
+
if extracted.is_required:
|
|
287
|
+
exploration_rate *= 0.5
|
|
288
|
+
else:
|
|
289
|
+
# Explore optional parameters more to avoid only testing link-provided values
|
|
290
|
+
exploration_rate *= 3.0
|
|
267
291
|
|
|
292
|
+
if biased_coin(1 - exploration_rate):
|
|
293
|
+
overrides[container][name] = extracted.value.ok()
|
|
294
|
+
applied_parameters.append(param_key)
|
|
295
|
+
|
|
296
|
+
# Get the extracted body value
|
|
268
297
|
if (
|
|
269
298
|
transition.request_body is not None
|
|
270
299
|
and isinstance(transition.request_body.value, Ok)
|
|
271
300
|
and transition.request_body.value.ok() is not UNRESOLVABLE
|
|
272
|
-
and not link.merge_body
|
|
273
|
-
and draw(st.integers(min_value=0, max_value=99)) < USE_TRANSITION_THRESHOLD
|
|
274
301
|
):
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
302
|
+
request_body = transition.request_body.value.ok()
|
|
303
|
+
else:
|
|
304
|
+
request_body = NOT_SET
|
|
305
|
+
|
|
306
|
+
# Link suppose to replace the entire extracted body
|
|
307
|
+
if request_body is not NOT_SET and not link.merge_body and biased_coin(1 - BASE_EXPLORATION_RATE):
|
|
308
|
+
overrides["body"] = request_body
|
|
309
|
+
if isinstance(overrides["body"], dict):
|
|
310
|
+
applied_parameters.extend(f"body.{field}" for field in overrides["body"])
|
|
311
|
+
else:
|
|
312
|
+
applied_parameters.append("body")
|
|
278
313
|
|
|
279
314
|
cases = st.one_of(
|
|
280
|
-
target.as_strategy(generation_mode=mode, phase=TestPhase.STATEFUL, **
|
|
315
|
+
[target.as_strategy(generation_mode=mode, phase=TestPhase.STATEFUL, **overrides) for mode in modes]
|
|
281
316
|
)
|
|
282
317
|
case = draw(cases)
|
|
283
|
-
if
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
318
|
+
if request_body is not NOT_SET and link.merge_body:
|
|
319
|
+
if isinstance(request_body, dict):
|
|
320
|
+
selected_fields = {}
|
|
321
|
+
|
|
322
|
+
for field_name, field_value in request_body.items():
|
|
323
|
+
if field_value is UNRESOLVABLE:
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
if biased_coin(1 - BASE_EXPLORATION_RATE):
|
|
327
|
+
selected_fields[field_name] = field_value
|
|
328
|
+
applied_parameters.append(f"body.{field_name}")
|
|
329
|
+
|
|
330
|
+
if selected_fields:
|
|
331
|
+
if isinstance(case.body, dict):
|
|
332
|
+
case.body = {**case.body, **selected_fields}
|
|
333
|
+
else:
|
|
334
|
+
# Can't merge into non-dict, replace entirely
|
|
335
|
+
case.body = selected_fields
|
|
336
|
+
elif biased_coin(1 - BASE_EXPLORATION_RATE):
|
|
337
|
+
case.body = request_body
|
|
338
|
+
applied_parameters.append("body")
|
|
339
|
+
|
|
340
|
+
# Re-validate generation mode after merging body
|
|
296
341
|
if case.meta and case.meta.generation.mode == GenerationMode.NEGATIVE:
|
|
297
342
|
# It is possible that the new body is now valid and the whole test case could be valid too
|
|
298
343
|
for alternative in case.operation.body:
|
|
@@ -304,7 +349,7 @@ def into_step_input(
|
|
|
304
349
|
)
|
|
305
350
|
if all(info.mode == GenerationMode.POSITIVE for info in case.meta.components.values()):
|
|
306
351
|
case.meta.generation.mode = GenerationMode.POSITIVE
|
|
307
|
-
return StepInput(case=case, transition=transition,
|
|
352
|
+
return StepInput(case=case, transition=transition, applied_parameters=applied_parameters)
|
|
308
353
|
|
|
309
354
|
return inner(output=_output)
|
|
310
355
|
|
|
@@ -10,7 +10,11 @@ from typing import TYPE_CHECKING, Any
|
|
|
10
10
|
from schemathesis.core import NOT_SET
|
|
11
11
|
from schemathesis.core.compat import RefResolutionError
|
|
12
12
|
from schemathesis.core.result import Ok
|
|
13
|
-
from schemathesis.specs.openapi.stateful.dependencies.inputs import
|
|
13
|
+
from schemathesis.specs.openapi.stateful.dependencies.inputs import (
|
|
14
|
+
extract_inputs,
|
|
15
|
+
merge_related_resources,
|
|
16
|
+
update_input_field_bindings,
|
|
17
|
+
)
|
|
14
18
|
from schemathesis.specs.openapi.stateful.dependencies.models import (
|
|
15
19
|
CanonicalizationCache,
|
|
16
20
|
Cardinality,
|
|
@@ -25,6 +29,7 @@ from schemathesis.specs.openapi.stateful.dependencies.models import (
|
|
|
25
29
|
ResourceMap,
|
|
26
30
|
)
|
|
27
31
|
from schemathesis.specs.openapi.stateful.dependencies.outputs import extract_outputs
|
|
32
|
+
from schemathesis.specs.openapi.stateful.dependencies.resources import remove_unused_resources
|
|
28
33
|
|
|
29
34
|
if TYPE_CHECKING:
|
|
30
35
|
from schemathesis.schemas import APIOperation
|
|
@@ -88,6 +93,12 @@ def analyze(schema: BaseOpenAPISchema) -> DependencyGraph:
|
|
|
88
93
|
for resource in updated_resources:
|
|
89
94
|
update_input_field_bindings(resource, operations)
|
|
90
95
|
|
|
96
|
+
# Merge parameter-inferred resources with schema-defined ones
|
|
97
|
+
merge_related_resources(operations, resources)
|
|
98
|
+
|
|
99
|
+
# Clean up orphaned resources
|
|
100
|
+
remove_unused_resources(operations, resources)
|
|
101
|
+
|
|
91
102
|
return DependencyGraph(operations=operations, resources=resources)
|
|
92
103
|
|
|
93
104
|
|
|
@@ -14,6 +14,7 @@ from schemathesis.specs.openapi.stateful.dependencies.models import (
|
|
|
14
14
|
DefinitionSource,
|
|
15
15
|
InputSlot,
|
|
16
16
|
OperationMap,
|
|
17
|
+
OutputSlot,
|
|
17
18
|
ResourceDefinition,
|
|
18
19
|
ResourceMap,
|
|
19
20
|
)
|
|
@@ -39,7 +40,7 @@ def extract_inputs(
|
|
|
39
40
|
creating placeholder resources if not yet discovered from their schemas.
|
|
40
41
|
"""
|
|
41
42
|
known_dependencies = set()
|
|
42
|
-
for param in operation.
|
|
43
|
+
for param in operation.iter_parameters():
|
|
43
44
|
input_slot = _resolve_parameter_dependency(
|
|
44
45
|
parameter_name=param.name,
|
|
45
46
|
parameter_location=param.location,
|
|
@@ -310,3 +311,118 @@ def update_input_field_bindings(resource_name: str, operations: OperationMap) ->
|
|
|
310
311
|
)
|
|
311
312
|
if new_field is not None:
|
|
312
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
|
+
)
|
|
@@ -394,13 +394,13 @@ def find_matching_field(*, parameter: str, resource: str, fields: list[str]) ->
|
|
|
394
394
|
return field
|
|
395
395
|
|
|
396
396
|
# Extract parameter components
|
|
397
|
-
parameter_prefix,
|
|
397
|
+
parameter_prefix, parameter_suffix = _split_parameter_name(parameter)
|
|
398
398
|
parameter_prefix_normalized = _normalize_for_matching(parameter_prefix)
|
|
399
399
|
|
|
400
400
|
# Parameter has resource prefix, field might not
|
|
401
401
|
# Example: `channelId` - `Channel.id`
|
|
402
402
|
if parameter_prefix and parameter_prefix_normalized == resource_normalized:
|
|
403
|
-
suffix_normalized = _normalize_for_matching(
|
|
403
|
+
suffix_normalized = _normalize_for_matching(parameter_suffix)
|
|
404
404
|
|
|
405
405
|
for field in fields:
|
|
406
406
|
field_normalized = _normalize_for_matching(field)
|
|
@@ -409,8 +409,8 @@ def find_matching_field(*, parameter: str, resource: str, fields: list[str]) ->
|
|
|
409
409
|
|
|
410
410
|
# Parameter has no prefix, field might have resource prefix
|
|
411
411
|
# Example: `id` - `Channel.channelId`
|
|
412
|
-
if not parameter_prefix and
|
|
413
|
-
expected_field_normalized = resource_normalized + _normalize_for_matching(
|
|
412
|
+
if not parameter_prefix and parameter_suffix:
|
|
413
|
+
expected_field_normalized = resource_normalized + _normalize_for_matching(parameter_suffix)
|
|
414
414
|
|
|
415
415
|
for field in fields:
|
|
416
416
|
field_normalized = _normalize_for_matching(field)
|
|
@@ -433,7 +433,7 @@ def _normalize_for_matching(text: str) -> str:
|
|
|
433
433
|
return text.lower().replace("_", "").replace("-", "")
|
|
434
434
|
|
|
435
435
|
|
|
436
|
-
def _split_parameter_name(
|
|
436
|
+
def _split_parameter_name(parameter_name: str) -> tuple[str, str]:
|
|
437
437
|
"""Split parameter into (prefix, suffix) components.
|
|
438
438
|
|
|
439
439
|
Examples:
|
|
@@ -444,13 +444,16 @@ def _split_parameter_name(param_name: str) -> tuple[str, str]:
|
|
|
444
444
|
"channel_id" -> ("channel", "_id")
|
|
445
445
|
|
|
446
446
|
"""
|
|
447
|
-
if
|
|
448
|
-
return (
|
|
447
|
+
if parameter_name.endswith("Id") and len(parameter_name) > 2:
|
|
448
|
+
return (parameter_name[:-2], "Id")
|
|
449
449
|
|
|
450
|
-
if
|
|
451
|
-
return (
|
|
450
|
+
if parameter_name.endswith("_id") and len(parameter_name) > 3:
|
|
451
|
+
return (parameter_name[:-3], "_id")
|
|
452
452
|
|
|
453
|
-
|
|
453
|
+
if parameter_name.endswith("_guid") and len(parameter_name) > 5:
|
|
454
|
+
return (parameter_name[:-5], "_guid")
|
|
455
|
+
|
|
456
|
+
return ("", parameter_name)
|
|
454
457
|
|
|
455
458
|
|
|
456
459
|
def strip_affixes(name: str, prefixes: list[str], suffixes: list[str]) -> str:
|
|
@@ -13,6 +13,7 @@ from schemathesis.specs.openapi.stateful.dependencies.models import (
|
|
|
13
13
|
CanonicalizationCache,
|
|
14
14
|
Cardinality,
|
|
15
15
|
DefinitionSource,
|
|
16
|
+
OperationMap,
|
|
16
17
|
ResourceDefinition,
|
|
17
18
|
ResourceMap,
|
|
18
19
|
extend_pointer,
|
|
@@ -293,7 +294,13 @@ def _extract_resource_from_schema(
|
|
|
293
294
|
properties = resolved.get("properties")
|
|
294
295
|
if properties:
|
|
295
296
|
fields = sorted(properties)
|
|
296
|
-
types = {
|
|
297
|
+
types = {}
|
|
298
|
+
for field, subschema in properties.items():
|
|
299
|
+
if isinstance(subschema, dict):
|
|
300
|
+
_, resolved_subschema = maybe_resolve(subschema, resolver, "")
|
|
301
|
+
else:
|
|
302
|
+
resolved_subschema = subschema
|
|
303
|
+
types[field] = set(get_type(cast(dict, resolved_subschema)))
|
|
297
304
|
source = DefinitionSource.SCHEMA_WITH_PROPERTIES
|
|
298
305
|
else:
|
|
299
306
|
fields = []
|
|
@@ -310,3 +317,18 @@ def _extract_resource_from_schema(
|
|
|
310
317
|
resources[resource_name] = resource
|
|
311
318
|
|
|
312
319
|
return resource
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def remove_unused_resources(operations: OperationMap, resources: ResourceMap) -> None:
|
|
323
|
+
"""Remove resources that aren't referenced by any operation."""
|
|
324
|
+
# Collect all resource names currently in use
|
|
325
|
+
used_resources = set()
|
|
326
|
+
for operation in operations.values():
|
|
327
|
+
for input_slot in operation.inputs:
|
|
328
|
+
used_resources.add(input_slot.resource.name)
|
|
329
|
+
for output_slot in operation.outputs:
|
|
330
|
+
used_resources.add(output_slot.resource.name)
|
|
331
|
+
|
|
332
|
+
unused = set(resources.keys()) - used_resources
|
|
333
|
+
for resource_name in unused:
|
|
334
|
+
del resources[resource_name]
|
|
@@ -232,30 +232,38 @@ def unwrap_schema(
|
|
|
232
232
|
_, resolved = maybe_resolve(array_schema, resolver, "")
|
|
233
233
|
pointer = f"/{encode_pointer(array_field)}"
|
|
234
234
|
|
|
235
|
+
uses_parent_ref = False
|
|
235
236
|
# Try to unwrap one more time
|
|
236
237
|
if resolved.get("type") == "array" or "items" in resolved:
|
|
237
238
|
nested_items = resolved.get("items")
|
|
238
239
|
if isinstance(nested_items, dict):
|
|
239
240
|
_, resolved_items = maybe_resolve(nested_items, resolver, "")
|
|
240
|
-
external_tag = _detect_externally_tagged_pattern(resolved_items, path)
|
|
241
|
+
external_tag = _detect_externally_tagged_pattern(resolved_items, path, parent_ref)
|
|
241
242
|
if external_tag:
|
|
242
|
-
|
|
243
|
+
external_tag_, uses_parent_ref = external_tag
|
|
244
|
+
nested_properties = resolved_items["properties"][external_tag_]
|
|
243
245
|
_, resolved = maybe_resolve(nested_properties, resolver, "")
|
|
244
|
-
pointer += f"/{encode_pointer(
|
|
246
|
+
pointer += f"/{encode_pointer(external_tag_)}"
|
|
245
247
|
|
|
248
|
+
ref = parent_ref if uses_parent_ref else array_schema.get("$ref")
|
|
246
249
|
return UnwrappedSchema(pointer=pointer, schema=resolved, ref=array_schema.get("$ref"))
|
|
247
250
|
|
|
248
251
|
# External tag
|
|
249
|
-
external_tag = _detect_externally_tagged_pattern(schema, path)
|
|
252
|
+
external_tag = _detect_externally_tagged_pattern(schema, path, parent_ref)
|
|
250
253
|
if external_tag:
|
|
251
|
-
|
|
254
|
+
external_tag_, uses_parent_ref = external_tag
|
|
255
|
+
tagged_schema = properties[external_tag_]
|
|
252
256
|
_, resolved_tagged = maybe_resolve(tagged_schema, resolver, "")
|
|
253
257
|
|
|
254
258
|
resolved = try_unwrap_all_of(resolved_tagged)
|
|
255
|
-
ref =
|
|
259
|
+
ref = (
|
|
260
|
+
parent_ref
|
|
261
|
+
if uses_parent_ref
|
|
262
|
+
else resolved.get("$ref") or resolved_tagged.get("$ref") or tagged_schema.get("$ref")
|
|
263
|
+
)
|
|
256
264
|
|
|
257
265
|
_, resolved = maybe_resolve(resolved, resolver, "")
|
|
258
|
-
return UnwrappedSchema(pointer=f"/{encode_pointer(
|
|
266
|
+
return UnwrappedSchema(pointer=f"/{encode_pointer(external_tag_)}", schema=resolved, ref=ref)
|
|
259
267
|
|
|
260
268
|
# No wrapper - single object at root
|
|
261
269
|
return UnwrappedSchema(pointer="/", schema=schema, ref=schema.get("$ref"))
|
|
@@ -391,7 +399,9 @@ def _is_pagination_wrapper(
|
|
|
391
399
|
return None
|
|
392
400
|
|
|
393
401
|
|
|
394
|
-
def _detect_externally_tagged_pattern(
|
|
402
|
+
def _detect_externally_tagged_pattern(
|
|
403
|
+
schema: Mapping[str, Any], path: str, parent_ref: str | None
|
|
404
|
+
) -> tuple[str, bool] | None:
|
|
395
405
|
"""Detect externally tagged resource pattern.
|
|
396
406
|
|
|
397
407
|
Pattern: {ResourceName: [...]} or {resourceName: [...]}
|
|
@@ -420,12 +430,18 @@ def _detect_externally_tagged_pattern(schema: Mapping[str, Any], path: str) -> s
|
|
|
420
430
|
# `data_request`
|
|
421
431
|
naming.to_snake_case(resource_name),
|
|
422
432
|
}
|
|
433
|
+
parent_names = set()
|
|
434
|
+
if parent_ref is not None:
|
|
435
|
+
maybe_resource_name = resource_name_from_ref(parent_ref)
|
|
436
|
+
parent_names.add(naming.to_plural(maybe_resource_name.lower()))
|
|
437
|
+
parent_names.add(naming.to_snake_case(maybe_resource_name))
|
|
438
|
+
possible_names = possible_names.union(parent_names)
|
|
423
439
|
|
|
424
440
|
for name, subschema in properties.items():
|
|
425
441
|
if name.lower() not in possible_names:
|
|
426
442
|
continue
|
|
427
443
|
|
|
428
444
|
if isinstance(subschema, dict) and "object" in get_type(subschema):
|
|
429
|
-
return name
|
|
445
|
+
return name, name.lower() in parent_names
|
|
430
446
|
|
|
431
447
|
return None
|
|
@@ -5,7 +5,7 @@ from functools import lru_cache
|
|
|
5
5
|
from typing import Any, Callable
|
|
6
6
|
|
|
7
7
|
from schemathesis.core import NOT_SET, NotSet
|
|
8
|
-
from schemathesis.core.errors import InvalidTransition, OperationNotFound, TransitionValidationError
|
|
8
|
+
from schemathesis.core.errors import InvalidTransition, OperationNotFound, TransitionValidationError, format_transition
|
|
9
9
|
from schemathesis.core.parameters import ParameterLocation
|
|
10
10
|
from schemathesis.core.result import Err, Ok, Result
|
|
11
11
|
from schemathesis.generation.stateful.state_machine import ExtractedParam, StepOutput, Transition
|
|
@@ -23,8 +23,9 @@ class NormalizedParameter:
|
|
|
23
23
|
name: str
|
|
24
24
|
expression: str
|
|
25
25
|
container_name: str
|
|
26
|
+
is_required: bool
|
|
26
27
|
|
|
27
|
-
__slots__ = ("location", "name", "expression", "container_name")
|
|
28
|
+
__slots__ = ("location", "name", "expression", "container_name", "is_required")
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
@dataclass(repr=False)
|
|
@@ -93,6 +94,10 @@ class OpenApiLink:
|
|
|
93
94
|
|
|
94
95
|
self._cached_extract = lru_cache(8)(self._extract_impl)
|
|
95
96
|
|
|
97
|
+
@property
|
|
98
|
+
def full_name(self) -> str:
|
|
99
|
+
return format_transition(self.source.label, self.status_code, self.name, self.target.label)
|
|
100
|
+
|
|
96
101
|
def _normalize_parameters(
|
|
97
102
|
self, parameters: dict[str, str], errors: list[TransitionValidationError]
|
|
98
103
|
) -> list[NormalizedParameter]:
|
|
@@ -131,15 +136,21 @@ class OpenApiLink:
|
|
|
131
136
|
except Exception as exc:
|
|
132
137
|
errors.append(TransitionValidationError(str(exc)))
|
|
133
138
|
|
|
139
|
+
is_required = False
|
|
134
140
|
if hasattr(self, "target"):
|
|
135
141
|
try:
|
|
136
142
|
container_name = self._get_parameter_container(location, name)
|
|
137
143
|
except TransitionValidationError as exc:
|
|
138
144
|
errors.append(exc)
|
|
139
145
|
continue
|
|
146
|
+
|
|
147
|
+
for param in self.target.iter_parameters():
|
|
148
|
+
if param.name == name:
|
|
149
|
+
is_required = param.is_required
|
|
150
|
+
break
|
|
140
151
|
else:
|
|
141
152
|
continue
|
|
142
|
-
result.append(NormalizedParameter(location, name, expression, container_name))
|
|
153
|
+
result.append(NormalizedParameter(location, name, expression, container_name, is_required=is_required))
|
|
143
154
|
return result
|
|
144
155
|
|
|
145
156
|
def _get_parameter_container(self, location: ParameterLocation | None, name: str) -> str:
|
|
@@ -158,7 +169,7 @@ class OpenApiLink:
|
|
|
158
169
|
def _extract_impl(self, wrapper: StepOutputWrapper) -> Transition:
|
|
159
170
|
output = wrapper.output
|
|
160
171
|
return Transition(
|
|
161
|
-
id=
|
|
172
|
+
id=self.full_name,
|
|
162
173
|
parent_id=output.case.id,
|
|
163
174
|
is_inferred=self.is_inferred,
|
|
164
175
|
parameters=self.extract_parameters(output),
|
|
@@ -178,7 +189,9 @@ class OpenApiLink:
|
|
|
178
189
|
value = Ok(expressions.evaluate(parameter.expression, output))
|
|
179
190
|
except Exception as exc:
|
|
180
191
|
value = Err(exc)
|
|
181
|
-
container[parameter.name] = ExtractedParam(
|
|
192
|
+
container[parameter.name] = ExtractedParam(
|
|
193
|
+
definition=parameter.expression, value=value, is_required=parameter.is_required
|
|
194
|
+
)
|
|
182
195
|
return extracted
|
|
183
196
|
|
|
184
197
|
def extract_body(self, output: StepOutput) -> ExtractedParam | None:
|
|
@@ -188,7 +201,7 @@ class OpenApiLink:
|
|
|
188
201
|
value = Ok(expressions.evaluate(self.body, output, evaluate_nested=True))
|
|
189
202
|
except Exception as exc:
|
|
190
203
|
value = Err(exc)
|
|
191
|
-
return ExtractedParam(definition=self.body, value=value)
|
|
204
|
+
return ExtractedParam(definition=self.body, value=value, is_required=True)
|
|
192
205
|
return None
|
|
193
206
|
|
|
194
207
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: schemathesis
|
|
3
|
-
Version: 4.3.
|
|
3
|
+
Version: 4.3.13
|
|
4
4
|
Summary: Property-based testing framework for Open API and GraphQL based apps
|
|
5
5
|
Project-URL: Documentation, https://schemathesis.readthedocs.io/en/stable/
|
|
6
6
|
Project-URL: Changelog, https://github.com/schemathesis/schemathesis/blob/master/CHANGELOG.md
|
|
@@ -52,7 +52,7 @@ schemathesis/core/compat.py,sha256=9BWCrFoqN2sJIaiht_anxe8kLjYMR7t0iiOkXqLRUZ8,1
|
|
|
52
52
|
schemathesis/core/control.py,sha256=IzwIc8HIAEMtZWW0Q0iXI7T1niBpjvcLlbuwOSmy5O8,130
|
|
53
53
|
schemathesis/core/curl.py,sha256=jrPL9KpNHteyJ6A1oxJRSkL5bfuBeuPs3xh9Z_ml2cE,1892
|
|
54
54
|
schemathesis/core/deserialization.py,sha256=qjXUPaz_mc1OSgXzTUSkC8tuVR8wgVQtb9g3CcAF6D0,2951
|
|
55
|
-
schemathesis/core/errors.py,sha256=
|
|
55
|
+
schemathesis/core/errors.py,sha256=sr23WgbD-52n5fmC-QBn2suzNUbsB1okvXIs_L5EyR0,19918
|
|
56
56
|
schemathesis/core/failures.py,sha256=yFpAxWdEnm0Ri8z8RqRI9H7vcLH5ztOeSIi4m4SGx5g,8996
|
|
57
57
|
schemathesis/core/fs.py,sha256=ItQT0_cVwjDdJX9IiI7EnU75NI2H3_DCEyyUjzg_BgI,472
|
|
58
58
|
schemathesis/core/hooks.py,sha256=qhbkkRSf8URJ4LKv2wmKRINKpquUOgxQzWBHKWRWo3Q,475
|
|
@@ -89,7 +89,7 @@ schemathesis/engine/phases/stateful/__init__.py,sha256=Lz1rgNqCfUSIz173XqCGsiMuU
|
|
|
89
89
|
schemathesis/engine/phases/stateful/_executor.py,sha256=yRpUJqKLTKMVRy7hEXPwmI23CtgGIprz341lCJwvTrU,15613
|
|
90
90
|
schemathesis/engine/phases/stateful/context.py,sha256=A7X1SLDOWFpCvFN9IiIeNVZM0emjqatmJL_k9UsO7vM,2946
|
|
91
91
|
schemathesis/engine/phases/unit/__init__.py,sha256=9dDcxyj887pktnE9YDIPNaR-vc7iqKQWIrFr77SbUTQ,8786
|
|
92
|
-
schemathesis/engine/phases/unit/_executor.py,sha256=
|
|
92
|
+
schemathesis/engine/phases/unit/_executor.py,sha256=4wr7POpPfeI7_Mx6i2pk2efyK1FxKGjXdMwi_MURTDU,17427
|
|
93
93
|
schemathesis/engine/phases/unit/_pool.py,sha256=iU0hdHDmohPnEv7_S1emcabuzbTf-Cznqwn0pGQ5wNQ,2480
|
|
94
94
|
schemathesis/generation/__init__.py,sha256=tvNO2FLiY8z3fZ_kL_QJhSgzXfnT4UqwSXMHCwfLI0g,645
|
|
95
95
|
schemathesis/generation/case.py,sha256=SLMw6zkzmeiZdaIij8_0tjTF70BrMlRSWREaqWii0uM,12508
|
|
@@ -104,7 +104,7 @@ schemathesis/generation/hypothesis/examples.py,sha256=6eGaKUEC3elmKsaqfKj1sLvM8E
|
|
|
104
104
|
schemathesis/generation/hypothesis/given.py,sha256=sTZR1of6XaHAPWtHx2_WLlZ50M8D5Rjux0GmWkWjDq4,2337
|
|
105
105
|
schemathesis/generation/hypothesis/reporting.py,sha256=uDVow6Ya8YFkqQuOqRsjbzsbyP4KKfr3jA7ZaY4FuKY,279
|
|
106
106
|
schemathesis/generation/stateful/__init__.py,sha256=s7jiJEnguIj44IsRyMi8afs-8yjIUuBbzW58bH5CHjs,1042
|
|
107
|
-
schemathesis/generation/stateful/state_machine.py,sha256=
|
|
107
|
+
schemathesis/generation/stateful/state_machine.py,sha256=CiVtpBEeotpNOUkYO3vJLKRe89gdT1kjguZ88vbfqs0,9500
|
|
108
108
|
schemathesis/graphql/__init__.py,sha256=_eO6MAPHGgiADVGRntnwtPxmuvk666sAh-FAU4cG9-0,326
|
|
109
109
|
schemathesis/graphql/checks.py,sha256=IADbxiZjgkBWrC5yzHDtohRABX6zKXk5w_zpWNwdzYo,3186
|
|
110
110
|
schemathesis/graphql/loaders.py,sha256=2tgG4HIvFmjHLr_KexVXnT8hSBM-dKG_fuXTZgE97So,9445
|
|
@@ -137,7 +137,7 @@ schemathesis/specs/openapi/formats.py,sha256=4tYRdckauHxkJCmOhmdwDq_eOpHPaKloi89
|
|
|
137
137
|
schemathesis/specs/openapi/media_types.py,sha256=F5M6TKl0s6Z5X8mZpPsWDEdPBvxclKRcUOc41eEwKbo,2472
|
|
138
138
|
schemathesis/specs/openapi/patterns.py,sha256=GqPZEXMRdWENQxanWjBOalIZ2MQUjuxk21kmdiI703E,18027
|
|
139
139
|
schemathesis/specs/openapi/references.py,sha256=AW1laU23BkiRf0EEFM538vyVFLXycGUiucGVV461le0,1927
|
|
140
|
-
schemathesis/specs/openapi/schemas.py,sha256=
|
|
140
|
+
schemathesis/specs/openapi/schemas.py,sha256=ONFB8kMBrryZL_tKHWvxnBjyUHoHh_MAUqxjuVDc78c,34034
|
|
141
141
|
schemathesis/specs/openapi/serialization.py,sha256=RPNdadne5wdhsGmjSvgKLRF58wpzpRx3wura8PsHM3o,12152
|
|
142
142
|
schemathesis/specs/openapi/utils.py,sha256=XkOJT8qD-6uhq-Tmwxk_xYku1Gy5F9pKL3ldNg_DRZw,522
|
|
143
143
|
schemathesis/specs/openapi/adapter/__init__.py,sha256=YEovBgLjnXd3WGPMJXq0KbSGHezkRlEv4dNRO7_evfk,249
|
|
@@ -159,17 +159,17 @@ schemathesis/specs/openapi/negative/__init__.py,sha256=B78vps314fJOMZwlPdv7vUHo7
|
|
|
159
159
|
schemathesis/specs/openapi/negative/mutations.py,sha256=9U352xJsdZBR-Zfy1V7_X3a5i91LIUS9Zqotrzp3BLA,21000
|
|
160
160
|
schemathesis/specs/openapi/negative/types.py,sha256=a7buCcVxNBG6ILBM3A7oNTAX0lyDseEtZndBuej8MbI,174
|
|
161
161
|
schemathesis/specs/openapi/negative/utils.py,sha256=ozcOIuASufLqZSgnKUACjX-EOZrrkuNdXX0SDnLoGYA,168
|
|
162
|
-
schemathesis/specs/openapi/stateful/__init__.py,sha256=
|
|
162
|
+
schemathesis/specs/openapi/stateful/__init__.py,sha256=RpGqyjKShp2X94obaHnCR9TO6Qt_ZAarlB-awlyMzUY,18654
|
|
163
163
|
schemathesis/specs/openapi/stateful/control.py,sha256=QaXLSbwQWtai5lxvvVtQV3BLJ8n5ePqSKB00XFxp-MA,3695
|
|
164
164
|
schemathesis/specs/openapi/stateful/inference.py,sha256=B99jSTDVi2yKxU7-raIb91xpacOrr0nZkEZY5Ej3eCY,9783
|
|
165
|
-
schemathesis/specs/openapi/stateful/links.py,sha256=
|
|
166
|
-
schemathesis/specs/openapi/stateful/dependencies/__init__.py,sha256=
|
|
167
|
-
schemathesis/specs/openapi/stateful/dependencies/inputs.py,sha256=
|
|
165
|
+
schemathesis/specs/openapi/stateful/links.py,sha256=TEZ7wudPdRdP-pg5XNIgYbual_9n2arBKnS2n8SxiaU,8629
|
|
166
|
+
schemathesis/specs/openapi/stateful/dependencies/__init__.py,sha256=9FWF7tiP7GaOwapRFIYjsu16LxkosKCzBvzjkSTCsjU,8183
|
|
167
|
+
schemathesis/specs/openapi/stateful/dependencies/inputs.py,sha256=sQydINThS6vp9-OnTKCb_unoVP4m3Ho-0xTG0K7ps8Q,15915
|
|
168
168
|
schemathesis/specs/openapi/stateful/dependencies/models.py,sha256=Kl482Hwq2M8lYAdqGmf_8Yje3voSj1WLDUIujRUDWDQ,12286
|
|
169
|
-
schemathesis/specs/openapi/stateful/dependencies/naming.py,sha256=
|
|
169
|
+
schemathesis/specs/openapi/stateful/dependencies/naming.py,sha256=GoO_Tw04u_7ix6qsAzMDoJooXZqIRAIV8sLphL4mGnw,13084
|
|
170
170
|
schemathesis/specs/openapi/stateful/dependencies/outputs.py,sha256=zvVUfQWNIuhMkKDpz5hsVGkkvkefLt1EswpJAnHajOw,1186
|
|
171
|
-
schemathesis/specs/openapi/stateful/dependencies/resources.py,sha256=
|
|
172
|
-
schemathesis/specs/openapi/stateful/dependencies/schemas.py,sha256=
|
|
171
|
+
schemathesis/specs/openapi/stateful/dependencies/resources.py,sha256=eUWE26ZixPtSuHl7laF2asS_-6qklQRhyQlk7e99hlc,12087
|
|
172
|
+
schemathesis/specs/openapi/stateful/dependencies/schemas.py,sha256=TKM6hEuyLm5NB-jQeEbIXF7GZF-7mqYLq3OsTBRDpws,14878
|
|
173
173
|
schemathesis/specs/openapi/types/__init__.py,sha256=VPsWtLJle__Kodw_QqtQ3OuvBzBcCIKsTOrXy3eA7OU,66
|
|
174
174
|
schemathesis/specs/openapi/types/v3.py,sha256=Vondr9Amk6JKCIM6i6RGcmTUjFfPgOOqzBXqerccLpo,1468
|
|
175
175
|
schemathesis/transport/__init__.py,sha256=6yg_RfV_9L0cpA6qpbH-SL9_3ggtHQji9CZrpIkbA6s,5321
|
|
@@ -178,8 +178,8 @@ schemathesis/transport/prepare.py,sha256=erYXRaxpQokIDzaIuvt_csHcw72iHfCyNq8VNEz
|
|
|
178
178
|
schemathesis/transport/requests.py,sha256=wriRI9fprTplE_qEZLEz1TerX6GwkE3pwr6ZnU2o6vQ,10648
|
|
179
179
|
schemathesis/transport/serialization.py,sha256=GwO6OAVTmL1JyKw7HiZ256tjV4CbrRbhQN0ep1uaZwI,11157
|
|
180
180
|
schemathesis/transport/wsgi.py,sha256=kQtasFre6pjdJWRKwLA_Qb-RyQHCFNpaey9ubzlFWKI,5907
|
|
181
|
-
schemathesis-4.3.
|
|
182
|
-
schemathesis-4.3.
|
|
183
|
-
schemathesis-4.3.
|
|
184
|
-
schemathesis-4.3.
|
|
185
|
-
schemathesis-4.3.
|
|
181
|
+
schemathesis-4.3.13.dist-info/METADATA,sha256=n-G9iaIj5lgmV9oro_G7FL4f2tAeI9xOXaLe95r6WnA,8566
|
|
182
|
+
schemathesis-4.3.13.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
183
|
+
schemathesis-4.3.13.dist-info/entry_points.txt,sha256=hiK3un-xfgPdwj9uj16YVDtTNpO128bmk0U82SMv8ZQ,152
|
|
184
|
+
schemathesis-4.3.13.dist-info/licenses/LICENSE,sha256=2Ve4J8v5jMQAWrT7r1nf3bI8Vflk3rZVQefiF2zpxwg,1121
|
|
185
|
+
schemathesis-4.3.13.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|