schemathesis 4.3.2__py3-none-any.whl → 4.3.4__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/checks.py +1 -1
- schemathesis/cli/commands/run/context.py +2 -1
- schemathesis/cli/commands/run/handlers/output.py +5 -1
- schemathesis/engine/context.py +1 -25
- schemathesis/engine/phases/unit/_executor.py +1 -1
- schemathesis/engine/recorder.py +11 -3
- schemathesis/generation/stateful/state_machine.py +8 -3
- schemathesis/specs/openapi/stateful/__init__.py +17 -6
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +85 -1
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +57 -1
- schemathesis/specs/openapi/stateful/dependencies/models.py +38 -7
- schemathesis/specs/openapi/stateful/dependencies/naming.py +139 -53
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +21 -10
- schemathesis/specs/openapi/stateful/inference.py +3 -2
- schemathesis/specs/openapi/stateful/links.py +15 -2
- {schemathesis-4.3.2.dist-info → schemathesis-4.3.4.dist-info}/METADATA +1 -1
- {schemathesis-4.3.2.dist-info → schemathesis-4.3.4.dist-info}/RECORD +20 -20
- {schemathesis-4.3.2.dist-info → schemathesis-4.3.4.dist-info}/WHEEL +0 -0
- {schemathesis-4.3.2.dist-info → schemathesis-4.3.4.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.3.2.dist-info → schemathesis-4.3.4.dist-info}/licenses/LICENSE +0 -0
schemathesis/checks.py
CHANGED
|
@@ -81,7 +81,7 @@ class CheckContext:
|
|
|
81
81
|
|
|
82
82
|
def _record_case(self, *, parent_id: str, case: Case) -> None:
|
|
83
83
|
if self._recorder is not None:
|
|
84
|
-
self._recorder.record_case(parent_id=parent_id, transition=None,
|
|
84
|
+
self._recorder.record_case(parent_id=parent_id, case=case, transition=None, is_transition_applied=False)
|
|
85
85
|
|
|
86
86
|
def _record_response(self, *, case_id: str, response: Response) -> None:
|
|
87
87
|
if self._recorder is not None:
|
|
@@ -112,7 +112,8 @@ class Statistic:
|
|
|
112
112
|
if has_failures:
|
|
113
113
|
self.cases_with_failures += 1
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
# Don't report extraction failures for inferred transitions
|
|
116
|
+
if case.transition is None or case.transition.is_inferred:
|
|
116
117
|
continue
|
|
117
118
|
transition = case.transition
|
|
118
119
|
parent = recorder.cases[transition.parent_id]
|
|
@@ -1048,7 +1048,11 @@ class OutputHandler(EventHandler):
|
|
|
1048
1048
|
and event.status not in (Status.INTERRUPTED, Status.SKIP, None)
|
|
1049
1049
|
):
|
|
1050
1050
|
assert self.stateful_tests_manager is not None
|
|
1051
|
-
links_seen = {
|
|
1051
|
+
links_seen = {
|
|
1052
|
+
case.transition.id
|
|
1053
|
+
for case in event.recorder.cases.values()
|
|
1054
|
+
if case.transition is not None and case.is_transition_applied
|
|
1055
|
+
}
|
|
1052
1056
|
self.stateful_tests_manager.update(links_seen, event.status)
|
|
1053
1057
|
self._check_stateful_warnings(ctx, event)
|
|
1054
1058
|
|
schemathesis/engine/context.py
CHANGED
|
@@ -105,18 +105,7 @@ class EngineContext:
|
|
|
105
105
|
InferenceAlgorithm.DEPENDENCY_ANALYSIS
|
|
106
106
|
)
|
|
107
107
|
):
|
|
108
|
-
|
|
109
|
-
for response_links in graph.iter_links():
|
|
110
|
-
operation = self.schema.get_operation_by_reference(response_links.producer_operation_ref)
|
|
111
|
-
response = operation.responses.get(response_links.status_code)
|
|
112
|
-
links = response.definition.setdefault(self.schema.adapter.links_keyword, {})
|
|
113
|
-
|
|
114
|
-
for link_name, definition in response_links.links.items():
|
|
115
|
-
# Find unique name if collision exists
|
|
116
|
-
final_name = _resolve_link_name_collision(link_name, links)
|
|
117
|
-
links[final_name] = definition.to_openapi()
|
|
118
|
-
injected += 1
|
|
119
|
-
|
|
108
|
+
injected += dependencies.inject_links(self.schema)
|
|
120
109
|
return injected
|
|
121
110
|
|
|
122
111
|
def stop(self) -> None:
|
|
@@ -171,16 +160,3 @@ class EngineContext:
|
|
|
171
160
|
kwargs["proxies"] = {"all": proxy}
|
|
172
161
|
self._transport_kwargs_cache[key] = kwargs
|
|
173
162
|
return kwargs
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def _resolve_link_name_collision(proposed_name: str, existing_links: dict[str, Any]) -> str:
|
|
177
|
-
if proposed_name not in existing_links:
|
|
178
|
-
return proposed_name
|
|
179
|
-
|
|
180
|
-
# Name collision - find next available suffix
|
|
181
|
-
suffix = 0
|
|
182
|
-
while True:
|
|
183
|
-
candidate = f"{proposed_name}_{suffix}"
|
|
184
|
-
if candidate not in existing_links:
|
|
185
|
-
return candidate
|
|
186
|
-
suffix += 1
|
|
@@ -385,7 +385,7 @@ def test_func(
|
|
|
385
385
|
transport_kwargs: dict[str, Any],
|
|
386
386
|
continue_on_failure: bool,
|
|
387
387
|
) -> None:
|
|
388
|
-
recorder.record_case(parent_id=None, transition=None,
|
|
388
|
+
recorder.record_case(parent_id=None, case=case, transition=None, is_transition_applied=False)
|
|
389
389
|
try:
|
|
390
390
|
response = case.call(**transport_kwargs)
|
|
391
391
|
except (requests.Timeout, requests.ConnectionError, ChunkedEncodingError) as error:
|
schemathesis/engine/recorder.py
CHANGED
|
@@ -41,9 +41,16 @@ class ScenarioRecorder:
|
|
|
41
41
|
self.checks = {}
|
|
42
42
|
self.interactions = {}
|
|
43
43
|
|
|
44
|
-
def record_case(
|
|
44
|
+
def record_case(
|
|
45
|
+
self, *, parent_id: str | None, case: Case, transition: Transition | None, is_transition_applied: bool
|
|
46
|
+
) -> None:
|
|
45
47
|
"""Record a test case and its relationship to a parent, if applicable."""
|
|
46
|
-
self.cases[case.id] = CaseNode(
|
|
48
|
+
self.cases[case.id] = CaseNode(
|
|
49
|
+
value=case,
|
|
50
|
+
parent_id=parent_id,
|
|
51
|
+
transition=transition,
|
|
52
|
+
is_transition_applied=is_transition_applied,
|
|
53
|
+
)
|
|
47
54
|
|
|
48
55
|
def record_response(self, *, case_id: str, response: Response) -> None:
|
|
49
56
|
"""Record the API response for a given test case."""
|
|
@@ -139,8 +146,9 @@ class CaseNode:
|
|
|
139
146
|
# Transition may be absent if `parent_id` is present for cases when a case is derived inside a check
|
|
140
147
|
# and outside of the implemented transition logic (e.g. Open API links)
|
|
141
148
|
transition: Transition | None
|
|
149
|
+
is_transition_applied: bool
|
|
142
150
|
|
|
143
|
-
__slots__ = ("value", "parent_id", "transition")
|
|
151
|
+
__slots__ = ("value", "parent_id", "transition", "is_transition_applied")
|
|
144
152
|
|
|
145
153
|
|
|
146
154
|
@dataclass
|
|
@@ -37,12 +37,16 @@ class StepInput:
|
|
|
37
37
|
|
|
38
38
|
case: Case
|
|
39
39
|
transition: Transition | None # None for initial steps
|
|
40
|
+
# Whether this transition was actually applied
|
|
41
|
+
# Data extraction failures can prevent it, as well as transitions can be skipped in some cases
|
|
42
|
+
# to improve discovery of bugs triggered by non-stateful inputs during stateful testing
|
|
43
|
+
is_applied: bool
|
|
40
44
|
|
|
41
|
-
__slots__ = ("case", "transition")
|
|
45
|
+
__slots__ = ("case", "transition", "is_applied")
|
|
42
46
|
|
|
43
47
|
@classmethod
|
|
44
48
|
def initial(cls, case: Case) -> StepInput:
|
|
45
|
-
return cls(case=case, transition=None)
|
|
49
|
+
return cls(case=case, transition=None, is_applied=False)
|
|
46
50
|
|
|
47
51
|
|
|
48
52
|
@dataclass
|
|
@@ -52,10 +56,11 @@ class Transition:
|
|
|
52
56
|
# ID of the transition (e.g. link name)
|
|
53
57
|
id: str
|
|
54
58
|
parent_id: str
|
|
59
|
+
is_inferred: bool
|
|
55
60
|
parameters: dict[str, dict[str, ExtractedParam]]
|
|
56
61
|
request_body: ExtractedParam | None
|
|
57
62
|
|
|
58
|
-
__slots__ = ("id", "parent_id", "parameters", "request_body")
|
|
63
|
+
__slots__ = ("id", "parent_id", "is_inferred", "parameters", "request_body")
|
|
59
64
|
|
|
60
65
|
|
|
61
66
|
@dataclass
|
|
@@ -49,6 +49,8 @@ class OpenAPIStateMachine(APIStateMachine):
|
|
|
49
49
|
|
|
50
50
|
# The proportion of negative tests generated for "root" transitions
|
|
51
51
|
NEGATIVE_TEST_CASES_THRESHOLD = 10
|
|
52
|
+
# How often some transition is skipped
|
|
53
|
+
USE_TRANSITION_THRESHOLD = 85
|
|
52
54
|
|
|
53
55
|
|
|
54
56
|
@dataclass
|
|
@@ -226,7 +228,7 @@ def classify_root_transitions(operations: list[APIOperation], transitions: ApiTr
|
|
|
226
228
|
if not operation_transitions or not operation_transitions.outgoing:
|
|
227
229
|
continue
|
|
228
230
|
|
|
229
|
-
if is_likely_root_transition(operation
|
|
231
|
+
if is_likely_root_transition(operation):
|
|
230
232
|
roots.reliable.add(operation.label)
|
|
231
233
|
else:
|
|
232
234
|
roots.fallback.add(operation.label)
|
|
@@ -234,7 +236,7 @@ def classify_root_transitions(operations: list[APIOperation], transitions: ApiTr
|
|
|
234
236
|
return roots
|
|
235
237
|
|
|
236
238
|
|
|
237
|
-
def is_likely_root_transition(operation: APIOperation
|
|
239
|
+
def is_likely_root_transition(operation: APIOperation) -> bool:
|
|
238
240
|
"""Check if operation is likely to succeed as a root transition."""
|
|
239
241
|
# POST operations with request bodies are likely to create resources
|
|
240
242
|
if operation.method == "post" and operation.body:
|
|
@@ -269,8 +271,12 @@ def into_step_input(
|
|
|
269
271
|
and isinstance(transition.request_body.value, Ok)
|
|
270
272
|
and transition.request_body.value.ok() is not UNRESOLVABLE
|
|
271
273
|
and not link.merge_body
|
|
274
|
+
and draw(st.integers(min_value=0, max_value=99)) < USE_TRANSITION_THRESHOLD
|
|
272
275
|
):
|
|
273
276
|
kwargs["body"] = transition.request_body.value.ok()
|
|
277
|
+
|
|
278
|
+
is_applied = bool(kwargs)
|
|
279
|
+
|
|
274
280
|
cases = strategies.combine(
|
|
275
281
|
[target.as_strategy(generation_mode=mode, phase=TestPhase.STATEFUL, **kwargs) for mode in modes]
|
|
276
282
|
)
|
|
@@ -280,24 +286,26 @@ def into_step_input(
|
|
|
280
286
|
and isinstance(transition.request_body.value, Ok)
|
|
281
287
|
and transition.request_body.value.ok() is not UNRESOLVABLE
|
|
282
288
|
and link.merge_body
|
|
289
|
+
and draw(st.integers(min_value=0, max_value=99)) < USE_TRANSITION_THRESHOLD
|
|
283
290
|
):
|
|
284
291
|
new = transition.request_body.value.ok()
|
|
285
292
|
if isinstance(case.body, dict) and isinstance(new, dict):
|
|
286
293
|
case.body = {**case.body, **new}
|
|
287
294
|
else:
|
|
288
295
|
case.body = new
|
|
296
|
+
is_applied = True
|
|
289
297
|
if case.meta and case.meta.generation.mode == GenerationMode.NEGATIVE:
|
|
290
298
|
# It is possible that the new body is now valid and the whole test case could be valid too
|
|
291
299
|
for alternative in case.operation.body:
|
|
292
300
|
if alternative.media_type == case.media_type:
|
|
293
301
|
schema = alternative.optimized_schema
|
|
294
|
-
if jsonschema.validators.validator_for(schema)(schema).is_valid(
|
|
302
|
+
if jsonschema.validators.validator_for(schema)(schema).is_valid(case.body):
|
|
295
303
|
case.meta.components[ParameterLocation.BODY] = ComponentInfo(
|
|
296
304
|
mode=GenerationMode.POSITIVE
|
|
297
305
|
)
|
|
298
306
|
if all(info.mode == GenerationMode.POSITIVE for info in case.meta.components.values()):
|
|
299
307
|
case.meta.generation.mode = GenerationMode.POSITIVE
|
|
300
|
-
return StepInput(case=case, transition=transition)
|
|
308
|
+
return StepInput(case=case, transition=transition, is_applied=is_applied)
|
|
301
309
|
|
|
302
310
|
return inner(output=_output)
|
|
303
311
|
|
|
@@ -322,10 +330,13 @@ def transition(*, name: str, target: Bundle, input: st.SearchStrategy[StepInput]
|
|
|
322
330
|
def step_function(self: OpenAPIStateMachine, input: StepInput) -> StepOutput | None:
|
|
323
331
|
if input.transition is not None:
|
|
324
332
|
self.recorder.record_case(
|
|
325
|
-
parent_id=input.transition.parent_id,
|
|
333
|
+
parent_id=input.transition.parent_id,
|
|
334
|
+
transition=input.transition,
|
|
335
|
+
case=input.case,
|
|
336
|
+
is_transition_applied=input.is_applied,
|
|
326
337
|
)
|
|
327
338
|
else:
|
|
328
|
-
self.recorder.record_case(parent_id=None, transition=None,
|
|
339
|
+
self.recorder.record_case(parent_id=None, case=input.case, transition=None, is_transition_applied=False)
|
|
329
340
|
self.control.record_step(input, self.recorder)
|
|
330
341
|
return APIStateMachine._step(self, input=input)
|
|
331
342
|
|
|
@@ -5,7 +5,7 @@ Infers which operations must run before others by tracking resource creation and
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
from typing import TYPE_CHECKING
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
9
|
|
|
10
10
|
from schemathesis.core.compat import RefResolutionError
|
|
11
11
|
from schemathesis.core.result import Ok
|
|
@@ -16,6 +16,7 @@ from schemathesis.specs.openapi.stateful.dependencies.models import (
|
|
|
16
16
|
DefinitionSource,
|
|
17
17
|
DependencyGraph,
|
|
18
18
|
InputSlot,
|
|
19
|
+
NormalizedLink,
|
|
19
20
|
OperationMap,
|
|
20
21
|
OperationNode,
|
|
21
22
|
OutputSlot,
|
|
@@ -25,6 +26,7 @@ from schemathesis.specs.openapi.stateful.dependencies.models import (
|
|
|
25
26
|
from schemathesis.specs.openapi.stateful.dependencies.outputs import extract_outputs
|
|
26
27
|
|
|
27
28
|
if TYPE_CHECKING:
|
|
29
|
+
from schemathesis.schemas import APIOperation
|
|
28
30
|
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
|
29
31
|
|
|
30
32
|
__all__ = [
|
|
@@ -86,3 +88,85 @@ def analyze(schema: BaseOpenAPISchema) -> DependencyGraph:
|
|
|
86
88
|
update_input_field_bindings(resource, operations)
|
|
87
89
|
|
|
88
90
|
return DependencyGraph(operations=operations, resources=resources)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def inject_links(schema: BaseOpenAPISchema) -> int:
|
|
94
|
+
injected = 0
|
|
95
|
+
graph = analyze(schema)
|
|
96
|
+
for response_links in graph.iter_links():
|
|
97
|
+
operation = schema.get_operation_by_reference(response_links.producer_operation_ref)
|
|
98
|
+
response = operation.responses.get(response_links.status_code)
|
|
99
|
+
links = response.definition.setdefault(schema.adapter.links_keyword, {})
|
|
100
|
+
|
|
101
|
+
# Normalize existing links once
|
|
102
|
+
if links:
|
|
103
|
+
normalized_existing = [_normalize_link(link, schema) for link in links.values()]
|
|
104
|
+
else:
|
|
105
|
+
normalized_existing = []
|
|
106
|
+
|
|
107
|
+
for link_name, definition in response_links.links.items():
|
|
108
|
+
inferred_link = definition.to_openapi()
|
|
109
|
+
|
|
110
|
+
# Check if duplicate exists
|
|
111
|
+
if normalized_existing:
|
|
112
|
+
if _normalize_link(inferred_link, schema) in normalized_existing:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
# Find unique name if collision exists
|
|
116
|
+
final_name = _resolve_link_name_collision(link_name, links)
|
|
117
|
+
links[final_name] = inferred_link
|
|
118
|
+
injected += 1
|
|
119
|
+
return injected
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _normalize_link(link: dict[str, Any], schema: BaseOpenAPISchema) -> NormalizedLink:
|
|
123
|
+
"""Normalize a link definition for comparison."""
|
|
124
|
+
operation = _resolve_link_operation(link, schema)
|
|
125
|
+
|
|
126
|
+
normalized_params = _normalize_parameter_keys(link.get("parameters", {}), operation)
|
|
127
|
+
|
|
128
|
+
return NormalizedLink(
|
|
129
|
+
path=operation.path,
|
|
130
|
+
method=operation.method,
|
|
131
|
+
parameters=normalized_params,
|
|
132
|
+
request_body=link.get("requestBody", {}),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _normalize_parameter_keys(parameters: dict, operation: APIOperation) -> set[str]:
|
|
137
|
+
"""Normalize parameter keys to location.name format."""
|
|
138
|
+
normalized = set()
|
|
139
|
+
|
|
140
|
+
for parameter_name in parameters.keys():
|
|
141
|
+
# If already has location prefix, use as-is
|
|
142
|
+
if "." in parameter_name:
|
|
143
|
+
normalized.add(parameter_name)
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
# Find the parameter and prepend location
|
|
147
|
+
for parameter in operation.iter_parameters():
|
|
148
|
+
if parameter.name == parameter_name:
|
|
149
|
+
normalized.add(f"{parameter.location.value}.{parameter_name}")
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
return normalized
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _resolve_link_operation(link: dict, schema: BaseOpenAPISchema) -> APIOperation:
|
|
156
|
+
"""Resolve link to operation, handling both operationRef and operationId."""
|
|
157
|
+
if "operationRef" in link:
|
|
158
|
+
return schema.get_operation_by_reference(link["operationRef"])
|
|
159
|
+
return schema.get_operation_by_id(link["operationId"])
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _resolve_link_name_collision(proposed_name: str, existing_links: dict[str, Any]) -> str:
|
|
163
|
+
"""Find unique link name if collision exists."""
|
|
164
|
+
if proposed_name not in existing_links:
|
|
165
|
+
return proposed_name
|
|
166
|
+
|
|
167
|
+
suffix = 0
|
|
168
|
+
while True:
|
|
169
|
+
candidate = f"{proposed_name}_{suffix}"
|
|
170
|
+
if candidate not in existing_links:
|
|
171
|
+
return candidate
|
|
172
|
+
suffix += 1
|
|
@@ -2,6 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING, Iterator
|
|
4
4
|
|
|
5
|
+
from schemathesis.core import media_types
|
|
6
|
+
from schemathesis.core.errors import MalformedMediaType
|
|
7
|
+
from schemathesis.core.jsonschema.bundler import BUNDLE_STORAGE_KEY
|
|
5
8
|
from schemathesis.core.parameters import ParameterLocation
|
|
6
9
|
from schemathesis.specs.openapi.stateful.dependencies import naming
|
|
7
10
|
from schemathesis.specs.openapi.stateful.dependencies.models import (
|
|
@@ -16,6 +19,7 @@ from schemathesis.specs.openapi.stateful.dependencies.resources import extract_r
|
|
|
16
19
|
|
|
17
20
|
if TYPE_CHECKING:
|
|
18
21
|
from schemathesis.core.compat import RefResolver
|
|
22
|
+
from schemathesis.specs.openapi.adapter.parameters import OpenApiBody
|
|
19
23
|
from schemathesis.specs.openapi.schemas import APIOperation
|
|
20
24
|
|
|
21
25
|
|
|
@@ -32,7 +36,6 @@ def extract_inputs(
|
|
|
32
36
|
Connects each parameter (e.g., `userId`) to its resource definition (`User`),
|
|
33
37
|
creating placeholder resources if not yet discovered from their schemas.
|
|
34
38
|
"""
|
|
35
|
-
# Note: Currently limited to path parameters. Query / header / body will be supported in future releases.
|
|
36
39
|
for param in operation.path_parameters:
|
|
37
40
|
input_slot = _resolve_parameter_dependency(
|
|
38
41
|
parameter_name=param.name,
|
|
@@ -46,6 +49,13 @@ def extract_inputs(
|
|
|
46
49
|
if input_slot is not None:
|
|
47
50
|
yield input_slot
|
|
48
51
|
|
|
52
|
+
for body in operation.body:
|
|
53
|
+
try:
|
|
54
|
+
if media_types.is_json(body.media_type):
|
|
55
|
+
yield from _resolve_body_dependencies(body=body, operation=operation, resources=resources)
|
|
56
|
+
except MalformedMediaType:
|
|
57
|
+
continue
|
|
58
|
+
|
|
49
59
|
|
|
50
60
|
def _resolve_parameter_dependency(
|
|
51
61
|
*,
|
|
@@ -152,6 +162,52 @@ def _find_resource_in_responses(
|
|
|
152
162
|
return None
|
|
153
163
|
|
|
154
164
|
|
|
165
|
+
def _resolve_body_dependencies(
|
|
166
|
+
*, body: OpenApiBody, operation: APIOperation, resources: ResourceMap
|
|
167
|
+
) -> Iterator[InputSlot]:
|
|
168
|
+
schema = body.raw_schema
|
|
169
|
+
if not isinstance(schema, dict):
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
# Right now, the body schema comes bundled to dependency analysis
|
|
173
|
+
if BUNDLE_STORAGE_KEY in schema and "$ref" in schema:
|
|
174
|
+
schema_key = schema["$ref"].split("/")[-1]
|
|
175
|
+
resolved = schema[BUNDLE_STORAGE_KEY][schema_key]
|
|
176
|
+
else:
|
|
177
|
+
resolved = schema
|
|
178
|
+
|
|
179
|
+
# Inspect each property that could be a part of some other resource
|
|
180
|
+
properties = resolved.get("properties", {})
|
|
181
|
+
path = operation.path
|
|
182
|
+
for property_name in properties:
|
|
183
|
+
resource_name = naming.from_parameter(property_name, path)
|
|
184
|
+
if resource_name is None:
|
|
185
|
+
continue
|
|
186
|
+
resource = resources.get(resource_name)
|
|
187
|
+
if resource is None:
|
|
188
|
+
resource = ResourceDefinition.inferred_from_parameter(
|
|
189
|
+
name=resource_name,
|
|
190
|
+
parameter_name=property_name,
|
|
191
|
+
)
|
|
192
|
+
resources[resource_name] = resource
|
|
193
|
+
field = property_name
|
|
194
|
+
else:
|
|
195
|
+
field = (
|
|
196
|
+
naming.find_matching_field(
|
|
197
|
+
parameter=property_name,
|
|
198
|
+
resource=resource_name,
|
|
199
|
+
fields=resource.fields,
|
|
200
|
+
)
|
|
201
|
+
or "id"
|
|
202
|
+
)
|
|
203
|
+
yield InputSlot(
|
|
204
|
+
resource=resource,
|
|
205
|
+
resource_field=field,
|
|
206
|
+
parameter_name=property_name,
|
|
207
|
+
parameter_location=ParameterLocation.BODY,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
155
211
|
def update_input_field_bindings(resource_name: str, operations: OperationMap) -> None:
|
|
156
212
|
"""Update input slots field bindings after resource definition was upgraded.
|
|
157
213
|
|
|
@@ -9,6 +9,7 @@ from typing_extensions import TypeAlias
|
|
|
9
9
|
|
|
10
10
|
from schemathesis.core.parameters import ParameterLocation
|
|
11
11
|
from schemathesis.core.transforms import encode_pointer
|
|
12
|
+
from schemathesis.specs.openapi.stateful.links import SCHEMATHESIS_LINK_EXTENSION
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
@dataclass
|
|
@@ -61,12 +62,21 @@ class DependencyGraph:
|
|
|
61
62
|
output_slot.pointer, input_slot.resource_field, output_slot.cardinality
|
|
62
63
|
)
|
|
63
64
|
link_name = f"{consumer.method.capitalize()}{input_slot.resource.name}"
|
|
65
|
+
parameters = {}
|
|
66
|
+
request_body = {}
|
|
67
|
+
# Data is extracted from response body
|
|
68
|
+
if input_slot.parameter_location == ParameterLocation.BODY:
|
|
69
|
+
request_body = {
|
|
70
|
+
input_slot.parameter_name: f"$response.body#{body_pointer}",
|
|
71
|
+
}
|
|
72
|
+
else:
|
|
73
|
+
parameters = {
|
|
74
|
+
f"{input_slot.parameter_location.value}.{input_slot.parameter_name}": f"$response.body#{body_pointer}",
|
|
75
|
+
}
|
|
64
76
|
links[link_name] = LinkDefinition(
|
|
65
77
|
operation_ref=f"#/paths/{consumer_path}/{consumer.method}",
|
|
66
|
-
parameters=
|
|
67
|
-
|
|
68
|
-
f"{input_slot.parameter_location.value}.{input_slot.parameter_name}": f"$response.body#{body_pointer}",
|
|
69
|
-
},
|
|
78
|
+
parameters=parameters,
|
|
79
|
+
request_body=request_body,
|
|
70
80
|
)
|
|
71
81
|
if links:
|
|
72
82
|
yield ResponseLinks(
|
|
@@ -136,14 +146,23 @@ class LinkDefinition:
|
|
|
136
146
|
parameters: dict[str, str]
|
|
137
147
|
"""Parameter mappings (e.g., {'path.id': '$response.body#/id'})"""
|
|
138
148
|
|
|
139
|
-
|
|
149
|
+
request_body: dict[str, str]
|
|
150
|
+
"""Request body (e.g., {'path.id': '$response.body#/id'})"""
|
|
151
|
+
|
|
152
|
+
__slots__ = ("operation_ref", "parameters", "request_body")
|
|
140
153
|
|
|
141
154
|
def to_openapi(self) -> dict[str, Any]:
|
|
142
155
|
"""Convert to OpenAPI Links format."""
|
|
143
|
-
|
|
156
|
+
links: dict[str, Any] = {
|
|
144
157
|
"operationRef": self.operation_ref,
|
|
145
|
-
"
|
|
158
|
+
SCHEMATHESIS_LINK_EXTENSION: {"is_inferred": True},
|
|
146
159
|
}
|
|
160
|
+
if self.parameters:
|
|
161
|
+
links["parameters"] = self.parameters
|
|
162
|
+
if self.request_body:
|
|
163
|
+
links["requestBody"] = self.request_body
|
|
164
|
+
links[SCHEMATHESIS_LINK_EXTENSION]["merge_body"] = True
|
|
165
|
+
return links
|
|
147
166
|
|
|
148
167
|
|
|
149
168
|
@dataclass
|
|
@@ -177,6 +196,18 @@ class ResponseLinks:
|
|
|
177
196
|
return {name: link_def.to_openapi() for name, link_def in self.links.items()}
|
|
178
197
|
|
|
179
198
|
|
|
199
|
+
@dataclass
|
|
200
|
+
class NormalizedLink:
|
|
201
|
+
"""Normalized representation of a link."""
|
|
202
|
+
|
|
203
|
+
path: str
|
|
204
|
+
method: str
|
|
205
|
+
parameters: set[str]
|
|
206
|
+
request_body: Any
|
|
207
|
+
|
|
208
|
+
__slots__ = ("path", "method", "parameters", "request_body")
|
|
209
|
+
|
|
210
|
+
|
|
180
211
|
class Cardinality(str, enum.Enum):
|
|
181
212
|
"""Whether there is one or many resources in a slot."""
|
|
182
213
|
|
|
@@ -27,76 +27,122 @@ def from_path(path: str) -> str | None:
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
IRREGULAR_TO_PLURAL = {
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"tornado": "tornadoes",
|
|
34
|
-
"torpedo": "torpedoes",
|
|
35
|
-
"genus": "genera",
|
|
36
|
-
"viscus": "viscera",
|
|
37
|
-
"stigma": "stigmata",
|
|
38
|
-
"stoma": "stomata",
|
|
39
|
-
"dogma": "dogmata",
|
|
40
|
-
"lemma": "lemmata",
|
|
30
|
+
"abuse": "abuses",
|
|
31
|
+
"alias": "aliases",
|
|
32
|
+
"analysis": "analyses",
|
|
41
33
|
"anathema": "anathemata",
|
|
42
|
-
"ox": "oxen",
|
|
43
34
|
"axe": "axes",
|
|
35
|
+
"base": "bases",
|
|
36
|
+
"bookshelf": "bookshelves",
|
|
37
|
+
"cache": "caches",
|
|
38
|
+
"canvas": "canvases",
|
|
39
|
+
"carve": "carves",
|
|
40
|
+
"case": "cases",
|
|
41
|
+
"cause": "causes",
|
|
42
|
+
"child": "children",
|
|
43
|
+
"course": "courses",
|
|
44
|
+
"criterion": "criteria",
|
|
45
|
+
"database": "databases",
|
|
46
|
+
"defense": "defenses",
|
|
47
|
+
"diagnosis": "diagnoses",
|
|
44
48
|
"die": "dice",
|
|
45
|
-
"
|
|
46
|
-
"
|
|
49
|
+
"dingo": "dingoes",
|
|
50
|
+
"disease": "diseases",
|
|
51
|
+
"dogma": "dogmata",
|
|
52
|
+
"dose": "doses",
|
|
47
53
|
"eave": "eaves",
|
|
54
|
+
"echo": "echoes",
|
|
55
|
+
"enterprise": "enterprises",
|
|
56
|
+
"ephemeris": "ephemerides",
|
|
57
|
+
"excuse": "excuses",
|
|
58
|
+
"expense": "expenses",
|
|
59
|
+
"foot": "feet",
|
|
60
|
+
"franchise": "franchises",
|
|
61
|
+
"genus": "genera",
|
|
48
62
|
"goose": "geese",
|
|
49
|
-
"tooth": "teeth",
|
|
50
|
-
"quiz": "quizzes",
|
|
51
|
-
"human": "humans",
|
|
52
|
-
"proof": "proofs",
|
|
53
|
-
"carve": "carves",
|
|
54
|
-
"valve": "valves",
|
|
55
|
-
"looey": "looies",
|
|
56
|
-
"thief": "thieves",
|
|
57
63
|
"groove": "grooves",
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"canvas": "canvases",
|
|
61
|
-
"use": "uses",
|
|
62
|
-
"case": "cases",
|
|
63
|
-
"vase": "vases",
|
|
64
|
+
"half": "halves",
|
|
65
|
+
"horse": "horses",
|
|
64
66
|
"house": "houses",
|
|
67
|
+
"human": "humans",
|
|
68
|
+
"hypothesis": "hypotheses",
|
|
69
|
+
"index": "indices",
|
|
70
|
+
"knife": "knives",
|
|
71
|
+
"lemma": "lemmata",
|
|
72
|
+
"license": "licenses",
|
|
73
|
+
"life": "lives",
|
|
74
|
+
"loaf": "loaves",
|
|
75
|
+
"looey": "looies",
|
|
76
|
+
"man": "men",
|
|
77
|
+
"matrix": "matrices",
|
|
65
78
|
"mouse": "mice",
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
79
|
+
"movie": "movies",
|
|
80
|
+
"nose": "noses",
|
|
81
|
+
"oasis": "oases",
|
|
82
|
+
"ox": "oxen",
|
|
83
|
+
"passerby": "passersby",
|
|
70
84
|
"pause": "pauses",
|
|
71
|
-
"
|
|
85
|
+
"person": "people",
|
|
72
86
|
"phase": "phases",
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"course": "courses",
|
|
87
|
+
"phenomenon": "phenomena",
|
|
88
|
+
"pickaxe": "pickaxes",
|
|
89
|
+
"proof": "proofs",
|
|
90
|
+
"purchase": "purchases",
|
|
78
91
|
"purpose": "purposes",
|
|
92
|
+
"quiz": "quizzes",
|
|
93
|
+
"radius": "radii",
|
|
94
|
+
"release": "releases",
|
|
79
95
|
"response": "responses",
|
|
96
|
+
"reuse": "reuses",
|
|
97
|
+
"rose": "roses",
|
|
98
|
+
"scarf": "scarves",
|
|
99
|
+
"self": "selves",
|
|
80
100
|
"sense": "senses",
|
|
101
|
+
"shelf": "shelves",
|
|
102
|
+
"size": "sizes",
|
|
103
|
+
"snooze": "snoozes",
|
|
104
|
+
"stigma": "stigmata",
|
|
105
|
+
"stoma": "stomata",
|
|
106
|
+
"synopsis": "synopses",
|
|
81
107
|
"tense": "tenses",
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
"
|
|
108
|
+
"thief": "thieves",
|
|
109
|
+
"tooth": "teeth",
|
|
110
|
+
"tornado": "tornadoes",
|
|
111
|
+
"torpedo": "torpedoes",
|
|
112
|
+
"use": "uses",
|
|
113
|
+
"valve": "valves",
|
|
114
|
+
"vase": "vases",
|
|
115
|
+
"verse": "verses",
|
|
116
|
+
"viscus": "viscera",
|
|
117
|
+
"volcano": "volcanoes",
|
|
118
|
+
"warehouse": "warehouses",
|
|
119
|
+
"wave": "waves",
|
|
120
|
+
"wife": "wives",
|
|
121
|
+
"wolf": "wolves",
|
|
122
|
+
"woman": "women",
|
|
123
|
+
"yes": "yeses",
|
|
124
|
+
"vie": "vies",
|
|
85
125
|
}
|
|
86
126
|
IRREGULAR_TO_SINGULAR = {v: k for k, v in IRREGULAR_TO_PLURAL.items()}
|
|
87
127
|
UNCOUNTABLE = frozenset(
|
|
88
128
|
[
|
|
129
|
+
"access",
|
|
130
|
+
"address",
|
|
89
131
|
"adulthood",
|
|
90
132
|
"advice",
|
|
91
133
|
"agenda",
|
|
92
134
|
"aid",
|
|
93
135
|
"aircraft",
|
|
94
136
|
"alcohol",
|
|
137
|
+
"alias",
|
|
95
138
|
"ammo",
|
|
139
|
+
"analysis",
|
|
96
140
|
"analytics",
|
|
97
141
|
"anime",
|
|
142
|
+
"anonymous",
|
|
98
143
|
"athletics",
|
|
99
144
|
"audio",
|
|
145
|
+
"bias",
|
|
100
146
|
"bison",
|
|
101
147
|
"blood",
|
|
102
148
|
"bream",
|
|
@@ -104,22 +150,31 @@ UNCOUNTABLE = frozenset(
|
|
|
104
150
|
"butter",
|
|
105
151
|
"carp",
|
|
106
152
|
"cash",
|
|
153
|
+
"chaos",
|
|
107
154
|
"chassis",
|
|
108
155
|
"chess",
|
|
109
156
|
"clothing",
|
|
110
157
|
"cod",
|
|
111
158
|
"commerce",
|
|
159
|
+
"compass",
|
|
160
|
+
"consensus",
|
|
112
161
|
"cooperation",
|
|
113
162
|
"corps",
|
|
163
|
+
"data",
|
|
114
164
|
"debris",
|
|
165
|
+
"deer",
|
|
115
166
|
"diabetes",
|
|
167
|
+
"diagnosis",
|
|
116
168
|
"digestion",
|
|
117
169
|
"elk",
|
|
118
170
|
"energy",
|
|
171
|
+
"ephemeris",
|
|
119
172
|
"equipment",
|
|
173
|
+
"eries",
|
|
120
174
|
"excretion",
|
|
121
175
|
"expertise",
|
|
122
176
|
"firmware",
|
|
177
|
+
"fish",
|
|
123
178
|
"flounder",
|
|
124
179
|
"fun",
|
|
125
180
|
"gallows",
|
|
@@ -141,12 +196,15 @@ UNCOUNTABLE = frozenset(
|
|
|
141
196
|
"machinery",
|
|
142
197
|
"mackerel",
|
|
143
198
|
"mail",
|
|
199
|
+
"manga",
|
|
200
|
+
"means",
|
|
144
201
|
"media",
|
|
202
|
+
"metadata",
|
|
145
203
|
"mews",
|
|
204
|
+
"money",
|
|
146
205
|
"moose",
|
|
147
|
-
"music",
|
|
148
206
|
"mud",
|
|
149
|
-
"
|
|
207
|
+
"music",
|
|
150
208
|
"news",
|
|
151
209
|
"only",
|
|
152
210
|
"personnel",
|
|
@@ -156,6 +214,9 @@ UNCOUNTABLE = frozenset(
|
|
|
156
214
|
"police",
|
|
157
215
|
"pollution",
|
|
158
216
|
"premises",
|
|
217
|
+
"progress",
|
|
218
|
+
"prometheus",
|
|
219
|
+
"radius",
|
|
159
220
|
"rain",
|
|
160
221
|
"research",
|
|
161
222
|
"rice",
|
|
@@ -164,10 +225,13 @@ UNCOUNTABLE = frozenset(
|
|
|
164
225
|
"series",
|
|
165
226
|
"sewage",
|
|
166
227
|
"shambles",
|
|
228
|
+
"sheep",
|
|
167
229
|
"shrimp",
|
|
168
230
|
"software",
|
|
231
|
+
"species",
|
|
169
232
|
"staff",
|
|
170
233
|
"swine",
|
|
234
|
+
"synopsis",
|
|
171
235
|
"tennis",
|
|
172
236
|
"traffic",
|
|
173
237
|
"transportation",
|
|
@@ -178,22 +242,37 @@ UNCOUNTABLE = frozenset(
|
|
|
178
242
|
"whiting",
|
|
179
243
|
"wildebeest",
|
|
180
244
|
"wildlife",
|
|
245
|
+
"wireless",
|
|
181
246
|
"you",
|
|
182
|
-
"sheep",
|
|
183
|
-
"deer",
|
|
184
|
-
"species",
|
|
185
|
-
"series",
|
|
186
|
-
"means",
|
|
187
247
|
]
|
|
188
248
|
)
|
|
189
249
|
|
|
190
250
|
|
|
251
|
+
def _is_word_like(s: str) -> bool:
|
|
252
|
+
"""Check if string looks like a word (not a path, technical term, etc)."""
|
|
253
|
+
# Skip empty or very short
|
|
254
|
+
if not s or len(s) < 2:
|
|
255
|
+
return False
|
|
256
|
+
# Skip if contains non-word characters (except underscore and hyphen)
|
|
257
|
+
if not all(c.isalpha() or c in ("_", "-") for c in s):
|
|
258
|
+
return False
|
|
259
|
+
# Skip if has numbers
|
|
260
|
+
return not any(c.isdigit() for c in s)
|
|
261
|
+
|
|
262
|
+
|
|
191
263
|
def to_singular(word: str) -> str:
|
|
192
|
-
if word
|
|
264
|
+
if not _is_word_like(word):
|
|
265
|
+
return word
|
|
266
|
+
if word.lower() in UNCOUNTABLE:
|
|
267
|
+
return word
|
|
268
|
+
known_lower = IRREGULAR_TO_SINGULAR.get(word.lower())
|
|
269
|
+
if known_lower is not None:
|
|
270
|
+
# Preserve case: if input was capitalized, capitalize result
|
|
271
|
+
if word[0].isupper():
|
|
272
|
+
return known_lower.capitalize()
|
|
273
|
+
return known_lower
|
|
274
|
+
if word.endswith(("ss", "us")):
|
|
193
275
|
return word
|
|
194
|
-
known = IRREGULAR_TO_SINGULAR.get(word)
|
|
195
|
-
if known is not None:
|
|
196
|
-
return known
|
|
197
276
|
if word.endswith("ies") and len(word) > 3 and word[-4] not in "aeiou":
|
|
198
277
|
return word[:-3] + "y"
|
|
199
278
|
if word.endswith("sses"):
|
|
@@ -211,11 +290,18 @@ def to_singular(word: str) -> str:
|
|
|
211
290
|
|
|
212
291
|
|
|
213
292
|
def to_plural(word: str) -> str:
|
|
214
|
-
if word
|
|
293
|
+
if not _is_word_like(word):
|
|
294
|
+
return word
|
|
295
|
+
if word.lower() in UNCOUNTABLE:
|
|
215
296
|
return word
|
|
216
297
|
known = IRREGULAR_TO_PLURAL.get(word)
|
|
217
298
|
if known is not None:
|
|
218
299
|
return known
|
|
300
|
+
known_lower = IRREGULAR_TO_PLURAL.get(word.lower())
|
|
301
|
+
if known_lower is not None:
|
|
302
|
+
if word[0].isupper():
|
|
303
|
+
return known_lower.capitalize()
|
|
304
|
+
return known_lower
|
|
219
305
|
# Only change y -> ies after consonants (party -> parties, not day -> days)
|
|
220
306
|
if word.endswith("y") and len(word) > 1 and word[-2] not in "aeiou":
|
|
221
307
|
return word[:-1] + "ies"
|
|
@@ -3,14 +3,6 @@ from __future__ import annotations
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from typing import TYPE_CHECKING, Any, Callable, Mapping
|
|
5
5
|
|
|
6
|
-
from hypothesis_jsonschema._canonicalise import (
|
|
7
|
-
SCHEMA_KEYS as SCHEMA_KEYS_TUPLE,
|
|
8
|
-
)
|
|
9
|
-
from hypothesis_jsonschema._canonicalise import (
|
|
10
|
-
SCHEMA_OBJECT_KEYS as SCHEMA_OBJECT_KEYS_TUPLE,
|
|
11
|
-
)
|
|
12
|
-
from hypothesis_jsonschema._canonicalise import canonicalish, merged
|
|
13
|
-
|
|
14
6
|
from schemathesis.core.jsonschema import ALL_KEYWORDS
|
|
15
7
|
from schemathesis.core.jsonschema.bundler import BUNDLE_STORAGE_KEY, bundle
|
|
16
8
|
from schemathesis.core.jsonschema.types import JsonSchema, JsonSchemaObject
|
|
@@ -23,8 +15,23 @@ if TYPE_CHECKING:
|
|
|
23
15
|
from schemathesis.core.compat import RefResolver
|
|
24
16
|
|
|
25
17
|
ROOT_POINTER = "/"
|
|
26
|
-
SCHEMA_KEYS = frozenset(
|
|
27
|
-
|
|
18
|
+
SCHEMA_KEYS = frozenset(
|
|
19
|
+
{
|
|
20
|
+
"propertyNames",
|
|
21
|
+
"contains",
|
|
22
|
+
"if",
|
|
23
|
+
"items",
|
|
24
|
+
"oneOf",
|
|
25
|
+
"anyOf",
|
|
26
|
+
"additionalProperties",
|
|
27
|
+
"then",
|
|
28
|
+
"else",
|
|
29
|
+
"not",
|
|
30
|
+
"additionalItems",
|
|
31
|
+
"allOf",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
SCHEMA_OBJECT_KEYS = frozenset({"dependencies", "properties", "patternProperties"})
|
|
28
35
|
|
|
29
36
|
|
|
30
37
|
def resolve_all_refs(schema: JsonSchemaObject) -> dict[str, Any]:
|
|
@@ -48,6 +55,8 @@ def resolve_all_refs(schema: JsonSchemaObject) -> dict[str, Any]:
|
|
|
48
55
|
|
|
49
56
|
|
|
50
57
|
def resolve_all_refs_inner(schema: JsonSchema, *, resolve: Callable[[str], dict[str, Any]]) -> dict[str, Any]:
|
|
58
|
+
from hypothesis_jsonschema._canonicalise import merged
|
|
59
|
+
|
|
51
60
|
if schema is True:
|
|
52
61
|
return {}
|
|
53
62
|
if schema is False:
|
|
@@ -80,6 +89,8 @@ def resolve_all_refs_inner(schema: JsonSchema, *, resolve: Callable[[str], dict[
|
|
|
80
89
|
|
|
81
90
|
def canonicalize(schema: dict[str, Any], resolver: RefResolver) -> Mapping[str, Any]:
|
|
82
91
|
"""Transform the input schema into its canonical-ish form."""
|
|
92
|
+
from hypothesis_jsonschema._canonicalise import canonicalish
|
|
93
|
+
|
|
83
94
|
# Canonicalisation in `hypothesis_jsonschema` requires all references to be resovable and non-recursive
|
|
84
95
|
# On the Schemathesis side bundling solves this problem
|
|
85
96
|
bundled = bundle(schema, resolver, inline_recursive=True)
|
|
@@ -22,6 +22,7 @@ from werkzeug.routing import Map, MapAdapter, Rule
|
|
|
22
22
|
|
|
23
23
|
from schemathesis.core.adapter import ResponsesContainer
|
|
24
24
|
from schemathesis.core.transforms import encode_pointer
|
|
25
|
+
from schemathesis.specs.openapi.stateful.links import SCHEMATHESIS_LINK_EXTENSION
|
|
25
26
|
|
|
26
27
|
if TYPE_CHECKING:
|
|
27
28
|
from schemathesis.engine.observations import LocationHeaderEntry
|
|
@@ -39,7 +40,7 @@ class OperationById:
|
|
|
39
40
|
__slots__ = ("value", "method", "path")
|
|
40
41
|
|
|
41
42
|
def to_link_base(self) -> dict[str, Any]:
|
|
42
|
-
return {"operationId": self.value, "
|
|
43
|
+
return {"operationId": self.value, SCHEMATHESIS_LINK_EXTENSION: {"is_inferred": True}}
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
@dataclass(unsafe_hash=True)
|
|
@@ -53,7 +54,7 @@ class OperationByRef:
|
|
|
53
54
|
__slots__ = ("value", "method", "path")
|
|
54
55
|
|
|
55
56
|
def to_link_base(self) -> dict[str, Any]:
|
|
56
|
-
return {"operationRef": self.value, "
|
|
57
|
+
return {"operationRef": self.value, SCHEMATHESIS_LINK_EXTENSION: {"is_inferred": True}}
|
|
57
58
|
|
|
58
59
|
|
|
59
60
|
OperationReference = Union[OperationById, OperationByRef]
|
|
@@ -38,8 +38,19 @@ class OpenApiLink:
|
|
|
38
38
|
parameters: list[NormalizedParameter]
|
|
39
39
|
body: dict[str, Any] | NotSet
|
|
40
40
|
merge_body: bool
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
is_inferred: bool
|
|
42
|
+
|
|
43
|
+
__slots__ = (
|
|
44
|
+
"name",
|
|
45
|
+
"status_code",
|
|
46
|
+
"source",
|
|
47
|
+
"target",
|
|
48
|
+
"parameters",
|
|
49
|
+
"body",
|
|
50
|
+
"merge_body",
|
|
51
|
+
"is_inferred",
|
|
52
|
+
"_cached_extract",
|
|
53
|
+
)
|
|
43
54
|
|
|
44
55
|
def __init__(self, name: str, status_code: str, definition: dict[str, Any], source: APIOperation):
|
|
45
56
|
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
|
@@ -69,6 +80,7 @@ class OpenApiLink:
|
|
|
69
80
|
self.parameters = self._normalize_parameters(definition.get("parameters", {}), errors)
|
|
70
81
|
self.body = definition.get("requestBody", NOT_SET)
|
|
71
82
|
self.merge_body = extension.get("merge_body", True) if extension else True
|
|
83
|
+
self.is_inferred = extension.get("is_inferred", False) if extension else False
|
|
72
84
|
|
|
73
85
|
if errors:
|
|
74
86
|
raise InvalidTransition(
|
|
@@ -148,6 +160,7 @@ class OpenApiLink:
|
|
|
148
160
|
return Transition(
|
|
149
161
|
id=f"{self.source.label} -> [{self.status_code}] {self.name} -> {self.target.label}",
|
|
150
162
|
parent_id=output.case.id,
|
|
163
|
+
is_inferred=self.is_inferred,
|
|
151
164
|
parameters=self.extract_parameters(output),
|
|
152
165
|
request_body=self.extract_body(output),
|
|
153
166
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: schemathesis
|
|
3
|
-
Version: 4.3.
|
|
3
|
+
Version: 4.3.4
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
schemathesis/__init__.py,sha256=QqVUCBQr-RDEstgCZLsxzIa9HJslVSeijrm9gES4b_0,1423
|
|
2
2
|
schemathesis/auths.py,sha256=JdEwPRS9WKmPcxzGXYYz9pjlIUMQYCfif7ZJU0Kde-I,16400
|
|
3
|
-
schemathesis/checks.py,sha256=
|
|
3
|
+
schemathesis/checks.py,sha256=yyR219TQjLAieop5O2waEVLILF6_i10B8mkutd6QaCs,6590
|
|
4
4
|
schemathesis/errors.py,sha256=K3irHIZkrBH2-9LIjlgXlm8RNC41Nffd39ncfwagUvw,1053
|
|
5
5
|
schemathesis/filters.py,sha256=IevPA5A04GfRLLjmkFLZ0CLhjNO3RmpZq_yw6MqjLIA,13515
|
|
6
6
|
schemathesis/hooks.py,sha256=q2wqYNgpMCO8ImSBkbrWDSwN0BSELelqJMgAAgGvv2M,14836
|
|
@@ -13,7 +13,7 @@ schemathesis/cli/core.py,sha256=ue7YUdVo3YvuzGL4s6i62NL6YqNDeVPBSnQ1znrvG2w,480
|
|
|
13
13
|
schemathesis/cli/commands/__init__.py,sha256=DNzKEnXu7GjGSVe0244ZErmygUBA3nGSyVY6JP3ixD0,3740
|
|
14
14
|
schemathesis/cli/commands/data.py,sha256=_ALywjIeCZjuaoDQFy-Kj8RZkEGqXd-Y95O47h8Jszs,171
|
|
15
15
|
schemathesis/cli/commands/run/__init__.py,sha256=_ApiSVh9q-TsJQ_-IiVBNnLCtTCDMTnOLwuJhOvbCp4,18925
|
|
16
|
-
schemathesis/cli/commands/run/context.py,sha256=
|
|
16
|
+
schemathesis/cli/commands/run/context.py,sha256=Usa89aSPf8Uv-2m-nWr0ghvTKM1ZZehALBI0m_lFHv4,8087
|
|
17
17
|
schemathesis/cli/commands/run/events.py,sha256=ew0TQOc9T2YBZynYWv95k9yfAk8-hGuZDLMxjT8EhvY,1595
|
|
18
18
|
schemathesis/cli/commands/run/executor.py,sha256=_koznTX0DoELPN_1mxr9K_Qg7-9MPXWdld1MFn3YG_Y,5329
|
|
19
19
|
schemathesis/cli/commands/run/filters.py,sha256=pzkNRcf5vLPSsMfnvt711GNzRSBK5iZIFjPA0fiH1N4,1701
|
|
@@ -23,7 +23,7 @@ schemathesis/cli/commands/run/handlers/__init__.py,sha256=TPZ3KdGi8m0fjlN0GjA31M
|
|
|
23
23
|
schemathesis/cli/commands/run/handlers/base.py,sha256=qUtDvtr3F6were_BznfnaPpMibGJMnQ5CA9aEzcIUBc,1306
|
|
24
24
|
schemathesis/cli/commands/run/handlers/cassettes.py,sha256=LzvQp--Ub5MXF7etet7fQD0Ufloh1R0j2X1o9dT8Z4k,19253
|
|
25
25
|
schemathesis/cli/commands/run/handlers/junitxml.py,sha256=qiFvM4-SlM67sep003SkLqPslzaEb4nOm3bkzw-DO-Q,2602
|
|
26
|
-
schemathesis/cli/commands/run/handlers/output.py,sha256=
|
|
26
|
+
schemathesis/cli/commands/run/handlers/output.py,sha256=jWrqEkEQPO2kgzxOffZacqxH6r7dkDmAx0ep9GA3NU8,64020
|
|
27
27
|
schemathesis/cli/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
28
28
|
schemathesis/cli/ext/fs.py,sha256=dHQYBjQozQmuSSfXVp-2KWFK0ESOb_w-lV2SptfMfco,461
|
|
29
29
|
schemathesis/cli/ext/groups.py,sha256=kQ37t6qeArcKaY2y5VxyK3_KwAkBKCVm58IYV8gewds,2720
|
|
@@ -76,20 +76,20 @@ schemathesis/core/jsonschema/types.py,sha256=C7f9g8yKFuoxC5_0YNIh8QAyGU0-tj8pzTM
|
|
|
76
76
|
schemathesis/core/output/__init__.py,sha256=SiHqONFskXl73AtP5dV29L14nZoKo7B-IeG52KZB32M,1446
|
|
77
77
|
schemathesis/core/output/sanitization.py,sha256=Ev3tae8dVwsYd7yVb2_1VBFYs92WFsQ4Eu1fGaymItE,2013
|
|
78
78
|
schemathesis/engine/__init__.py,sha256=QaFE-FinaTAaarteADo2RRMJ-Sz6hZB9TzD5KjMinIA,706
|
|
79
|
-
schemathesis/engine/context.py,sha256=
|
|
79
|
+
schemathesis/engine/context.py,sha256=YaBfwTUyTCZaMq7-jtAKFQj-Eh1aQdbZ0UNcC5d_epU,5792
|
|
80
80
|
schemathesis/engine/control.py,sha256=FXzP8dxL47j1Giqpy2-Bsr_MdMw9YiATSK_UfpFwDtk,1348
|
|
81
81
|
schemathesis/engine/core.py,sha256=qlPHnZVq2RrUe93fOciXd1hC3E1gVyF2BIWMPMeLIj8,6655
|
|
82
82
|
schemathesis/engine/errors.py,sha256=FlpEk44WRLzRkdK9m37z93EQuY3kbeMIQRGwU5e3Qm4,19005
|
|
83
83
|
schemathesis/engine/events.py,sha256=jpCtMkWWfNe2jUeZh_Ly_wfZEF44EOodL-I_W4C9rgg,6594
|
|
84
84
|
schemathesis/engine/observations.py,sha256=T-5R8GeVIqvxpCMxc6vZ04UUxUTx3w7689r3Dc6bIcE,1416
|
|
85
|
-
schemathesis/engine/recorder.py,sha256=
|
|
85
|
+
schemathesis/engine/recorder.py,sha256=KWyWkGkZxIwSDU92jNWCJXU4G4E5WqfhLM6G1Yi7Jyo,8636
|
|
86
86
|
schemathesis/engine/phases/__init__.py,sha256=7Yp7dQbd6-K9pavIJeURg6jiNeMpW8UU-Iiikr668ts,3278
|
|
87
87
|
schemathesis/engine/phases/probes.py,sha256=YogjJcZJcTMS8sMdGnG4oXKmMUj_4r_J7MY-BBJtCRU,5690
|
|
88
88
|
schemathesis/engine/phases/stateful/__init__.py,sha256=Lz1rgNqCfUSIz173XqCGsiMuUI5bh4L-RIFexU1-c_Q,2461
|
|
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=YDibV3lkC2UMHLvh1FSmnlaQ-SJS-R0MU2qEF4NBbf0,17235
|
|
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=Qc2_5JrWuUkCzAFTTgnVqNUJ2sioslmINTXiY7nHHgA,12326
|
|
@@ -105,7 +105,7 @@ schemathesis/generation/hypothesis/given.py,sha256=sTZR1of6XaHAPWtHx2_WLlZ50M8D5
|
|
|
105
105
|
schemathesis/generation/hypothesis/reporting.py,sha256=uDVow6Ya8YFkqQuOqRsjbzsbyP4KKfr3jA7ZaY4FuKY,279
|
|
106
106
|
schemathesis/generation/hypothesis/strategies.py,sha256=RurE81E06d99YKG48dizy9346ayfNswYTt38zewmGgw,483
|
|
107
107
|
schemathesis/generation/stateful/__init__.py,sha256=s7jiJEnguIj44IsRyMi8afs-8yjIUuBbzW58bH5CHjs,1042
|
|
108
|
-
schemathesis/generation/stateful/state_machine.py,sha256=
|
|
108
|
+
schemathesis/generation/stateful/state_machine.py,sha256=25kkYImw5byNwuTtt97aNE3kTHAF8rZ-p3ax_bmd3JI,9135
|
|
109
109
|
schemathesis/graphql/__init__.py,sha256=_eO6MAPHGgiADVGRntnwtPxmuvk666sAh-FAU4cG9-0,326
|
|
110
110
|
schemathesis/graphql/checks.py,sha256=IADbxiZjgkBWrC5yzHDtohRABX6zKXk5w_zpWNwdzYo,3186
|
|
111
111
|
schemathesis/graphql/loaders.py,sha256=2tgG4HIvFmjHLr_KexVXnT8hSBM-dKG_fuXTZgE97So,9445
|
|
@@ -160,17 +160,17 @@ schemathesis/specs/openapi/negative/__init__.py,sha256=B78vps314fJOMZwlPdv7vUHo7
|
|
|
160
160
|
schemathesis/specs/openapi/negative/mutations.py,sha256=9U352xJsdZBR-Zfy1V7_X3a5i91LIUS9Zqotrzp3BLA,21000
|
|
161
161
|
schemathesis/specs/openapi/negative/types.py,sha256=a7buCcVxNBG6ILBM3A7oNTAX0lyDseEtZndBuej8MbI,174
|
|
162
162
|
schemathesis/specs/openapi/negative/utils.py,sha256=ozcOIuASufLqZSgnKUACjX-EOZrrkuNdXX0SDnLoGYA,168
|
|
163
|
-
schemathesis/specs/openapi/stateful/__init__.py,sha256=
|
|
163
|
+
schemathesis/specs/openapi/stateful/__init__.py,sha256=CQx2WJ3mKn5qmYRc90DqsG9w3Gx7DrB60S9HFz81STY,16663
|
|
164
164
|
schemathesis/specs/openapi/stateful/control.py,sha256=QaXLSbwQWtai5lxvvVtQV3BLJ8n5ePqSKB00XFxp-MA,3695
|
|
165
|
-
schemathesis/specs/openapi/stateful/inference.py,sha256=
|
|
166
|
-
schemathesis/specs/openapi/stateful/links.py,sha256=
|
|
167
|
-
schemathesis/specs/openapi/stateful/dependencies/__init__.py,sha256=
|
|
168
|
-
schemathesis/specs/openapi/stateful/dependencies/inputs.py,sha256=
|
|
169
|
-
schemathesis/specs/openapi/stateful/dependencies/models.py,sha256=
|
|
170
|
-
schemathesis/specs/openapi/stateful/dependencies/naming.py,sha256=
|
|
165
|
+
schemathesis/specs/openapi/stateful/inference.py,sha256=B99jSTDVi2yKxU7-raIb91xpacOrr0nZkEZY5Ej3eCY,9783
|
|
166
|
+
schemathesis/specs/openapi/stateful/links.py,sha256=SSA66mU50FFBz7e6sA37CfL-Vt0OY3gont72oFSvZYU,8163
|
|
167
|
+
schemathesis/specs/openapi/stateful/dependencies/__init__.py,sha256=IE8WIeWNhQoNCwiivQKoDe3GD_aobxmjQYvarwxp_1M,6379
|
|
168
|
+
schemathesis/specs/openapi/stateful/dependencies/inputs.py,sha256=DJDDCq73OYvCIPMxLKXJGTQGloNf6z6mgxjzjD0kJHA,8739
|
|
169
|
+
schemathesis/specs/openapi/stateful/dependencies/models.py,sha256=s8_RBwpciAmPMISp5WDabqEuX7dXW84S-QWnhkodz6g,10938
|
|
170
|
+
schemathesis/specs/openapi/stateful/dependencies/naming.py,sha256=MGoyh1bfw2SoKzdbzpHxed9LHMjokPJTU_YErZaF-Ls,11396
|
|
171
171
|
schemathesis/specs/openapi/stateful/dependencies/outputs.py,sha256=zvVUfQWNIuhMkKDpz5hsVGkkvkefLt1EswpJAnHajOw,1186
|
|
172
172
|
schemathesis/specs/openapi/stateful/dependencies/resources.py,sha256=7E2Z6LvomSRrp_0vCD_adzoux0wBLEjKi_EiSqiN43U,9664
|
|
173
|
-
schemathesis/specs/openapi/stateful/dependencies/schemas.py,sha256=
|
|
173
|
+
schemathesis/specs/openapi/stateful/dependencies/schemas.py,sha256=yMu13RsXIPDeZT1tATTxI1vkpYhjs-XFSFEvx3_Xh_Q,14094
|
|
174
174
|
schemathesis/specs/openapi/types/__init__.py,sha256=VPsWtLJle__Kodw_QqtQ3OuvBzBcCIKsTOrXy3eA7OU,66
|
|
175
175
|
schemathesis/specs/openapi/types/v3.py,sha256=Vondr9Amk6JKCIM6i6RGcmTUjFfPgOOqzBXqerccLpo,1468
|
|
176
176
|
schemathesis/transport/__init__.py,sha256=6yg_RfV_9L0cpA6qpbH-SL9_3ggtHQji9CZrpIkbA6s,5321
|
|
@@ -179,8 +179,8 @@ schemathesis/transport/prepare.py,sha256=erYXRaxpQokIDzaIuvt_csHcw72iHfCyNq8VNEz
|
|
|
179
179
|
schemathesis/transport/requests.py,sha256=wriRI9fprTplE_qEZLEz1TerX6GwkE3pwr6ZnU2o6vQ,10648
|
|
180
180
|
schemathesis/transport/serialization.py,sha256=GwO6OAVTmL1JyKw7HiZ256tjV4CbrRbhQN0ep1uaZwI,11157
|
|
181
181
|
schemathesis/transport/wsgi.py,sha256=kQtasFre6pjdJWRKwLA_Qb-RyQHCFNpaey9ubzlFWKI,5907
|
|
182
|
-
schemathesis-4.3.
|
|
183
|
-
schemathesis-4.3.
|
|
184
|
-
schemathesis-4.3.
|
|
185
|
-
schemathesis-4.3.
|
|
186
|
-
schemathesis-4.3.
|
|
182
|
+
schemathesis-4.3.4.dist-info/METADATA,sha256=Su_dAAs7RpHERfreFwrore0Nx6LqQJQB60HlC-C8TAQ,8540
|
|
183
|
+
schemathesis-4.3.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
184
|
+
schemathesis-4.3.4.dist-info/entry_points.txt,sha256=hiK3un-xfgPdwj9uj16YVDtTNpO128bmk0U82SMv8ZQ,152
|
|
185
|
+
schemathesis-4.3.4.dist-info/licenses/LICENSE,sha256=2Ve4J8v5jMQAWrT7r1nf3bI8Vflk3rZVQefiF2zpxwg,1121
|
|
186
|
+
schemathesis-4.3.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|