schemathesis 3.25.6__py3-none-any.whl → 4.0.0a1__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 +27 -65
- schemathesis/auths.py +102 -82
- schemathesis/checks.py +126 -46
- schemathesis/cli/__init__.py +11 -1760
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +37 -0
- schemathesis/cli/commands/run/__init__.py +662 -0
- schemathesis/cli/commands/run/checks.py +80 -0
- schemathesis/cli/commands/run/context.py +117 -0
- schemathesis/cli/commands/run/events.py +35 -0
- schemathesis/cli/commands/run/executor.py +138 -0
- schemathesis/cli/commands/run/filters.py +194 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +18 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
- schemathesis/cli/commands/run/handlers/output.py +746 -0
- schemathesis/cli/commands/run/hypothesis.py +105 -0
- schemathesis/cli/commands/run/loaders.py +129 -0
- schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
- schemathesis/cli/constants.py +5 -52
- schemathesis/cli/core.py +17 -0
- schemathesis/cli/ext/fs.py +14 -0
- schemathesis/cli/ext/groups.py +55 -0
- schemathesis/cli/{options.py → ext/options.py} +39 -10
- schemathesis/cli/hooks.py +36 -0
- schemathesis/contrib/__init__.py +1 -3
- schemathesis/contrib/openapi/__init__.py +1 -3
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
- schemathesis/core/__init__.py +58 -0
- schemathesis/core/compat.py +25 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +58 -0
- schemathesis/core/deserialization.py +65 -0
- schemathesis/core/errors.py +370 -0
- schemathesis/core/failures.py +285 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
- schemathesis/core/loaders.py +104 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
- schemathesis/core/output/__init__.py +69 -0
- schemathesis/core/output/sanitization.py +197 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +31 -0
- schemathesis/{internal → core}/result.py +1 -1
- schemathesis/core/transforms.py +113 -0
- schemathesis/core/transport.py +108 -0
- schemathesis/core/validation.py +38 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +30 -0
- schemathesis/engine/config.py +59 -0
- schemathesis/engine/context.py +119 -0
- schemathesis/engine/control.py +36 -0
- schemathesis/engine/core.py +157 -0
- schemathesis/engine/errors.py +394 -0
- schemathesis/engine/events.py +337 -0
- schemathesis/engine/phases/__init__.py +66 -0
- schemathesis/{runner → engine/phases}/probes.py +50 -67
- schemathesis/engine/phases/stateful/__init__.py +65 -0
- schemathesis/engine/phases/stateful/_executor.py +326 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +174 -0
- schemathesis/engine/phases/unit/_executor.py +321 -0
- schemathesis/engine/phases/unit/_pool.py +74 -0
- schemathesis/engine/recorder.py +241 -0
- schemathesis/errors.py +31 -0
- schemathesis/experimental/__init__.py +18 -14
- schemathesis/filters.py +103 -14
- schemathesis/generation/__init__.py +21 -37
- schemathesis/generation/case.py +190 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/generation/hypothesis/__init__.py +30 -0
- schemathesis/generation/hypothesis/builder.py +585 -0
- schemathesis/generation/hypothesis/examples.py +50 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +14 -0
- schemathesis/generation/hypothesis/strategies.py +16 -0
- schemathesis/generation/meta.py +115 -0
- schemathesis/generation/modes.py +28 -0
- schemathesis/generation/overrides.py +96 -0
- schemathesis/generation/stateful/__init__.py +20 -0
- schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
- schemathesis/generation/targets.py +69 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +115 -0
- schemathesis/graphql/loaders.py +131 -0
- schemathesis/hooks.py +99 -67
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +412 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +63 -0
- schemathesis/openapi/loaders.py +178 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +273 -0
- schemathesis/pytest/loaders.py +12 -0
- schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +537 -261
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/_cache.py +25 -0
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +7 -5
- schemathesis/specs/graphql/schemas.py +215 -187
- schemathesis/specs/graphql/validation.py +11 -18
- schemathesis/specs/openapi/__init__.py +7 -1
- schemathesis/specs/openapi/_cache.py +122 -0
- schemathesis/specs/openapi/_hypothesis.py +146 -165
- schemathesis/specs/openapi/checks.py +565 -67
- schemathesis/specs/openapi/converter.py +33 -6
- schemathesis/specs/openapi/definitions.py +11 -18
- schemathesis/specs/openapi/examples.py +139 -23
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +4 -6
- schemathesis/specs/openapi/expressions/extractors.py +23 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +38 -14
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +45 -0
- schemathesis/specs/openapi/links.py +65 -165
- schemathesis/specs/openapi/media_types.py +32 -0
- schemathesis/specs/openapi/negative/__init__.py +7 -3
- schemathesis/specs/openapi/negative/mutations.py +24 -8
- schemathesis/specs/openapi/parameters.py +46 -30
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +47 -57
- schemathesis/specs/openapi/schemas.py +478 -369
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +11 -6
- schemathesis/specs/openapi/stateful/__init__.py +185 -73
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/transport/__init__.py +104 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +99 -0
- schemathesis/transport/requests.py +221 -0
- schemathesis/{_xml.py → transport/serialization.py} +143 -28
- schemathesis/transport/wsgi.py +165 -0
- schemathesis-4.0.0a1.dist-info/METADATA +297 -0
- schemathesis-4.0.0a1.dist-info/RECORD +152 -0
- {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
- {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
- schemathesis/_compat.py +0 -74
- schemathesis/_dependency_versions.py +0 -17
- schemathesis/_hypothesis.py +0 -246
- schemathesis/_override.py +0 -49
- schemathesis/cli/cassettes.py +0 -375
- schemathesis/cli/context.py +0 -58
- schemathesis/cli/debug.py +0 -26
- schemathesis/cli/handlers.py +0 -16
- schemathesis/cli/junitxml.py +0 -43
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -790
- schemathesis/cli/output/short.py +0 -44
- schemathesis/cli/sanitization.py +0 -20
- schemathesis/code_samples.py +0 -149
- schemathesis/constants.py +0 -55
- schemathesis/contrib/openapi/formats/__init__.py +0 -9
- schemathesis/contrib/openapi/formats/uuid.py +0 -15
- schemathesis/contrib/unique_data.py +0 -41
- schemathesis/exceptions.py +0 -560
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -17
- schemathesis/failures.py +0 -209
- schemathesis/fixups/__init__.py +0 -36
- schemathesis/fixups/fast_api.py +0 -41
- schemathesis/fixups/utf8_bom.py +0 -29
- schemathesis/graphql.py +0 -4
- schemathesis/internal/__init__.py +0 -7
- schemathesis/internal/copy.py +0 -13
- schemathesis/internal/datetime.py +0 -5
- schemathesis/internal/deprecation.py +0 -34
- schemathesis/internal/jsonschema.py +0 -35
- schemathesis/internal/transformation.py +0 -15
- schemathesis/internal/validation.py +0 -34
- schemathesis/lazy.py +0 -361
- schemathesis/loaders.py +0 -120
- schemathesis/models.py +0 -1234
- schemathesis/parameters.py +0 -86
- schemathesis/runner/__init__.py +0 -570
- schemathesis/runner/events.py +0 -329
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -1035
- schemathesis/runner/impl/solo.py +0 -90
- schemathesis/runner/impl/threadpool.py +0 -400
- schemathesis/runner/serialization.py +0 -411
- schemathesis/sanitization.py +0 -248
- schemathesis/serializers.py +0 -323
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -11
- schemathesis/service/ci.py +0 -201
- schemathesis/service/client.py +0 -100
- schemathesis/service/constants.py +0 -38
- schemathesis/service/events.py +0 -57
- schemathesis/service/hosts.py +0 -107
- schemathesis/service/metadata.py +0 -46
- schemathesis/service/models.py +0 -49
- schemathesis/service/report.py +0 -255
- schemathesis/service/serialization.py +0 -199
- schemathesis/service/usage.py +0 -65
- schemathesis/specs/graphql/loaders.py +0 -344
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/loaders.py +0 -667
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis/specs/openapi/validation.py +0 -25
- schemathesis/stateful/__init__.py +0 -133
- schemathesis/targets.py +0 -45
- schemathesis/throttling.py +0 -41
- schemathesis/transports/__init__.py +0 -5
- schemathesis/transports/auth.py +0 -15
- schemathesis/transports/headers.py +0 -35
- schemathesis/transports/responses.py +0 -52
- schemathesis/types.py +0 -35
- schemathesis/utils.py +0 -169
- schemathesis-3.25.6.dist-info/METADATA +0 -356
- schemathesis-3.25.6.dist-info/RECORD +0 -134
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -1,13 +1,19 @@
|
|
1
1
|
"""Expression nodes description and evaluation logic."""
|
2
|
+
|
2
3
|
from __future__ import annotations
|
4
|
+
|
3
5
|
from dataclasses import dataclass
|
4
6
|
from enum import Enum, unique
|
5
|
-
from typing import Any
|
7
|
+
from typing import TYPE_CHECKING, Any, cast
|
6
8
|
|
7
9
|
from requests.structures import CaseInsensitiveDict
|
8
10
|
|
9
|
-
from
|
10
|
-
from .
|
11
|
+
from schemathesis.core.transforms import UNRESOLVABLE, resolve_pointer
|
12
|
+
from schemathesis.transport.requests import REQUESTS_TRANSPORT
|
13
|
+
|
14
|
+
if TYPE_CHECKING:
|
15
|
+
from .context import ExpressionContext
|
16
|
+
from .extractors import Extractor
|
11
17
|
|
12
18
|
|
13
19
|
@dataclass
|
@@ -48,7 +54,12 @@ class URL(Node):
|
|
48
54
|
"""A node for `$url` expression."""
|
49
55
|
|
50
56
|
def evaluate(self, context: ExpressionContext) -> str:
|
51
|
-
|
57
|
+
import requests
|
58
|
+
|
59
|
+
base_url = context.case.operation.base_url or "http://127.0.0.1"
|
60
|
+
kwargs = REQUESTS_TRANSPORT.serialize_case(context.case, base_url=base_url)
|
61
|
+
prepared = requests.Request(**kwargs).prepare()
|
62
|
+
return cast(str, prepared.url)
|
52
63
|
|
53
64
|
|
54
65
|
@dataclass
|
@@ -73,6 +84,7 @@ class NonBodyRequest(Node):
|
|
73
84
|
|
74
85
|
location: str
|
75
86
|
parameter: str
|
87
|
+
extractor: Extractor | None = None
|
76
88
|
|
77
89
|
def evaluate(self, context: ExpressionContext) -> str:
|
78
90
|
container: dict | CaseInsensitiveDict = {
|
@@ -82,7 +94,12 @@ class NonBodyRequest(Node):
|
|
82
94
|
}[self.location] or {}
|
83
95
|
if self.location == "header":
|
84
96
|
container = CaseInsensitiveDict(container)
|
85
|
-
|
97
|
+
value = container.get(self.parameter)
|
98
|
+
if value is None:
|
99
|
+
return ""
|
100
|
+
if self.extractor is not None:
|
101
|
+
return self.extractor.extract(value) or ""
|
102
|
+
return value
|
86
103
|
|
87
104
|
|
88
105
|
@dataclass
|
@@ -95,7 +112,10 @@ class BodyRequest(Node):
|
|
95
112
|
document = context.case.body
|
96
113
|
if self.pointer is None:
|
97
114
|
return document
|
98
|
-
|
115
|
+
resolved = resolve_pointer(document, self.pointer[1:])
|
116
|
+
if resolved is UNRESOLVABLE:
|
117
|
+
return None
|
118
|
+
return resolved
|
99
119
|
|
100
120
|
|
101
121
|
@dataclass
|
@@ -103,9 +123,15 @@ class HeaderResponse(Node):
|
|
103
123
|
"""A node for `$response.header` expressions."""
|
104
124
|
|
105
125
|
parameter: str
|
126
|
+
extractor: Extractor | None = None
|
106
127
|
|
107
128
|
def evaluate(self, context: ExpressionContext) -> str:
|
108
|
-
|
129
|
+
value = context.response.headers.get(self.parameter.lower())
|
130
|
+
if value is None:
|
131
|
+
return ""
|
132
|
+
if self.extractor is not None:
|
133
|
+
return self.extractor.extract(value[0]) or ""
|
134
|
+
return value[0]
|
109
135
|
|
110
136
|
|
111
137
|
@dataclass
|
@@ -115,13 +141,11 @@ class BodyResponse(Node):
|
|
115
141
|
pointer: str | None = None
|
116
142
|
|
117
143
|
def evaluate(self, context: ExpressionContext) -> Any:
|
118
|
-
|
119
|
-
|
120
|
-
if isinstance(context.response, WSGIResponse):
|
121
|
-
document = context.response.json
|
122
|
-
else:
|
123
|
-
document = context.response.json()
|
144
|
+
document = context.response.json()
|
124
145
|
if self.pointer is None:
|
125
146
|
# We need the parsed document - data will be serialized before sending to the application
|
126
147
|
return document
|
127
|
-
|
148
|
+
resolved = resolve_pointer(document, self.pointer[1:])
|
149
|
+
if resolved is UNRESOLVABLE:
|
150
|
+
return None
|
151
|
+
return resolved
|
@@ -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,7 +1,12 @@
|
|
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
|
|
8
|
+
from schemathesis.transport.serialization import Binary
|
9
|
+
|
5
10
|
if TYPE_CHECKING:
|
6
11
|
from hypothesis import strategies as st
|
7
12
|
|
@@ -33,5 +38,45 @@ def unregister_string_format(name: str) -> None:
|
|
33
38
|
raise ValueError(f"Unknown Open API format: {name}") from exc
|
34
39
|
|
35
40
|
|
41
|
+
def header_values(blacklist_characters: str = "\n\r") -> st.SearchStrategy[str]:
|
42
|
+
from hypothesis import strategies as st
|
43
|
+
|
44
|
+
return st.text(
|
45
|
+
alphabet=st.characters(min_codepoint=0, max_codepoint=255, blacklist_characters=blacklist_characters)
|
46
|
+
# Header values with leading non-visible chars can't be sent with `requests`
|
47
|
+
).map(str.lstrip)
|
48
|
+
|
49
|
+
|
50
|
+
HEADER_FORMAT = "_header_value"
|
51
|
+
|
52
|
+
|
53
|
+
@lru_cache
|
54
|
+
def get_default_format_strategies() -> dict[str, st.SearchStrategy]:
|
55
|
+
"""Get all default "format" strategies."""
|
56
|
+
from hypothesis import strategies as st
|
57
|
+
from requests.auth import _basic_auth_str
|
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
|
+
"uuid": st.uuids().map(str),
|
71
|
+
# RFC 7230, Section 3.2.6
|
72
|
+
"_header_name": st.text(
|
73
|
+
min_size=1, alphabet=st.sampled_from("!#$%&'*+-.^_`|~" + string.digits + string.ascii_letters)
|
74
|
+
),
|
75
|
+
HEADER_FORMAT: header_value,
|
76
|
+
"_basic_auth": st.tuples(latin1_text, latin1_text).map(make_basic_auth_str),
|
77
|
+
"_bearer_auth": header_value.map("Bearer {}".format),
|
78
|
+
}
|
79
|
+
|
80
|
+
|
36
81
|
register = register_string_format
|
37
82
|
unregister = unregister_string_format
|
@@ -2,165 +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 typing import Any, Generator,
|
10
|
+
from typing import TYPE_CHECKING, Any, Generator, Literal, TypedDict, Union, cast
|
9
11
|
|
10
|
-
from
|
11
|
-
from
|
12
|
-
from
|
13
|
-
from
|
14
|
-
from ...types import NotSet
|
12
|
+
from schemathesis.core import NOT_SET, NotSet
|
13
|
+
from schemathesis.generation.case import Case
|
14
|
+
from schemathesis.generation.stateful.state_machine import Direction
|
15
|
+
from schemathesis.schemas import APIOperation
|
15
16
|
|
16
|
-
from ...constants import NOT_SET
|
17
|
-
from ...internal.copy import fast_deepcopy
|
18
17
|
from . import expressions
|
19
18
|
from .constants import LOCATION_TO_CONTAINER
|
20
|
-
from .
|
21
|
-
|
19
|
+
from .references import RECURSION_DEPTH_LIMIT
|
22
20
|
|
23
21
|
if TYPE_CHECKING:
|
24
|
-
from
|
22
|
+
from jsonschema import RefResolver
|
25
23
|
|
26
24
|
|
27
|
-
|
28
|
-
class Link(StatefulTest):
|
29
|
-
operation: APIOperation
|
30
|
-
parameters: dict[str, Any]
|
31
|
-
request_body: Any = NOT_SET
|
25
|
+
SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
|
32
26
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
raise ValueError(
|
37
|
-
f"Request body is not defined in API operation {self.operation.method.upper()} {self.operation.path}"
|
38
|
-
)
|
39
|
-
|
40
|
-
@classmethod
|
41
|
-
def from_definition(cls, name: str, definition: dict[str, dict[str, Any]], source_operation: APIOperation) -> Link:
|
42
|
-
# Links can be behind a reference
|
43
|
-
_, definition = source_operation.schema.resolver.resolve_in_scope( # type: ignore
|
44
|
-
definition, source_operation.definition.scope
|
45
|
-
)
|
46
|
-
if "operationId" in definition:
|
47
|
-
# source_operation.schema is `BaseOpenAPISchema` and has this method
|
48
|
-
operation = source_operation.schema.get_operation_by_id(definition["operationId"]) # type: ignore
|
49
|
-
else:
|
50
|
-
operation = source_operation.schema.get_operation_by_reference(definition["operationRef"]) # type: ignore
|
51
|
-
return cls(
|
52
|
-
# Pylint can't detect that the API operation is always defined at this point
|
53
|
-
# E.g. if there is no matching operation or no operations at all, then a ValueError will be risen
|
54
|
-
name=name,
|
55
|
-
operation=operation,
|
56
|
-
parameters=definition.get("parameters", {}),
|
57
|
-
request_body=definition.get("requestBody", NOT_SET), # `None` might be a valid value - `null`
|
58
|
-
)
|
59
|
-
|
60
|
-
def parse(self, case: Case, response: GenericResponse) -> ParsedData:
|
61
|
-
"""Parse data into a structure expected by links definition."""
|
62
|
-
context = expressions.ExpressionContext(case=case, response=response)
|
63
|
-
parameters = {
|
64
|
-
parameter: expressions.evaluate(expression, context) for parameter, expression in self.parameters.items()
|
65
|
-
}
|
66
|
-
return ParsedData(
|
67
|
-
parameters=parameters,
|
68
|
-
# https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#link-object
|
69
|
-
# > A literal value or {expression} to use as a request body when calling the target operation.
|
70
|
-
# In this case all literals will be passed as is, and expressions will be evaluated
|
71
|
-
body=expressions.evaluate(self.request_body, context),
|
72
|
-
)
|
73
|
-
|
74
|
-
def make_operation(self, collected: list[ParsedData]) -> APIOperation:
|
75
|
-
"""Create a modified version of the original API operation with additional data merged in."""
|
76
|
-
# We split the gathered data among all locations & store the original parameter
|
77
|
-
containers = {
|
78
|
-
location: {
|
79
|
-
parameter.name: {"options": [], "parameter": parameter}
|
80
|
-
for parameter in getattr(self.operation, container_name)
|
81
|
-
}
|
82
|
-
for location, container_name in LOCATION_TO_CONTAINER.items()
|
83
|
-
}
|
84
|
-
# There might be duplicates in the data
|
85
|
-
for item in set(collected):
|
86
|
-
for name, value in item.parameters.items():
|
87
|
-
container = self._get_container_by_parameter_name(name, containers)
|
88
|
-
container.append(value)
|
89
|
-
if "body" in containers["body"] and item.body is not NOT_SET:
|
90
|
-
containers["body"]["body"]["options"].append(item.body)
|
91
|
-
# These are the final `path_parameters`, `query`, and other API operation components
|
92
|
-
components: dict[str, ParameterSet] = {
|
93
|
-
container_name: getattr(self.operation, container_name).__class__()
|
94
|
-
for location, container_name in LOCATION_TO_CONTAINER.items()
|
95
|
-
}
|
96
|
-
# Here are all components that are filled with parameters
|
97
|
-
for location, parameters in containers.items():
|
98
|
-
for parameter_data in parameters.values():
|
99
|
-
parameter = parameter_data["parameter"]
|
100
|
-
if parameter_data["options"]:
|
101
|
-
definition = fast_deepcopy(parameter.definition)
|
102
|
-
if "schema" in definition:
|
103
|
-
# The actual schema doesn't matter since we have a list of allowed values
|
104
|
-
definition["schema"] = {"enum": parameter_data["options"]}
|
105
|
-
else:
|
106
|
-
# Other schema-related keywords will be ignored later, during the canonicalisation step
|
107
|
-
# inside `hypothesis-jsonschema`
|
108
|
-
definition["enum"] = parameter_data["options"]
|
109
|
-
new_parameter: OpenAPIParameter
|
110
|
-
if isinstance(parameter, OpenAPI30Body):
|
111
|
-
new_parameter = parameter.__class__(
|
112
|
-
definition, media_type=parameter.media_type, required=parameter.required
|
113
|
-
)
|
114
|
-
elif isinstance(parameter, OpenAPI20Body):
|
115
|
-
new_parameter = parameter.__class__(definition, media_type=parameter.media_type)
|
116
|
-
else:
|
117
|
-
new_parameter = parameter.__class__(definition)
|
118
|
-
components[LOCATION_TO_CONTAINER[location]].add(new_parameter)
|
119
|
-
else:
|
120
|
-
# No options were gathered for this parameter - use the original one
|
121
|
-
components[LOCATION_TO_CONTAINER[location]].add(parameter)
|
122
|
-
return self.operation.clone(**components)
|
123
|
-
|
124
|
-
def _get_container_by_parameter_name(self, full_name: str, templates: dict[str, dict[str, dict[str, Any]]]) -> list:
|
125
|
-
"""Detect in what request part the parameters is defined."""
|
126
|
-
location: str | None
|
127
|
-
try:
|
128
|
-
# The parameter name is prefixed with its location. Example: `path.id`
|
129
|
-
location, name = full_name.split(".")
|
130
|
-
except ValueError:
|
131
|
-
location, name = None, full_name
|
132
|
-
if location:
|
133
|
-
try:
|
134
|
-
parameters = templates[location]
|
135
|
-
except KeyError:
|
136
|
-
self._unknown_parameter(full_name)
|
137
|
-
else:
|
138
|
-
for parameters in templates.values():
|
139
|
-
if name in parameters:
|
140
|
-
break
|
141
|
-
else:
|
142
|
-
self._unknown_parameter(full_name)
|
143
|
-
if not parameters:
|
144
|
-
self._unknown_parameter(full_name)
|
145
|
-
return parameters[name]["options"]
|
146
|
-
|
147
|
-
def _unknown_parameter(self, name: str) -> NoReturn:
|
148
|
-
raise ValueError(
|
149
|
-
f"Parameter `{name}` is not defined in API operation {self.operation.method.upper()} {self.operation.path}"
|
150
|
-
)
|
151
|
-
|
152
|
-
|
153
|
-
def get_links(response: GenericResponse, operation: APIOperation, field: str) -> Sequence[Link]:
|
154
|
-
"""Get `x-links` / `links` definitions from the schema."""
|
155
|
-
responses = operation.definition.resolved["responses"]
|
156
|
-
if str(response.status_code) in responses:
|
157
|
-
response_definition = responses[str(response.status_code)]
|
158
|
-
elif response.status_code in responses:
|
159
|
-
response_definition = responses[response.status_code]
|
160
|
-
else:
|
161
|
-
response_definition = responses.get("default", {})
|
162
|
-
links = response_definition.get(field, {})
|
163
|
-
return [Link.from_definition(name, definition, operation) for name, definition in links.items()]
|
27
|
+
|
28
|
+
class SchemathesisLink(TypedDict):
|
29
|
+
merge_body: bool
|
164
30
|
|
165
31
|
|
166
32
|
@dataclass(repr=False)
|
@@ -174,41 +40,59 @@ class OpenAPILink(Direction):
|
|
174
40
|
status_code: str
|
175
41
|
definition: dict[str, Any]
|
176
42
|
operation: APIOperation
|
177
|
-
parameters: list[tuple[
|
43
|
+
parameters: list[tuple[Literal["path", "query", "header", "cookie", "body"] | None, str, str]] = field(init=False)
|
178
44
|
body: dict[str, Any] | NotSet = field(init=False)
|
45
|
+
merge_body: bool = True
|
46
|
+
|
47
|
+
def __repr__(self) -> str:
|
48
|
+
path = self.operation.path
|
49
|
+
method = self.operation.method
|
50
|
+
return f"state.schema['{path}']['{method}'].links['{self.status_code}']['{self.name}']"
|
179
51
|
|
180
52
|
def __post_init__(self) -> None:
|
53
|
+
extension = self.definition.get(SCHEMATHESIS_LINK_EXTENSION)
|
181
54
|
self.parameters = [
|
182
55
|
normalize_parameter(parameter, expression)
|
183
56
|
for parameter, expression in self.definition.get("parameters", {}).items()
|
184
57
|
]
|
185
58
|
self.body = self.definition.get("requestBody", NOT_SET)
|
59
|
+
if extension is not None:
|
60
|
+
self.merge_body = extension.get("merge_body", True)
|
186
61
|
|
187
|
-
def set_data(self, case: Case,
|
62
|
+
def set_data(self, case: Case, **kwargs: Any) -> None:
|
188
63
|
"""Assign all linked definitions to the new case instance."""
|
189
64
|
context = kwargs["context"]
|
190
65
|
self.set_parameters(case, context)
|
191
66
|
self.set_body(case, context)
|
192
|
-
case.set_source(context.response, context.case, elapsed)
|
193
67
|
|
194
68
|
def set_parameters(self, case: Case, context: expressions.ExpressionContext) -> None:
|
195
69
|
for location, name, expression in self.parameters:
|
196
|
-
container = get_container(case, location, name)
|
70
|
+
location, container = get_container(case, location, name)
|
197
71
|
# Might happen if there is directly specified container,
|
198
72
|
# but the schema has no parameters of such type at all.
|
199
73
|
# Therefore the container is empty, otherwise it will be at least an empty object
|
200
74
|
if container is None:
|
201
75
|
message = f"No such parameter in `{case.operation.method.upper()} {case.operation.path}`: `{name}`."
|
202
|
-
possibilities = [param.name for param in case.operation.
|
76
|
+
possibilities = [param.name for param in case.operation.iter_parameters()]
|
203
77
|
matches = get_close_matches(name, possibilities)
|
204
78
|
if matches:
|
205
79
|
message += f" Did you mean `{matches[0]}`?"
|
206
80
|
raise ValueError(message)
|
207
|
-
|
208
|
-
|
209
|
-
|
81
|
+
value = expressions.evaluate(expression, context)
|
82
|
+
if value is not None:
|
83
|
+
container[name] = value
|
84
|
+
|
85
|
+
def set_body(
|
86
|
+
self,
|
87
|
+
case: Case,
|
88
|
+
context: expressions.ExpressionContext,
|
89
|
+
) -> None:
|
210
90
|
if self.body is not NOT_SET:
|
211
|
-
|
91
|
+
evaluated = expressions.evaluate(self.body, context, evaluate_nested=True)
|
92
|
+
if self.merge_body:
|
93
|
+
case.body = merge_body(case.body, evaluated)
|
94
|
+
else:
|
95
|
+
case.body = evaluated
|
212
96
|
|
213
97
|
def get_target_operation(self) -> APIOperation:
|
214
98
|
if "operationId" in self.definition:
|
@@ -216,21 +100,32 @@ class OpenAPILink(Direction):
|
|
216
100
|
return self.operation.schema.get_operation_by_reference(self.definition["operationRef"]) # type: ignore
|
217
101
|
|
218
102
|
|
219
|
-
def
|
103
|
+
def merge_body(old: Any, new: Any) -> Any:
|
104
|
+
if isinstance(old, dict) and isinstance(new, dict):
|
105
|
+
return {**old, **new}
|
106
|
+
return new
|
107
|
+
|
108
|
+
|
109
|
+
def get_container(
|
110
|
+
case: Case, location: Literal["path", "query", "header", "cookie", "body"] | None, name: str
|
111
|
+
) -> tuple[Literal["path", "query", "header", "cookie", "body"], dict[str, Any] | None]:
|
220
112
|
"""Get a container that suppose to store the given parameter."""
|
221
113
|
if location:
|
222
114
|
container_name = LOCATION_TO_CONTAINER[location]
|
223
115
|
else:
|
224
|
-
for param in case.operation.
|
116
|
+
for param in case.operation.iter_parameters():
|
225
117
|
if param.name == name:
|
118
|
+
location = param.location
|
226
119
|
container_name = LOCATION_TO_CONTAINER[param.location]
|
227
120
|
break
|
228
121
|
else:
|
229
|
-
raise ValueError(f"Parameter `{name}` is not defined in API operation `{case.operation.
|
230
|
-
return getattr(case, container_name)
|
122
|
+
raise ValueError(f"Parameter `{name}` is not defined in API operation `{case.operation.label}`")
|
123
|
+
return location, getattr(case, container_name)
|
231
124
|
|
232
125
|
|
233
|
-
def normalize_parameter(
|
126
|
+
def normalize_parameter(
|
127
|
+
parameter: str, expression: str
|
128
|
+
) -> tuple[Literal["path", "query", "header", "cookie", "body"] | None, str, str]:
|
234
129
|
"""Normalize runtime expressions.
|
235
130
|
|
236
131
|
Runtime expressions may have parameter names prefixed with their location - `path.id`.
|
@@ -240,13 +135,15 @@ def normalize_parameter(parameter: str, expression: str) -> tuple[str | None, st
|
|
240
135
|
try:
|
241
136
|
# The parameter name is prefixed with its location. Example: `path.id`
|
242
137
|
location, name = tuple(parameter.split("."))
|
243
|
-
|
138
|
+
_location = cast(Literal["path", "query", "header", "cookie", "body"], location)
|
139
|
+
return _location, name, expression
|
244
140
|
except ValueError:
|
245
141
|
return None, parameter, expression
|
246
142
|
|
247
143
|
|
248
144
|
def get_all_links(operation: APIOperation) -> Generator[tuple[str, OpenAPILink], None, None]:
|
249
|
-
for status_code, definition in operation.definition.
|
145
|
+
for status_code, definition in operation.definition.raw["responses"].items():
|
146
|
+
definition = operation.schema.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 8) # type: ignore[attr-defined]
|
250
147
|
for name, link_definition in definition.get(operation.schema.links_field, {}).items(): # type: ignore
|
251
148
|
yield status_code, OpenAPILink(name, status_code, link_definition, operation)
|
252
149
|
|
@@ -273,6 +170,7 @@ def _get_response_by_status_code(responses: dict[StatusCode, dict[str, Any]], st
|
|
273
170
|
|
274
171
|
|
275
172
|
def add_link(
|
173
|
+
resolver: RefResolver,
|
276
174
|
responses: dict[StatusCode, dict[str, Any]],
|
277
175
|
links_field: str,
|
278
176
|
parameters: dict[str, str] | None,
|
@@ -282,6 +180,8 @@ def add_link(
|
|
282
180
|
name: str | None = None,
|
283
181
|
) -> None:
|
284
182
|
response = _get_response_by_status_code(responses, status_code)
|
183
|
+
if "$ref" in response:
|
184
|
+
_, response = resolver.resolve(response["$ref"])
|
285
185
|
links_definition = response.setdefault(links_field, {})
|
286
186
|
new_link: dict[str, str | dict[str, str]] = {}
|
287
187
|
if parameters is not None:
|
@@ -295,8 +195,8 @@ def add_link(
|
|
295
195
|
name = name or f"{target.method.upper()} {target.path}"
|
296
196
|
# operationId is a dict lookup which is more efficient than using `operationRef`, since it
|
297
197
|
# 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.
|
198
|
+
if "operationId" in target.definition.raw:
|
199
|
+
new_link["operationId"] = target.definition.raw["operationId"]
|
300
200
|
else:
|
301
201
|
new_link["operationRef"] = target.operation_reference
|
302
202
|
# The name is arbitrary, so we don't really case what it is,
|
@@ -0,0 +1,32 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Any, Collection
|
4
|
+
|
5
|
+
from schemathesis.transport import SerializationContext
|
6
|
+
from schemathesis.transport.asgi import ASGI_TRANSPORT
|
7
|
+
from schemathesis.transport.requests import REQUESTS_TRANSPORT
|
8
|
+
from schemathesis.transport.wsgi import WSGI_TRANSPORT
|
9
|
+
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
from hypothesis import strategies as st
|
12
|
+
|
13
|
+
|
14
|
+
MEDIA_TYPES: dict[str, st.SearchStrategy[bytes]] = {}
|
15
|
+
|
16
|
+
|
17
|
+
def register_media_type(name: str, strategy: st.SearchStrategy[bytes], *, aliases: Collection[str] = ()) -> None:
|
18
|
+
"""Register a strategy for the given media type."""
|
19
|
+
|
20
|
+
@REQUESTS_TRANSPORT.serializer(name, *aliases)
|
21
|
+
@ASGI_TRANSPORT.serializer(name, *aliases)
|
22
|
+
@WSGI_TRANSPORT.serializer(name, *aliases)
|
23
|
+
def serialize(ctx: SerializationContext, value: Any) -> dict[str, Any]:
|
24
|
+
return {"data": value}
|
25
|
+
|
26
|
+
MEDIA_TYPES[name] = strategy
|
27
|
+
for alias in aliases:
|
28
|
+
MEDIA_TYPES[alias] = strategy
|
29
|
+
|
30
|
+
|
31
|
+
def unregister_all() -> None:
|
32
|
+
MEDIA_TYPES.clear()
|
@@ -1,7 +1,8 @@
|
|
1
1
|
from __future__ import annotations
|
2
|
+
|
2
3
|
from dataclasses import dataclass
|
3
4
|
from functools import lru_cache
|
4
|
-
from typing import Any
|
5
|
+
from typing import TYPE_CHECKING, Any
|
5
6
|
from urllib.parse import urlencode
|
6
7
|
|
7
8
|
import jsonschema
|
@@ -10,8 +11,11 @@ from hypothesis_jsonschema import from_schema
|
|
10
11
|
|
11
12
|
from ..constants import ALL_KEYWORDS
|
12
13
|
from .mutations import MutationContext
|
13
|
-
|
14
|
-
|
14
|
+
|
15
|
+
if TYPE_CHECKING:
|
16
|
+
from schemathesis.generation import GenerationConfig
|
17
|
+
|
18
|
+
from .types import Draw, Schema
|
15
19
|
|
16
20
|
|
17
21
|
@dataclass
|