schemathesis 3.25.5__py3-none-any.whl → 3.39.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemathesis/__init__.py +6 -6
- schemathesis/_compat.py +2 -2
- schemathesis/_dependency_versions.py +4 -2
- schemathesis/_hypothesis.py +369 -56
- schemathesis/_lazy_import.py +1 -0
- schemathesis/_override.py +5 -4
- schemathesis/_patches.py +21 -0
- schemathesis/_rate_limiter.py +7 -0
- schemathesis/_xml.py +75 -22
- schemathesis/auths.py +78 -16
- schemathesis/checks.py +21 -9
- schemathesis/cli/__init__.py +793 -448
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/callbacks.py +58 -13
- schemathesis/cli/cassettes.py +233 -47
- schemathesis/cli/constants.py +8 -2
- schemathesis/cli/context.py +24 -4
- schemathesis/cli/debug.py +2 -1
- schemathesis/cli/handlers.py +4 -1
- schemathesis/cli/junitxml.py +103 -22
- schemathesis/cli/options.py +15 -4
- schemathesis/cli/output/default.py +286 -115
- schemathesis/cli/output/short.py +25 -6
- schemathesis/cli/reporting.py +79 -0
- schemathesis/cli/sanitization.py +6 -0
- schemathesis/code_samples.py +5 -3
- schemathesis/constants.py +1 -0
- schemathesis/contrib/openapi/__init__.py +1 -1
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -1
- schemathesis/contrib/openapi/formats/uuid.py +2 -1
- schemathesis/contrib/unique_data.py +3 -3
- schemathesis/exceptions.py +76 -65
- schemathesis/experimental/__init__.py +35 -0
- schemathesis/extra/_aiohttp.py +1 -0
- schemathesis/extra/_flask.py +4 -1
- schemathesis/extra/_server.py +1 -0
- schemathesis/extra/pytest_plugin.py +17 -25
- schemathesis/failures.py +77 -9
- schemathesis/filters.py +185 -8
- schemathesis/fixups/__init__.py +1 -0
- schemathesis/fixups/fast_api.py +2 -2
- schemathesis/fixups/utf8_bom.py +1 -2
- schemathesis/generation/__init__.py +20 -36
- schemathesis/generation/_hypothesis.py +59 -0
- schemathesis/generation/_methods.py +44 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/graphql.py +0 -1
- schemathesis/hooks.py +89 -12
- schemathesis/internal/checks.py +84 -0
- schemathesis/internal/copy.py +22 -3
- schemathesis/internal/deprecation.py +6 -2
- schemathesis/internal/diff.py +15 -0
- schemathesis/internal/extensions.py +27 -0
- schemathesis/internal/jsonschema.py +2 -1
- schemathesis/internal/output.py +68 -0
- schemathesis/internal/result.py +1 -1
- schemathesis/internal/transformation.py +11 -0
- schemathesis/lazy.py +138 -25
- schemathesis/loaders.py +7 -5
- schemathesis/models.py +323 -213
- schemathesis/parameters.py +4 -0
- schemathesis/runner/__init__.py +72 -22
- schemathesis/runner/events.py +86 -6
- schemathesis/runner/impl/context.py +104 -0
- schemathesis/runner/impl/core.py +447 -187
- schemathesis/runner/impl/solo.py +19 -29
- schemathesis/runner/impl/threadpool.py +70 -79
- schemathesis/{cli → runner}/probes.py +37 -25
- schemathesis/runner/serialization.py +150 -17
- schemathesis/sanitization.py +5 -1
- schemathesis/schemas.py +170 -102
- schemathesis/serializers.py +17 -4
- schemathesis/service/ci.py +1 -0
- schemathesis/service/client.py +39 -6
- schemathesis/service/events.py +5 -1
- schemathesis/service/extensions.py +224 -0
- schemathesis/service/hosts.py +6 -2
- schemathesis/service/metadata.py +25 -0
- schemathesis/service/models.py +211 -2
- schemathesis/service/report.py +6 -6
- schemathesis/service/serialization.py +60 -71
- schemathesis/service/usage.py +1 -0
- schemathesis/specs/graphql/_cache.py +26 -0
- schemathesis/specs/graphql/loaders.py +25 -5
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +2 -2
- schemathesis/specs/graphql/schemas.py +130 -100
- schemathesis/specs/graphql/validation.py +1 -2
- schemathesis/specs/openapi/__init__.py +1 -0
- schemathesis/specs/openapi/_cache.py +123 -0
- schemathesis/specs/openapi/_hypothesis.py +79 -61
- schemathesis/specs/openapi/checks.py +504 -25
- schemathesis/specs/openapi/converter.py +31 -4
- schemathesis/specs/openapi/definitions.py +10 -17
- schemathesis/specs/openapi/examples.py +143 -31
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +1 -1
- schemathesis/specs/openapi/expressions/extractors.py +26 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +29 -6
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +44 -0
- schemathesis/specs/openapi/links.py +125 -42
- schemathesis/specs/openapi/loaders.py +77 -36
- schemathesis/specs/openapi/media_types.py +34 -0
- schemathesis/specs/openapi/negative/__init__.py +6 -3
- schemathesis/specs/openapi/negative/mutations.py +21 -6
- schemathesis/specs/openapi/parameters.py +39 -25
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +37 -7
- schemathesis/specs/openapi/schemas.py +368 -242
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +1 -0
- schemathesis/specs/openapi/stateful/__init__.py +198 -70
- schemathesis/specs/openapi/stateful/statistic.py +198 -0
- schemathesis/specs/openapi/stateful/types.py +14 -0
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/specs/openapi/validation.py +1 -0
- schemathesis/stateful/__init__.py +35 -21
- schemathesis/stateful/config.py +97 -0
- schemathesis/stateful/context.py +135 -0
- schemathesis/stateful/events.py +274 -0
- schemathesis/stateful/runner.py +309 -0
- schemathesis/stateful/sink.py +68 -0
- schemathesis/stateful/state_machine.py +67 -38
- schemathesis/stateful/statistic.py +22 -0
- schemathesis/stateful/validation.py +100 -0
- schemathesis/targets.py +33 -1
- schemathesis/throttling.py +25 -5
- schemathesis/transports/__init__.py +354 -0
- schemathesis/transports/asgi.py +7 -0
- schemathesis/transports/auth.py +25 -2
- schemathesis/transports/content_types.py +3 -1
- schemathesis/transports/headers.py +2 -1
- schemathesis/transports/responses.py +9 -4
- schemathesis/types.py +9 -0
- schemathesis/utils.py +11 -16
- schemathesis-3.39.7.dist-info/METADATA +293 -0
- schemathesis-3.39.7.dist-info/RECORD +160 -0
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/WHEEL +1 -1
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis-3.25.5.dist-info/METADATA +0 -356
- schemathesis-3.25.5.dist-info/RECORD +0 -134
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
2
4
|
from functools import lru_cache
|
|
3
5
|
from typing import Generator
|
|
4
6
|
|
|
5
|
-
from . import lexer, nodes
|
|
7
|
+
from . import extractors, lexer, nodes
|
|
6
8
|
from .errors import RuntimeExpressionError, UnknownToken
|
|
7
9
|
|
|
8
10
|
|
|
@@ -53,7 +55,8 @@ def _parse_request(tokens: lexer.TokenGenerator, expr: str) -> nodes.BodyRequest
|
|
|
53
55
|
if location.value in ("query", "path", "header"):
|
|
54
56
|
skip_dot(tokens, f"$request.{location.value}")
|
|
55
57
|
parameter = take_string(tokens, expr)
|
|
56
|
-
|
|
58
|
+
extractor = take_extractor(tokens, expr, parameter.end)
|
|
59
|
+
return nodes.NonBodyRequest(location.value, parameter.value, extractor)
|
|
57
60
|
if location.value == "body":
|
|
58
61
|
try:
|
|
59
62
|
token = next(tokens)
|
|
@@ -70,7 +73,8 @@ def _parse_response(tokens: lexer.TokenGenerator, expr: str) -> nodes.HeaderResp
|
|
|
70
73
|
if location.value == "header":
|
|
71
74
|
skip_dot(tokens, f"$response.{location.value}")
|
|
72
75
|
parameter = take_string(tokens, expr)
|
|
73
|
-
|
|
76
|
+
extractor = take_extractor(tokens, expr, parameter.end)
|
|
77
|
+
return nodes.HeaderResponse(parameter.value, extractor=extractor)
|
|
74
78
|
if location.value == "body":
|
|
75
79
|
try:
|
|
76
80
|
token = next(tokens)
|
|
@@ -87,8 +91,25 @@ def skip_dot(tokens: lexer.TokenGenerator, name: str) -> None:
|
|
|
87
91
|
raise RuntimeExpressionError(f"`{name}` expression should be followed by a dot (`.`). Got: {token.value}")
|
|
88
92
|
|
|
89
93
|
|
|
90
|
-
def take_string(tokens: lexer.TokenGenerator, expr: str) ->
|
|
94
|
+
def take_string(tokens: lexer.TokenGenerator, expr: str) -> lexer.Token:
|
|
91
95
|
parameter = next(tokens)
|
|
92
96
|
if not parameter.is_string:
|
|
93
97
|
raise RuntimeExpressionError(f"Invalid expression: {expr}")
|
|
94
|
-
return parameter
|
|
98
|
+
return parameter
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def take_extractor(tokens: lexer.TokenGenerator, expr: str, current_end: int) -> extractors.Extractor | None:
|
|
102
|
+
rest = expr[current_end + 1 :]
|
|
103
|
+
if not rest or rest.startswith("}"):
|
|
104
|
+
return None
|
|
105
|
+
extractor = next(tokens)
|
|
106
|
+
if not extractor.value.startswith("#regex:"):
|
|
107
|
+
raise RuntimeExpressionError(f"Invalid extractor: {expr}")
|
|
108
|
+
pattern = extractor.value[len("#regex:") :]
|
|
109
|
+
try:
|
|
110
|
+
compiled = re.compile(pattern)
|
|
111
|
+
except re.error as exc:
|
|
112
|
+
raise RuntimeExpressionError(f"Invalid regex extractor: {exc}") from None
|
|
113
|
+
if compiled.groups != 1:
|
|
114
|
+
raise RuntimeExpressionError("Regex extractor should have exactly one capturing group")
|
|
115
|
+
return extractors.RegexExtractor(compiled)
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import string
|
|
4
|
+
from base64 import b64encode
|
|
5
|
+
from functools import lru_cache
|
|
3
6
|
from typing import TYPE_CHECKING
|
|
4
7
|
|
|
5
8
|
if TYPE_CHECKING:
|
|
@@ -33,5 +36,46 @@ def unregister_string_format(name: str) -> None:
|
|
|
33
36
|
raise ValueError(f"Unknown Open API format: {name}") from exc
|
|
34
37
|
|
|
35
38
|
|
|
39
|
+
def header_values(blacklist_characters: str = "\n\r") -> st.SearchStrategy[str]:
|
|
40
|
+
from hypothesis import strategies as st
|
|
41
|
+
|
|
42
|
+
return st.text(
|
|
43
|
+
alphabet=st.characters(min_codepoint=0, max_codepoint=255, blacklist_characters=blacklist_characters)
|
|
44
|
+
# Header values with leading non-visible chars can't be sent with `requests`
|
|
45
|
+
).map(str.lstrip)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
HEADER_FORMAT = "_header_value"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@lru_cache
|
|
52
|
+
def get_default_format_strategies() -> dict[str, st.SearchStrategy]:
|
|
53
|
+
"""Get all default "format" strategies."""
|
|
54
|
+
from hypothesis import strategies as st
|
|
55
|
+
from requests.auth import _basic_auth_str
|
|
56
|
+
|
|
57
|
+
from ...serializers import Binary
|
|
58
|
+
|
|
59
|
+
def make_basic_auth_str(item: tuple[str, str]) -> str:
|
|
60
|
+
return _basic_auth_str(*item)
|
|
61
|
+
|
|
62
|
+
latin1_text = st.text(alphabet=st.characters(min_codepoint=0, max_codepoint=255))
|
|
63
|
+
|
|
64
|
+
# Define valid characters here to avoid filtering them out in `is_valid_header` later
|
|
65
|
+
header_value = header_values()
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"binary": st.binary().map(Binary),
|
|
69
|
+
"byte": st.binary().map(lambda x: b64encode(x).decode()),
|
|
70
|
+
# RFC 7230, Section 3.2.6
|
|
71
|
+
"_header_name": st.text(
|
|
72
|
+
min_size=1, alphabet=st.sampled_from("!#$%&'*+-.^_`|~" + string.digits + string.ascii_letters)
|
|
73
|
+
),
|
|
74
|
+
HEADER_FORMAT: header_value,
|
|
75
|
+
"_basic_auth": st.tuples(latin1_text, latin1_text).map(make_basic_auth_str),
|
|
76
|
+
"_bearer_auth": header_value.map("Bearer {}".format),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
36
80
|
register = register_string_format
|
|
37
81
|
unregister = unregister_string_format
|
|
@@ -2,26 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
Based on https://swagger.io/docs/specification/links/
|
|
4
4
|
"""
|
|
5
|
+
|
|
5
6
|
from __future__ import annotations
|
|
7
|
+
|
|
6
8
|
from dataclasses import dataclass, field
|
|
7
9
|
from difflib import get_close_matches
|
|
8
|
-
from
|
|
9
|
-
|
|
10
|
-
from ...models import APIOperation, Case
|
|
11
|
-
from ...parameters import ParameterSet
|
|
12
|
-
from ...stateful import ParsedData, StatefulTest
|
|
13
|
-
from ...stateful.state_machine import Direction
|
|
14
|
-
from ...types import NotSet
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Generator, Literal, NoReturn, Sequence, TypedDict, Union, cast
|
|
15
12
|
|
|
16
13
|
from ...constants import NOT_SET
|
|
17
14
|
from ...internal.copy import fast_deepcopy
|
|
15
|
+
from ...models import APIOperation, Case, TransitionId
|
|
16
|
+
from ...stateful import ParsedData, StatefulTest, UnresolvableLink
|
|
17
|
+
from ...stateful.state_machine import Direction
|
|
18
18
|
from . import expressions
|
|
19
19
|
from .constants import LOCATION_TO_CONTAINER
|
|
20
20
|
from .parameters import OpenAPI20Body, OpenAPI30Body, OpenAPIParameter
|
|
21
|
-
|
|
21
|
+
from .references import RECURSION_DEPTH_LIMIT, Unresolvable
|
|
22
22
|
|
|
23
23
|
if TYPE_CHECKING:
|
|
24
|
+
from hypothesis.vendor.pretty import RepresentationPrinter
|
|
25
|
+
from jsonschema import RefResolver
|
|
26
|
+
|
|
27
|
+
from ...parameters import ParameterSet
|
|
24
28
|
from ...transports.responses import GenericResponse
|
|
29
|
+
from ...types import NotSet
|
|
25
30
|
|
|
26
31
|
|
|
27
32
|
@dataclass(repr=False)
|
|
@@ -29,6 +34,7 @@ class Link(StatefulTest):
|
|
|
29
34
|
operation: APIOperation
|
|
30
35
|
parameters: dict[str, Any]
|
|
31
36
|
request_body: Any = NOT_SET
|
|
37
|
+
merge_body: bool = True
|
|
32
38
|
|
|
33
39
|
def __post_init__(self) -> None:
|
|
34
40
|
if self.request_body is not NOT_SET and not self.operation.body:
|
|
@@ -48,6 +54,7 @@ class Link(StatefulTest):
|
|
|
48
54
|
operation = source_operation.schema.get_operation_by_id(definition["operationId"]) # type: ignore
|
|
49
55
|
else:
|
|
50
56
|
operation = source_operation.schema.get_operation_by_reference(definition["operationRef"]) # type: ignore
|
|
57
|
+
extension = definition.get(SCHEMATHESIS_LINK_EXTENSION)
|
|
51
58
|
return cls(
|
|
52
59
|
# Pylint can't detect that the API operation is always defined at this point
|
|
53
60
|
# E.g. if there is no matching operation or no operations at all, then a ValueError will be risen
|
|
@@ -55,21 +62,25 @@ class Link(StatefulTest):
|
|
|
55
62
|
operation=operation,
|
|
56
63
|
parameters=definition.get("parameters", {}),
|
|
57
64
|
request_body=definition.get("requestBody", NOT_SET), # `None` might be a valid value - `null`
|
|
65
|
+
merge_body=extension.get("merge_body", True) if extension is not None else True,
|
|
58
66
|
)
|
|
59
67
|
|
|
60
68
|
def parse(self, case: Case, response: GenericResponse) -> ParsedData:
|
|
61
69
|
"""Parse data into a structure expected by links definition."""
|
|
62
70
|
context = expressions.ExpressionContext(case=case, response=response)
|
|
63
|
-
parameters = {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
body=
|
|
72
|
-
)
|
|
71
|
+
parameters = {}
|
|
72
|
+
for parameter, expression in self.parameters.items():
|
|
73
|
+
evaluated = expressions.evaluate(expression, context)
|
|
74
|
+
if isinstance(evaluated, Unresolvable):
|
|
75
|
+
raise UnresolvableLink(f"Unresolvable reference in the link: {expression}")
|
|
76
|
+
parameters[parameter] = evaluated
|
|
77
|
+
body = expressions.evaluate(self.request_body, context, evaluate_nested=True)
|
|
78
|
+
if self.merge_body:
|
|
79
|
+
body = merge_body(case.body, body)
|
|
80
|
+
return ParsedData(parameters=parameters, body=body)
|
|
81
|
+
|
|
82
|
+
def is_match(self) -> bool:
|
|
83
|
+
return self.operation.schema.filter_set.match(SimpleNamespace(operation=self.operation))
|
|
73
84
|
|
|
74
85
|
def make_operation(self, collected: list[ParsedData]) -> APIOperation:
|
|
75
86
|
"""Create a modified version of the original API operation with additional data merged in."""
|
|
@@ -152,17 +163,27 @@ class Link(StatefulTest):
|
|
|
152
163
|
|
|
153
164
|
def get_links(response: GenericResponse, operation: APIOperation, field: str) -> Sequence[Link]:
|
|
154
165
|
"""Get `x-links` / `links` definitions from the schema."""
|
|
155
|
-
responses = operation.definition.
|
|
166
|
+
responses = operation.definition.raw["responses"]
|
|
156
167
|
if str(response.status_code) in responses:
|
|
157
|
-
|
|
168
|
+
definition = responses[str(response.status_code)]
|
|
158
169
|
elif response.status_code in responses:
|
|
159
|
-
|
|
170
|
+
definition = responses[response.status_code]
|
|
160
171
|
else:
|
|
161
|
-
|
|
162
|
-
|
|
172
|
+
definition = responses.get("default", {})
|
|
173
|
+
if not definition:
|
|
174
|
+
return []
|
|
175
|
+
_, definition = operation.schema.resolver.resolve_in_scope(definition, operation.definition.scope) # type: ignore[attr-defined]
|
|
176
|
+
links = definition.get(field, {})
|
|
163
177
|
return [Link.from_definition(name, definition, operation) for name, definition in links.items()]
|
|
164
178
|
|
|
165
179
|
|
|
180
|
+
SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class SchemathesisLink(TypedDict):
|
|
184
|
+
merge_body: bool
|
|
185
|
+
|
|
186
|
+
|
|
166
187
|
@dataclass(repr=False)
|
|
167
188
|
class OpenAPILink(Direction):
|
|
168
189
|
"""Alternative approach to link processing.
|
|
@@ -174,41 +195,87 @@ class OpenAPILink(Direction):
|
|
|
174
195
|
status_code: str
|
|
175
196
|
definition: dict[str, Any]
|
|
176
197
|
operation: APIOperation
|
|
177
|
-
parameters: list[tuple[
|
|
198
|
+
parameters: list[tuple[Literal["path", "query", "header", "cookie", "body"] | None, str, str]] = field(init=False)
|
|
178
199
|
body: dict[str, Any] | NotSet = field(init=False)
|
|
200
|
+
merge_body: bool = True
|
|
201
|
+
|
|
202
|
+
def __repr__(self) -> str:
|
|
203
|
+
path = self.operation.path
|
|
204
|
+
method = self.operation.method
|
|
205
|
+
return f"state.schema['{path}']['{method}'].links['{self.status_code}']['{self.name}']"
|
|
206
|
+
|
|
207
|
+
def _repr_pretty_(self, printer: RepresentationPrinter, cycle: bool) -> None:
|
|
208
|
+
return printer.text(repr(self))
|
|
179
209
|
|
|
180
210
|
def __post_init__(self) -> None:
|
|
211
|
+
extension = self.definition.get(SCHEMATHESIS_LINK_EXTENSION)
|
|
181
212
|
self.parameters = [
|
|
182
213
|
normalize_parameter(parameter, expression)
|
|
183
214
|
for parameter, expression in self.definition.get("parameters", {}).items()
|
|
184
215
|
]
|
|
185
216
|
self.body = self.definition.get("requestBody", NOT_SET)
|
|
217
|
+
if extension is not None:
|
|
218
|
+
self.merge_body = extension.get("merge_body", True)
|
|
186
219
|
|
|
187
220
|
def set_data(self, case: Case, elapsed: float, **kwargs: Any) -> None:
|
|
188
221
|
"""Assign all linked definitions to the new case instance."""
|
|
189
222
|
context = kwargs["context"]
|
|
190
|
-
self.set_parameters(case, context)
|
|
191
|
-
self.set_body(case, context)
|
|
192
|
-
|
|
223
|
+
overrides = self.set_parameters(case, context)
|
|
224
|
+
self.set_body(case, context, overrides)
|
|
225
|
+
overrides_all_parameters = True
|
|
226
|
+
if case.operation.body and "body" not in overrides.get("body", []):
|
|
227
|
+
overrides_all_parameters = False
|
|
228
|
+
if overrides_all_parameters:
|
|
229
|
+
for parameter in case.operation.iter_parameters():
|
|
230
|
+
if parameter.name not in overrides.get(parameter.location, []):
|
|
231
|
+
overrides_all_parameters = False
|
|
232
|
+
break
|
|
233
|
+
case.set_source(
|
|
234
|
+
context.response,
|
|
235
|
+
context.case,
|
|
236
|
+
elapsed,
|
|
237
|
+
overrides_all_parameters,
|
|
238
|
+
transition_id=TransitionId(
|
|
239
|
+
name=self.name,
|
|
240
|
+
status_code=self.status_code,
|
|
241
|
+
),
|
|
242
|
+
)
|
|
193
243
|
|
|
194
|
-
def set_parameters(
|
|
244
|
+
def set_parameters(
|
|
245
|
+
self, case: Case, context: expressions.ExpressionContext
|
|
246
|
+
) -> dict[Literal["path", "query", "header", "cookie", "body"], list[str]]:
|
|
247
|
+
overrides: dict[Literal["path", "query", "header", "cookie", "body"], list[str]] = {}
|
|
195
248
|
for location, name, expression in self.parameters:
|
|
196
|
-
container = get_container(case, location, name)
|
|
249
|
+
location, container = get_container(case, location, name)
|
|
197
250
|
# Might happen if there is directly specified container,
|
|
198
251
|
# but the schema has no parameters of such type at all.
|
|
199
252
|
# Therefore the container is empty, otherwise it will be at least an empty object
|
|
200
253
|
if container is None:
|
|
201
254
|
message = f"No such parameter in `{case.operation.method.upper()} {case.operation.path}`: `{name}`."
|
|
202
|
-
possibilities = [param.name for param in case.operation.
|
|
255
|
+
possibilities = [param.name for param in case.operation.iter_parameters()]
|
|
203
256
|
matches = get_close_matches(name, possibilities)
|
|
204
257
|
if matches:
|
|
205
258
|
message += f" Did you mean `{matches[0]}`?"
|
|
206
259
|
raise ValueError(message)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
260
|
+
value = expressions.evaluate(expression, context)
|
|
261
|
+
if value is not None:
|
|
262
|
+
container[name] = value
|
|
263
|
+
overrides.setdefault(location, []).append(name)
|
|
264
|
+
return overrides
|
|
265
|
+
|
|
266
|
+
def set_body(
|
|
267
|
+
self,
|
|
268
|
+
case: Case,
|
|
269
|
+
context: expressions.ExpressionContext,
|
|
270
|
+
overrides: dict[Literal["path", "query", "header", "cookie", "body"], list[str]],
|
|
271
|
+
) -> None:
|
|
210
272
|
if self.body is not NOT_SET:
|
|
211
|
-
|
|
273
|
+
evaluated = expressions.evaluate(self.body, context, evaluate_nested=True)
|
|
274
|
+
overrides["body"] = ["body"]
|
|
275
|
+
if self.merge_body:
|
|
276
|
+
case.body = merge_body(case.body, evaluated)
|
|
277
|
+
else:
|
|
278
|
+
case.body = evaluated
|
|
212
279
|
|
|
213
280
|
def get_target_operation(self) -> APIOperation:
|
|
214
281
|
if "operationId" in self.definition:
|
|
@@ -216,21 +283,32 @@ class OpenAPILink(Direction):
|
|
|
216
283
|
return self.operation.schema.get_operation_by_reference(self.definition["operationRef"]) # type: ignore
|
|
217
284
|
|
|
218
285
|
|
|
219
|
-
def
|
|
286
|
+
def merge_body(old: Any, new: Any) -> Any:
|
|
287
|
+
if isinstance(old, dict) and isinstance(new, dict):
|
|
288
|
+
return {**old, **new}
|
|
289
|
+
return new
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def get_container(
|
|
293
|
+
case: Case, location: Literal["path", "query", "header", "cookie", "body"] | None, name: str
|
|
294
|
+
) -> tuple[Literal["path", "query", "header", "cookie", "body"], dict[str, Any] | None]:
|
|
220
295
|
"""Get a container that suppose to store the given parameter."""
|
|
221
296
|
if location:
|
|
222
297
|
container_name = LOCATION_TO_CONTAINER[location]
|
|
223
298
|
else:
|
|
224
|
-
for param in case.operation.
|
|
299
|
+
for param in case.operation.iter_parameters():
|
|
225
300
|
if param.name == name:
|
|
301
|
+
location = param.location
|
|
226
302
|
container_name = LOCATION_TO_CONTAINER[param.location]
|
|
227
303
|
break
|
|
228
304
|
else:
|
|
229
305
|
raise ValueError(f"Parameter `{name}` is not defined in API operation `{case.operation.verbose_name}`")
|
|
230
|
-
return getattr(case, container_name)
|
|
306
|
+
return location, getattr(case, container_name)
|
|
231
307
|
|
|
232
308
|
|
|
233
|
-
def normalize_parameter(
|
|
309
|
+
def normalize_parameter(
|
|
310
|
+
parameter: str, expression: str
|
|
311
|
+
) -> tuple[Literal["path", "query", "header", "cookie", "body"] | None, str, str]:
|
|
234
312
|
"""Normalize runtime expressions.
|
|
235
313
|
|
|
236
314
|
Runtime expressions may have parameter names prefixed with their location - `path.id`.
|
|
@@ -240,13 +318,15 @@ def normalize_parameter(parameter: str, expression: str) -> tuple[str | None, st
|
|
|
240
318
|
try:
|
|
241
319
|
# The parameter name is prefixed with its location. Example: `path.id`
|
|
242
320
|
location, name = tuple(parameter.split("."))
|
|
243
|
-
|
|
321
|
+
_location = cast(Literal["path", "query", "header", "cookie", "body"], location)
|
|
322
|
+
return _location, name, expression
|
|
244
323
|
except ValueError:
|
|
245
324
|
return None, parameter, expression
|
|
246
325
|
|
|
247
326
|
|
|
248
327
|
def get_all_links(operation: APIOperation) -> Generator[tuple[str, OpenAPILink], None, None]:
|
|
249
|
-
for status_code, definition in operation.definition.
|
|
328
|
+
for status_code, definition in operation.definition.raw["responses"].items():
|
|
329
|
+
definition = operation.schema.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 8) # type: ignore[attr-defined]
|
|
250
330
|
for name, link_definition in definition.get(operation.schema.links_field, {}).items(): # type: ignore
|
|
251
331
|
yield status_code, OpenAPILink(name, status_code, link_definition, operation)
|
|
252
332
|
|
|
@@ -273,6 +353,7 @@ def _get_response_by_status_code(responses: dict[StatusCode, dict[str, Any]], st
|
|
|
273
353
|
|
|
274
354
|
|
|
275
355
|
def add_link(
|
|
356
|
+
resolver: RefResolver,
|
|
276
357
|
responses: dict[StatusCode, dict[str, Any]],
|
|
277
358
|
links_field: str,
|
|
278
359
|
parameters: dict[str, str] | None,
|
|
@@ -282,6 +363,8 @@ def add_link(
|
|
|
282
363
|
name: str | None = None,
|
|
283
364
|
) -> None:
|
|
284
365
|
response = _get_response_by_status_code(responses, status_code)
|
|
366
|
+
if "$ref" in response:
|
|
367
|
+
_, response = resolver.resolve(response["$ref"])
|
|
285
368
|
links_definition = response.setdefault(links_field, {})
|
|
286
369
|
new_link: dict[str, str | dict[str, str]] = {}
|
|
287
370
|
if parameters is not None:
|
|
@@ -295,8 +378,8 @@ def add_link(
|
|
|
295
378
|
name = name or f"{target.method.upper()} {target.path}"
|
|
296
379
|
# operationId is a dict lookup which is more efficient than using `operationRef`, since it
|
|
297
380
|
# doesn't involve reference resolving when we will look up for this target during testing.
|
|
298
|
-
if "operationId" in target.definition.
|
|
299
|
-
new_link["operationId"] = target.definition.
|
|
381
|
+
if "operationId" in target.definition.raw:
|
|
382
|
+
new_link["operationId"] = target.definition.raw["operationId"]
|
|
300
383
|
else:
|
|
301
384
|
new_link["operationRef"] = target.operation_reference
|
|
302
385
|
# The name is arbitrary, so we don't really case what it is,
|