schemathesis 3.13.0__py3-none-any.whl → 4.4.2__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 +53 -25
- schemathesis/auths.py +507 -0
- schemathesis/checks.py +190 -25
- schemathesis/cli/__init__.py +27 -1016
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +133 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +602 -0
- schemathesis/cli/commands/run/context.py +228 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +45 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
- schemathesis/cli/commands/run/handlers/output.py +1750 -0
- schemathesis/cli/commands/run/loaders.py +118 -0
- schemathesis/cli/commands/run/validation.py +256 -0
- schemathesis/cli/constants.py +5 -0
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +203 -0
- schemathesis/cli/ext/options.py +81 -0
- schemathesis/config/__init__.py +202 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +101 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +163 -0
- schemathesis/config/_generation.py +157 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +335 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +253 -0
- schemathesis/config/_projects.py +543 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +89 -0
- schemathesis/config/schema.json +975 -0
- schemathesis/core/__init__.py +72 -0
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +100 -0
- schemathesis/core/deserialization.py +210 -0
- schemathesis/core/errors.py +588 -0
- schemathesis/core/failures.py +316 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +183 -0
- schemathesis/core/jsonschema/keywords.py +40 -0
- schemathesis/core/jsonschema/references.py +222 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/lazy_import.py +15 -0
- schemathesis/core/loaders.py +107 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/core/media_types.py +79 -0
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/core/parameters.py +45 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +34 -0
- schemathesis/core/result.py +27 -0
- schemathesis/core/schema_analysis.py +17 -0
- schemathesis/core/shell.py +203 -0
- schemathesis/core/transforms.py +144 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +73 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +152 -0
- schemathesis/engine/control.py +44 -0
- schemathesis/engine/core.py +201 -0
- schemathesis/engine/errors.py +446 -0
- schemathesis/engine/events.py +284 -0
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +108 -0
- schemathesis/engine/phases/analysis.py +28 -0
- schemathesis/engine/phases/probes.py +172 -0
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +364 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +220 -0
- schemathesis/engine/phases/unit/_executor.py +459 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +254 -0
- schemathesis/errors.py +47 -0
- schemathesis/filters.py +395 -0
- schemathesis/generation/__init__.py +25 -0
- schemathesis/generation/case.py +478 -0
- schemathesis/generation/coverage.py +1528 -0
- schemathesis/generation/hypothesis/__init__.py +121 -0
- schemathesis/generation/hypothesis/builder.py +992 -0
- schemathesis/generation/hypothesis/examples.py +56 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +285 -0
- schemathesis/generation/meta.py +227 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +127 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +294 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +285 -0
- schemathesis/hooks.py +270 -91
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +467 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +315 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +341 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/pytest/plugin.py +357 -0
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +683 -247
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/nodes.py +27 -0
- schemathesis/specs/graphql/scalars.py +86 -0
- schemathesis/specs/graphql/schemas.py +395 -123
- schemathesis/specs/graphql/validation.py +33 -0
- schemathesis/specs/openapi/__init__.py +9 -1
- schemathesis/specs/openapi/_hypothesis.py +578 -317
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +729 -0
- schemathesis/specs/openapi/adapter/protocol.py +59 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +368 -0
- schemathesis/specs/openapi/adapter/security.py +144 -0
- schemathesis/specs/openapi/adapter/v2.py +30 -0
- schemathesis/specs/openapi/adapter/v3_0.py +30 -0
- schemathesis/specs/openapi/adapter/v3_1.py +30 -0
- schemathesis/specs/openapi/analysis.py +96 -0
- schemathesis/specs/openapi/checks.py +753 -74
- schemathesis/specs/openapi/converter.py +176 -37
- schemathesis/specs/openapi/definitions.py +599 -4
- schemathesis/specs/openapi/examples.py +581 -165
- schemathesis/specs/openapi/expressions/__init__.py +52 -5
- schemathesis/specs/openapi/expressions/extractors.py +25 -0
- schemathesis/specs/openapi/expressions/lexer.py +34 -31
- schemathesis/specs/openapi/expressions/nodes.py +97 -46
- schemathesis/specs/openapi/expressions/parser.py +35 -13
- schemathesis/specs/openapi/formats.py +122 -0
- schemathesis/specs/openapi/media_types.py +75 -0
- schemathesis/specs/openapi/negative/__init__.py +117 -68
- schemathesis/specs/openapi/negative/mutations.py +294 -104
- schemathesis/specs/openapi/negative/utils.py +3 -6
- schemathesis/specs/openapi/patterns.py +458 -0
- schemathesis/specs/openapi/references.py +60 -81
- schemathesis/specs/openapi/schemas.py +648 -650
- schemathesis/specs/openapi/serialization.py +53 -30
- schemathesis/specs/openapi/stateful/__init__.py +404 -69
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
- schemathesis/specs/openapi/stateful/inference.py +254 -0
- schemathesis/specs/openapi/stateful/links.py +219 -78
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/common.py +23 -0
- schemathesis/specs/openapi/types/v2.py +129 -0
- schemathesis/specs/openapi/types/v3.py +134 -0
- schemathesis/specs/openapi/utils.py +7 -6
- schemathesis/specs/openapi/warnings.py +75 -0
- schemathesis/transport/__init__.py +224 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +126 -0
- schemathesis/transport/requests.py +278 -0
- schemathesis/transport/serialization.py +329 -0
- schemathesis/transport/wsgi.py +175 -0
- schemathesis-4.4.2.dist-info/METADATA +213 -0
- schemathesis-4.4.2.dist-info/RECORD +192 -0
- {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
- schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
- {schemathesis-3.13.0.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
- schemathesis/_compat.py +0 -41
- schemathesis/_hypothesis.py +0 -115
- schemathesis/cli/callbacks.py +0 -188
- schemathesis/cli/cassettes.py +0 -253
- schemathesis/cli/context.py +0 -36
- schemathesis/cli/debug.py +0 -21
- schemathesis/cli/handlers.py +0 -11
- schemathesis/cli/junitxml.py +0 -41
- schemathesis/cli/options.py +0 -51
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -508
- schemathesis/cli/output/short.py +0 -40
- schemathesis/constants.py +0 -79
- schemathesis/exceptions.py +0 -207
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -16
- schemathesis/extra/pytest_plugin.py +0 -216
- schemathesis/failures.py +0 -131
- schemathesis/fixups/__init__.py +0 -29
- schemathesis/fixups/fast_api.py +0 -30
- schemathesis/lazy.py +0 -227
- schemathesis/models.py +0 -1041
- schemathesis/parameters.py +0 -88
- schemathesis/runner/__init__.py +0 -460
- schemathesis/runner/events.py +0 -240
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -755
- schemathesis/runner/impl/solo.py +0 -85
- schemathesis/runner/impl/threadpool.py +0 -367
- schemathesis/runner/serialization.py +0 -189
- schemathesis/serializers.py +0 -233
- schemathesis/service/__init__.py +0 -3
- schemathesis/service/client.py +0 -46
- schemathesis/service/constants.py +0 -12
- schemathesis/service/events.py +0 -39
- schemathesis/service/handler.py +0 -39
- schemathesis/service/models.py +0 -7
- schemathesis/service/serialization.py +0 -153
- schemathesis/service/worker.py +0 -40
- schemathesis/specs/graphql/loaders.py +0 -215
- schemathesis/specs/openapi/constants.py +0 -7
- schemathesis/specs/openapi/expressions/context.py +0 -12
- schemathesis/specs/openapi/expressions/pointers.py +0 -29
- schemathesis/specs/openapi/filters.py +0 -44
- schemathesis/specs/openapi/links.py +0 -302
- schemathesis/specs/openapi/loaders.py +0 -453
- schemathesis/specs/openapi/parameters.py +0 -413
- schemathesis/specs/openapi/security.py +0 -129
- schemathesis/specs/openapi/validation.py +0 -24
- schemathesis/stateful.py +0 -349
- schemathesis/targets.py +0 -32
- schemathesis/types.py +0 -38
- schemathesis/utils.py +0 -436
- schemathesis-3.13.0.dist-info/METADATA +0 -202
- schemathesis-3.13.0.dist-info/RECORD +0 -91
- schemathesis-3.13.0.dist-info/entry_points.txt +0 -6
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
|
@@ -1,118 +1,74 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
from
|
|
5
|
-
from
|
|
6
|
-
from copy import deepcopy
|
|
7
|
-
from typing import Any, Callable, Dict, Generator, Iterable, Optional, Tuple, Union
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Callable, Iterable, Optional, Union, cast
|
|
8
6
|
from urllib.parse import quote_plus
|
|
9
|
-
from weakref import WeakKeyDictionary
|
|
10
7
|
|
|
8
|
+
import jsonschema.protocols
|
|
9
|
+
from hypothesis import event, note, reject
|
|
11
10
|
from hypothesis import strategies as st
|
|
12
11
|
from hypothesis_jsonschema import from_schema
|
|
13
|
-
from requests.auth import _basic_auth_str
|
|
14
12
|
from requests.structures import CaseInsensitiveDict
|
|
15
13
|
|
|
16
|
-
from
|
|
17
|
-
from
|
|
18
|
-
from
|
|
19
|
-
from
|
|
20
|
-
from
|
|
21
|
-
from
|
|
22
|
-
from
|
|
23
|
-
from .
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
register_string_format("binary", st.binary())
|
|
52
|
-
register_string_format("byte", st.binary().map(lambda x: b64encode(x).decode()))
|
|
53
|
-
|
|
54
|
-
def make_basic_auth_str(item: Tuple[str, str]) -> str:
|
|
55
|
-
return _basic_auth_str(*item)
|
|
56
|
-
|
|
57
|
-
latin1_text = st.text(alphabet=st.characters(min_codepoint=0, max_codepoint=255))
|
|
58
|
-
|
|
59
|
-
# RFC 7230, Section 3.2.6
|
|
60
|
-
register_string_format(
|
|
61
|
-
"_header_name",
|
|
62
|
-
st.text(min_size=1, alphabet=st.sampled_from("!#$%&'*+-.^_`|~" + string.digits + string.ascii_letters)),
|
|
63
|
-
)
|
|
64
|
-
# Define valid characters here to avoid filtering them out in `is_valid_header` later
|
|
65
|
-
header_value = st.text(alphabet=st.characters(min_codepoint=0, max_codepoint=255, blacklist_characters="\n\r"))
|
|
66
|
-
# Header values with leading non-visible chars can't be sent with `requests`
|
|
67
|
-
register_string_format(HEADER_FORMAT, header_value.map(str.lstrip))
|
|
68
|
-
register_string_format("_basic_auth", st.tuples(latin1_text, latin1_text).map(make_basic_auth_str)) # type: ignore
|
|
69
|
-
register_string_format(
|
|
70
|
-
"_bearer_auth",
|
|
71
|
-
header_value.map("Bearer {}".format),
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def is_valid_header(headers: Dict[str, Any]) -> bool:
|
|
76
|
-
"""Verify if the generated headers are valid."""
|
|
77
|
-
for name, value in headers.items():
|
|
78
|
-
if not utils.is_latin_1_encodable(value):
|
|
79
|
-
return False
|
|
80
|
-
if utils.has_invalid_characters(name, value):
|
|
81
|
-
return False
|
|
82
|
-
return True
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def is_illegal_surrogate(item: Any) -> bool:
|
|
86
|
-
def check(value: Any) -> bool:
|
|
87
|
-
return isinstance(value, str) and bool(re.search(r"[\ud800-\udfff]", value))
|
|
88
|
-
|
|
89
|
-
if isinstance(item, list):
|
|
90
|
-
return any(check(item_) for item_ in item)
|
|
91
|
-
return check(item)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def is_valid_query(query: Dict[str, Any]) -> bool:
|
|
95
|
-
"""Surrogates are not allowed in a query string.
|
|
14
|
+
from schemathesis.config import GenerationConfig
|
|
15
|
+
from schemathesis.core import NOT_SET, media_types
|
|
16
|
+
from schemathesis.core.control import SkipTest
|
|
17
|
+
from schemathesis.core.errors import SERIALIZERS_SUGGESTION_MESSAGE, MalformedMediaType, SerializationNotPossible
|
|
18
|
+
from schemathesis.core.jsonschema.types import JsonSchema
|
|
19
|
+
from schemathesis.core.parameters import ParameterLocation
|
|
20
|
+
from schemathesis.core.transport import prepare_urlencoded
|
|
21
|
+
from schemathesis.generation.meta import (
|
|
22
|
+
CaseMetadata,
|
|
23
|
+
ComponentInfo,
|
|
24
|
+
ExamplesPhaseData,
|
|
25
|
+
FuzzingPhaseData,
|
|
26
|
+
GenerationInfo,
|
|
27
|
+
PhaseInfo,
|
|
28
|
+
StatefulPhaseData,
|
|
29
|
+
TestPhase,
|
|
30
|
+
)
|
|
31
|
+
from schemathesis.openapi.generation.filters import is_valid_urlencoded
|
|
32
|
+
from schemathesis.schemas import APIOperation
|
|
33
|
+
from schemathesis.specs.openapi.adapter.parameters import FORM_MEDIA_TYPES, OpenApiBody, OpenApiParameterSet
|
|
34
|
+
from schemathesis.specs.openapi.negative.mutations import MutationMetadata
|
|
35
|
+
|
|
36
|
+
from ... import auths
|
|
37
|
+
from ...generation import GenerationMode
|
|
38
|
+
from ...hooks import HookContext, HookDispatcher, apply_to_all_dispatchers
|
|
39
|
+
from .formats import (
|
|
40
|
+
DEFAULT_HEADER_EXCLUDE_CHARACTERS,
|
|
41
|
+
HEADER_FORMAT,
|
|
42
|
+
STRING_FORMATS,
|
|
43
|
+
get_default_format_strategies,
|
|
44
|
+
header_values,
|
|
45
|
+
)
|
|
46
|
+
from .media_types import MEDIA_TYPES
|
|
47
|
+
from .negative import GeneratedValue, negative_schema
|
|
48
|
+
from .negative.utils import can_negate
|
|
96
49
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return True
|
|
50
|
+
SLASH = "/"
|
|
51
|
+
StrategyFactory = Callable[
|
|
52
|
+
[JsonSchema, str, ParameterLocation, Optional[str], GenerationConfig, type[jsonschema.protocols.Validator]],
|
|
53
|
+
st.SearchStrategy,
|
|
54
|
+
]
|
|
103
55
|
|
|
104
56
|
|
|
105
|
-
@st.composite # type: ignore
|
|
106
|
-
def
|
|
107
|
-
draw:
|
|
57
|
+
@st.composite # type: ignore[misc]
|
|
58
|
+
def openapi_cases(
|
|
59
|
+
draw: st.DrawFn,
|
|
60
|
+
*,
|
|
108
61
|
operation: APIOperation,
|
|
109
|
-
hooks:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
62
|
+
hooks: HookDispatcher | None = None,
|
|
63
|
+
auth_storage: auths.AuthStorage | None = None,
|
|
64
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
|
65
|
+
path_parameters: dict[str, Any] | None = None,
|
|
66
|
+
headers: dict[str, Any] | None = None,
|
|
67
|
+
cookies: dict[str, Any] | None = None,
|
|
68
|
+
query: dict[str, Any] | None = None,
|
|
115
69
|
body: Any = NOT_SET,
|
|
70
|
+
media_type: str | None = None,
|
|
71
|
+
phase: TestPhase = TestPhase.FUZZING,
|
|
116
72
|
) -> Any:
|
|
117
73
|
"""A strategy that creates `Case` instances.
|
|
118
74
|
|
|
@@ -126,254 +82,573 @@ def get_case_strategy( # pylint: disable=too-many-locals
|
|
|
126
82
|
The primary purpose of this behavior is to prevent sending incomplete explicit examples by generating missing parts
|
|
127
83
|
as it works with `body`.
|
|
128
84
|
"""
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
85
|
+
start = time.monotonic()
|
|
86
|
+
|
|
87
|
+
generation_config = operation.schema.config.generation_for(operation=operation, phase=phase.value)
|
|
88
|
+
|
|
89
|
+
ctx = HookContext(operation=operation)
|
|
90
|
+
|
|
91
|
+
path_parameters_ = generate_parameter(
|
|
92
|
+
ParameterLocation.PATH, path_parameters, operation, draw, ctx, hooks, generation_mode, generation_config
|
|
93
|
+
)
|
|
94
|
+
headers_ = generate_parameter(
|
|
95
|
+
ParameterLocation.HEADER, headers, operation, draw, ctx, hooks, generation_mode, generation_config
|
|
96
|
+
)
|
|
97
|
+
cookies_ = generate_parameter(
|
|
98
|
+
ParameterLocation.COOKIE, cookies, operation, draw, ctx, hooks, generation_mode, generation_config
|
|
99
|
+
)
|
|
100
|
+
query_ = generate_parameter(
|
|
101
|
+
ParameterLocation.QUERY, query, operation, draw, ctx, hooks, generation_mode, generation_config
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if body is NOT_SET:
|
|
105
|
+
if operation.body:
|
|
106
|
+
body_generator = generation_mode
|
|
107
|
+
if generation_mode.is_negative:
|
|
108
|
+
# Consider only schemas that are possible to negate
|
|
109
|
+
candidates = [item for item in operation.body.items if can_negate(item.optimized_schema)]
|
|
110
|
+
# Not possible to negate body, fallback to positive data generation
|
|
111
|
+
if not candidates:
|
|
112
|
+
candidates = operation.body.items
|
|
113
|
+
body_generator = GenerationMode.POSITIVE
|
|
114
|
+
else:
|
|
115
|
+
candidates = operation.body.items
|
|
116
|
+
parameter = draw(st.sampled_from(candidates))
|
|
117
|
+
strategy = _get_body_strategy(parameter, operation, generation_config, draw, body_generator)
|
|
118
|
+
strategy = apply_hooks(operation, ctx, hooks, strategy, ParameterLocation.BODY)
|
|
119
|
+
# Parameter may have a wildcard media type. In this case, choose any supported one
|
|
120
|
+
possible_media_types = sorted(
|
|
121
|
+
operation.schema.transport.get_matching_media_types(parameter.media_type), key=lambda x: x[0]
|
|
122
|
+
)
|
|
123
|
+
if not possible_media_types:
|
|
124
|
+
all_media_types = operation.get_request_payload_content_types()
|
|
125
|
+
if all(
|
|
126
|
+
operation.schema.transport.get_first_matching_media_type(media_type) is None
|
|
127
|
+
for media_type in all_media_types
|
|
128
|
+
):
|
|
129
|
+
# None of media types defined for this operation are not supported
|
|
130
|
+
raise SerializationNotPossible.from_media_types(*all_media_types) from None
|
|
131
|
+
# Other media types are possible - avoid choosing this media type in the future
|
|
132
|
+
event_text = f"Can't serialize data to `{parameter.media_type}`."
|
|
133
|
+
note(f"{event_text} {SERIALIZERS_SUGGESTION_MESSAGE}")
|
|
134
|
+
event(event_text)
|
|
135
|
+
reject()
|
|
136
|
+
media_type, _ = draw(st.sampled_from(possible_media_types))
|
|
137
|
+
if media_type is not None and media_types.parse(media_type) == (
|
|
138
|
+
"application",
|
|
139
|
+
"x-www-form-urlencoded",
|
|
140
|
+
):
|
|
141
|
+
if body_generator.is_negative:
|
|
142
|
+
# For negative strategies, unwrap GeneratedValue, apply transformation, then rewrap
|
|
143
|
+
strategy = strategy.map(
|
|
144
|
+
lambda x: GeneratedValue(prepare_urlencoded(x.value), x.meta)
|
|
145
|
+
if isinstance(x, GeneratedValue)
|
|
146
|
+
else prepare_urlencoded(x)
|
|
147
|
+
).filter(lambda x: is_valid_urlencoded(x.value if isinstance(x, GeneratedValue) else x))
|
|
148
|
+
else:
|
|
149
|
+
strategy = strategy.map(prepare_urlencoded).filter(is_valid_urlencoded)
|
|
150
|
+
body_result = draw(strategy)
|
|
151
|
+
body_metadata = None
|
|
152
|
+
# Negative strategy returns GeneratedValue, positive returns just value
|
|
153
|
+
if isinstance(body_result, GeneratedValue):
|
|
154
|
+
body_metadata = body_result.meta
|
|
155
|
+
body_result = body_result.value
|
|
156
|
+
body_ = ValueContainer(value=body_result, location="body", generator=body_generator, meta=body_metadata)
|
|
149
157
|
else:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
158
|
+
body_ = ValueContainer(value=body, location="body", generator=None, meta=None)
|
|
159
|
+
else:
|
|
160
|
+
# This explicit body payload comes for a media type that has a custom strategy registered
|
|
161
|
+
# Such strategies only support binary payloads, otherwise they can't be serialized
|
|
162
|
+
if not isinstance(body, bytes) and media_type and _find_media_type_strategy(media_type) is not None:
|
|
163
|
+
all_media_types = operation.get_request_payload_content_types()
|
|
164
|
+
raise SerializationNotPossible.from_media_types(*all_media_types)
|
|
165
|
+
body_ = ValueContainer(value=body, location="body", generator=None, meta=None)
|
|
166
|
+
|
|
167
|
+
# If we need to generate negative cases but no generated values were negated, then skip the whole test
|
|
168
|
+
if generation_mode.is_negative and not any_negated_values([query_, cookies_, headers_, path_parameters_, body_]):
|
|
169
|
+
if generation_config.modes == [GenerationMode.NEGATIVE]:
|
|
170
|
+
raise SkipTest("Impossible to generate negative test cases")
|
|
171
|
+
else:
|
|
172
|
+
reject()
|
|
173
|
+
|
|
174
|
+
# Extract mutation metadata from negated values and create phase-appropriate data
|
|
175
|
+
if generation_mode.is_negative:
|
|
176
|
+
negated_container = None
|
|
177
|
+
for container in [query_, cookies_, headers_, path_parameters_, body_]:
|
|
178
|
+
if container.generator == GenerationMode.NEGATIVE and container.meta is not None:
|
|
179
|
+
negated_container = container
|
|
180
|
+
break
|
|
181
|
+
|
|
182
|
+
if negated_container and negated_container.meta:
|
|
183
|
+
metadata = negated_container.meta
|
|
184
|
+
location_map = {
|
|
185
|
+
"query": ParameterLocation.QUERY,
|
|
186
|
+
"path_parameters": ParameterLocation.PATH,
|
|
187
|
+
"headers": ParameterLocation.HEADER,
|
|
188
|
+
"cookies": ParameterLocation.COOKIE,
|
|
189
|
+
"body": ParameterLocation.BODY,
|
|
190
|
+
}
|
|
191
|
+
parameter_location = location_map.get(negated_container.location)
|
|
192
|
+
_phase_data = {
|
|
193
|
+
TestPhase.EXAMPLES: ExamplesPhaseData(
|
|
194
|
+
description=metadata.description,
|
|
195
|
+
parameter=metadata.parameter,
|
|
196
|
+
parameter_location=parameter_location,
|
|
197
|
+
location=metadata.location,
|
|
198
|
+
),
|
|
199
|
+
TestPhase.FUZZING: FuzzingPhaseData(
|
|
200
|
+
description=metadata.description,
|
|
201
|
+
parameter=metadata.parameter,
|
|
202
|
+
parameter_location=parameter_location,
|
|
203
|
+
location=metadata.location,
|
|
204
|
+
),
|
|
205
|
+
TestPhase.STATEFUL: StatefulPhaseData(
|
|
206
|
+
description=metadata.description,
|
|
207
|
+
parameter=metadata.parameter,
|
|
208
|
+
parameter_location=parameter_location,
|
|
209
|
+
location=metadata.location,
|
|
210
|
+
),
|
|
211
|
+
}[phase]
|
|
212
|
+
phase_data = cast(Union[ExamplesPhaseData, FuzzingPhaseData, StatefulPhaseData], _phase_data)
|
|
213
|
+
else:
|
|
214
|
+
_phase_data = {
|
|
215
|
+
TestPhase.EXAMPLES: ExamplesPhaseData(
|
|
216
|
+
description="Schema mutated",
|
|
217
|
+
parameter=None,
|
|
218
|
+
parameter_location=None,
|
|
219
|
+
location=None,
|
|
220
|
+
),
|
|
221
|
+
TestPhase.FUZZING: FuzzingPhaseData(
|
|
222
|
+
description="Schema mutated",
|
|
223
|
+
parameter=None,
|
|
224
|
+
parameter_location=None,
|
|
225
|
+
location=None,
|
|
226
|
+
),
|
|
227
|
+
TestPhase.STATEFUL: StatefulPhaseData(
|
|
228
|
+
description="Schema mutated",
|
|
229
|
+
parameter=None,
|
|
230
|
+
parameter_location=None,
|
|
231
|
+
location=None,
|
|
232
|
+
),
|
|
233
|
+
}[phase]
|
|
234
|
+
phase_data = cast(Union[ExamplesPhaseData, FuzzingPhaseData, StatefulPhaseData], _phase_data)
|
|
235
|
+
else:
|
|
236
|
+
_phase_data = {
|
|
237
|
+
TestPhase.EXAMPLES: ExamplesPhaseData(
|
|
238
|
+
description="Positive test case",
|
|
239
|
+
parameter=None,
|
|
240
|
+
parameter_location=None,
|
|
241
|
+
location=None,
|
|
242
|
+
),
|
|
243
|
+
TestPhase.FUZZING: FuzzingPhaseData(
|
|
244
|
+
description="Positive test case",
|
|
245
|
+
parameter=None,
|
|
246
|
+
parameter_location=None,
|
|
247
|
+
location=None,
|
|
248
|
+
),
|
|
249
|
+
TestPhase.STATEFUL: StatefulPhaseData(
|
|
250
|
+
description="Positive test case",
|
|
251
|
+
parameter=None,
|
|
252
|
+
parameter_location=None,
|
|
253
|
+
location=None,
|
|
254
|
+
),
|
|
255
|
+
}[phase]
|
|
256
|
+
phase_data = cast(Union[ExamplesPhaseData, FuzzingPhaseData, StatefulPhaseData], _phase_data)
|
|
257
|
+
|
|
258
|
+
instance = operation.Case(
|
|
162
259
|
media_type=media_type,
|
|
163
|
-
path_parameters=
|
|
164
|
-
headers=CaseInsensitiveDict(
|
|
165
|
-
cookies=
|
|
166
|
-
query=
|
|
167
|
-
body=
|
|
168
|
-
|
|
260
|
+
path_parameters=path_parameters_.value or {},
|
|
261
|
+
headers=headers_.value or CaseInsensitiveDict(),
|
|
262
|
+
cookies=cookies_.value or {},
|
|
263
|
+
query=query_.value or {},
|
|
264
|
+
body=body_.value,
|
|
265
|
+
_meta=CaseMetadata(
|
|
266
|
+
generation=GenerationInfo(
|
|
267
|
+
time=time.monotonic() - start,
|
|
268
|
+
mode=generation_mode,
|
|
269
|
+
),
|
|
270
|
+
phase=PhaseInfo(name=phase, data=phase_data),
|
|
271
|
+
components={
|
|
272
|
+
kind: ComponentInfo(mode=value.generator)
|
|
273
|
+
for kind, value in [
|
|
274
|
+
(ParameterLocation.QUERY, query_),
|
|
275
|
+
(ParameterLocation.PATH, path_parameters_),
|
|
276
|
+
(ParameterLocation.HEADER, headers_),
|
|
277
|
+
(ParameterLocation.COOKIE, cookies_),
|
|
278
|
+
(ParameterLocation.BODY, body_),
|
|
279
|
+
]
|
|
280
|
+
if value.generator is not None
|
|
281
|
+
},
|
|
282
|
+
),
|
|
169
283
|
)
|
|
284
|
+
auth_context = auths.AuthContext(
|
|
285
|
+
operation=operation,
|
|
286
|
+
app=operation.app,
|
|
287
|
+
)
|
|
288
|
+
auths.set_on_case(instance, auth_context, auth_storage)
|
|
289
|
+
return instance
|
|
170
290
|
|
|
171
291
|
|
|
172
|
-
|
|
173
|
-
"The API schema contains non-string keys. "
|
|
174
|
-
"If you store your schema in YAML, it is likely caused by unquoted keys parsed as "
|
|
175
|
-
"non-strings. For example, `on` is parsed as boolean `true`, "
|
|
176
|
-
"but `'on'` (with quotes) is a string `'on'`. See more information at https://noyaml.com/."
|
|
177
|
-
)
|
|
292
|
+
OPTIONAL_BODY_RATE = 0.05
|
|
178
293
|
|
|
179
294
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
except TypeError as exc:
|
|
186
|
-
if is_yaml_parsing_issue(operation):
|
|
187
|
-
raise InvalidSchema(YAML_PARSING_ISSUE_MESSAGE) from exc
|
|
188
|
-
raise
|
|
189
|
-
|
|
295
|
+
def _find_media_type_strategy(content_type: str) -> st.SearchStrategy[bytes] | None:
|
|
296
|
+
"""Find a registered strategy for a content type, supporting wildcard patterns."""
|
|
297
|
+
# Try exact match first
|
|
298
|
+
if content_type in MEDIA_TYPES:
|
|
299
|
+
return MEDIA_TYPES[content_type]
|
|
190
300
|
|
|
191
|
-
def is_yaml_parsing_issue(operation: APIOperation) -> bool:
|
|
192
|
-
"""Detect whether the API operation has problems because of YAML syntax.
|
|
193
|
-
|
|
194
|
-
For example, unquoted 'on' is parsed as `True`.
|
|
195
|
-
"""
|
|
196
301
|
try:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
302
|
+
main, sub = media_types.parse(content_type)
|
|
303
|
+
except MalformedMediaType:
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
# Check registered media types for wildcard matches
|
|
307
|
+
for registered_type, strategy in MEDIA_TYPES.items():
|
|
308
|
+
try:
|
|
309
|
+
target_main, target_sub = media_types.parse(registered_type)
|
|
310
|
+
except MalformedMediaType:
|
|
311
|
+
continue
|
|
312
|
+
# Match if both main and sub types are compatible
|
|
313
|
+
# "*" in either the requested or registered type acts as a wildcard
|
|
314
|
+
main_match = main == "*" or target_main == "*" or main == target_main
|
|
315
|
+
sub_match = sub == "*" or target_sub == "*" or sub == target_sub
|
|
316
|
+
if main_match and sub_match:
|
|
317
|
+
return strategy
|
|
318
|
+
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _build_form_strategy_with_encoding(
|
|
323
|
+
parameter: OpenApiBody,
|
|
324
|
+
operation: APIOperation,
|
|
325
|
+
generation_config: GenerationConfig,
|
|
326
|
+
generation_mode: GenerationMode,
|
|
327
|
+
) -> st.SearchStrategy | None:
|
|
328
|
+
"""Build a strategy for form bodies that have custom encoding contentType.
|
|
202
329
|
|
|
330
|
+
Supports wildcard media type matching (e.g., "image/*" matches "image/png").
|
|
203
331
|
|
|
204
|
-
|
|
332
|
+
Returns `None` if no custom encoding with registered strategies is found.
|
|
333
|
+
"""
|
|
334
|
+
schema = parameter.optimized_schema
|
|
335
|
+
if not isinstance(schema, dict) or schema.get("type") != "object":
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
properties = schema.get("properties", {})
|
|
339
|
+
if not properties:
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
# Check which properties have custom content types with registered strategies
|
|
343
|
+
custom_property_strategies = {}
|
|
344
|
+
for property_name in properties:
|
|
345
|
+
content_type = parameter.get_property_content_type(property_name)
|
|
346
|
+
|
|
347
|
+
if content_type is not None and not isinstance(content_type, str):
|
|
348
|
+
# Happens in broken schemas
|
|
349
|
+
continue # type: ignore[unreachable]
|
|
350
|
+
|
|
351
|
+
if content_type:
|
|
352
|
+
# Handle multiple content types (e.g., "image/png, image/jpeg")
|
|
353
|
+
content_types = [ct.strip() for ct in content_type.split(",")]
|
|
354
|
+
strategies_for_types = []
|
|
355
|
+
for ct in content_types:
|
|
356
|
+
strategy = _find_media_type_strategy(ct)
|
|
357
|
+
if strategy is not None:
|
|
358
|
+
strategies_for_types.append(strategy)
|
|
359
|
+
|
|
360
|
+
if strategies_for_types:
|
|
361
|
+
custom_property_strategies[property_name] = st.one_of(*strategies_for_types)
|
|
362
|
+
|
|
363
|
+
if not custom_property_strategies:
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
# Build strategies for properties
|
|
367
|
+
property_strategies = {}
|
|
368
|
+
for property_name, subschema in properties.items():
|
|
369
|
+
if property_name in custom_property_strategies:
|
|
370
|
+
property_strategies[property_name] = custom_property_strategies[property_name]
|
|
371
|
+
else:
|
|
372
|
+
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
|
373
|
+
|
|
374
|
+
assert isinstance(operation.schema, BaseOpenAPISchema)
|
|
375
|
+
strategy_factory = GENERATOR_MODE_TO_STRATEGY_FACTORY[generation_mode]
|
|
376
|
+
property_strategies[property_name] = strategy_factory(
|
|
377
|
+
subschema,
|
|
378
|
+
operation.label,
|
|
379
|
+
ParameterLocation.BODY,
|
|
380
|
+
parameter.media_type,
|
|
381
|
+
generation_config,
|
|
382
|
+
operation.schema.adapter.jsonschema_validator_cls,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Build fixed dictionary strategy with optional properties
|
|
386
|
+
required = set(schema.get("required", []))
|
|
387
|
+
required_strategies = {k: v for k, v in property_strategies.items() if k in required}
|
|
388
|
+
optional_strategies = {k: st.just(NOT_SET) | v for k, v in property_strategies.items() if k not in required}
|
|
389
|
+
|
|
390
|
+
@st.composite # type: ignore[misc]
|
|
391
|
+
def build_body(draw: st.DrawFn) -> dict[str, Any]:
|
|
392
|
+
body: dict[str, Any] = {}
|
|
393
|
+
# Generate required properties
|
|
394
|
+
for key, strategy in required_strategies.items():
|
|
395
|
+
body[key] = draw(strategy)
|
|
396
|
+
# Generate optional properties, filtering out NOT_SET
|
|
397
|
+
for key, strategy in optional_strategies.items():
|
|
398
|
+
value = draw(strategy)
|
|
399
|
+
if value is not NOT_SET:
|
|
400
|
+
body[key] = value
|
|
401
|
+
return body
|
|
402
|
+
|
|
403
|
+
return build_body()
|
|
205
404
|
|
|
206
405
|
|
|
207
406
|
def _get_body_strategy(
|
|
208
|
-
parameter:
|
|
209
|
-
to_strategy: StrategyFactory,
|
|
407
|
+
parameter: OpenApiBody,
|
|
210
408
|
operation: APIOperation,
|
|
409
|
+
generation_config: GenerationConfig,
|
|
410
|
+
draw: st.DrawFn,
|
|
411
|
+
generation_mode: GenerationMode,
|
|
211
412
|
) -> st.SearchStrategy:
|
|
212
|
-
#
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
413
|
+
# Check for custom encoding in form bodies (multipart/form-data or application/x-www-form-urlencoded)
|
|
414
|
+
if parameter.media_type in FORM_MEDIA_TYPES:
|
|
415
|
+
custom_strategy = _build_form_strategy_with_encoding(parameter, operation, generation_config, generation_mode)
|
|
416
|
+
if custom_strategy is not None:
|
|
417
|
+
return custom_strategy
|
|
418
|
+
|
|
419
|
+
# Check for custom media type strategy
|
|
420
|
+
custom_strategy = _find_media_type_strategy(parameter.media_type)
|
|
421
|
+
if custom_strategy is not None:
|
|
422
|
+
return custom_strategy
|
|
423
|
+
|
|
424
|
+
# Use the cached strategy from the parameter
|
|
425
|
+
strategy = parameter.get_strategy(operation, generation_config, generation_mode)
|
|
426
|
+
|
|
427
|
+
# It is likely will be rejected, hence choose it rarely
|
|
428
|
+
if (
|
|
429
|
+
not parameter.is_required
|
|
430
|
+
and draw(st.floats(min_value=0.0, max_value=1.0, allow_infinity=False, allow_nan=False, allow_subnormal=False))
|
|
431
|
+
< OPTIONAL_BODY_RATE
|
|
432
|
+
):
|
|
220
433
|
strategy |= st.just(NOT_SET)
|
|
221
|
-
_BODY_STRATEGIES_CACHE.setdefault(parameter, {})[to_strategy] = strategy
|
|
222
434
|
return strategy
|
|
223
435
|
|
|
224
436
|
|
|
225
437
|
def get_parameters_value(
|
|
226
|
-
value:
|
|
227
|
-
location:
|
|
228
|
-
draw:
|
|
438
|
+
value: dict[str, Any] | None,
|
|
439
|
+
location: ParameterLocation,
|
|
440
|
+
draw: st.DrawFn,
|
|
229
441
|
operation: APIOperation,
|
|
230
|
-
|
|
231
|
-
hooks:
|
|
232
|
-
|
|
233
|
-
|
|
442
|
+
ctx: HookContext,
|
|
443
|
+
hooks: HookDispatcher | None,
|
|
444
|
+
generation_mode: GenerationMode,
|
|
445
|
+
generation_config: GenerationConfig,
|
|
446
|
+
) -> tuple[dict[str, Any] | None, Any]:
|
|
234
447
|
"""Get the final value for the specified location.
|
|
235
448
|
|
|
236
449
|
If the value is not set, then generate it from the relevant strategy. Otherwise, check what is missing in it and
|
|
237
450
|
generate those parts.
|
|
238
451
|
"""
|
|
239
|
-
if
|
|
240
|
-
strategy = get_parameters_strategy(operation,
|
|
241
|
-
strategy = apply_hooks(operation,
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
452
|
+
if value is None:
|
|
453
|
+
strategy = get_parameters_strategy(operation, generation_mode, location, generation_config)
|
|
454
|
+
strategy = apply_hooks(operation, ctx, hooks, strategy, location)
|
|
455
|
+
result = draw(strategy)
|
|
456
|
+
# Negative strategy returns GeneratedValue, positive returns just value
|
|
457
|
+
if isinstance(result, GeneratedValue):
|
|
458
|
+
return result.value, result.meta
|
|
459
|
+
return result, None
|
|
460
|
+
strategy = get_parameters_strategy(operation, generation_mode, location, generation_config, exclude=value.keys())
|
|
461
|
+
strategy = apply_hooks(operation, ctx, hooks, strategy, location)
|
|
462
|
+
new = draw(strategy)
|
|
463
|
+
metadata = None
|
|
464
|
+
# Negative strategy returns GeneratedValue, positive returns just value
|
|
465
|
+
if isinstance(new, GeneratedValue):
|
|
466
|
+
new, metadata = new.value, new.meta
|
|
467
|
+
if new is not None:
|
|
468
|
+
copied = dict(value)
|
|
469
|
+
copied.update(new)
|
|
470
|
+
return copied, metadata
|
|
471
|
+
return value, metadata
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@dataclass
|
|
475
|
+
class ValueContainer:
|
|
476
|
+
"""Container for a value generated by a data generator or explicitly provided."""
|
|
477
|
+
|
|
478
|
+
value: Any
|
|
479
|
+
location: str
|
|
480
|
+
generator: GenerationMode | None
|
|
481
|
+
meta: MutationMetadata | None
|
|
482
|
+
|
|
483
|
+
__slots__ = ("value", "location", "generator", "meta")
|
|
484
|
+
|
|
485
|
+
@property
|
|
486
|
+
def is_generated(self) -> bool:
|
|
487
|
+
"""If value was generated."""
|
|
488
|
+
return self.generator is not None and (self.location == "body" or self.value is not None)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def any_negated_values(values: list[ValueContainer]) -> bool:
|
|
492
|
+
"""Check if any generated values are negated."""
|
|
493
|
+
return any(value.generator == GenerationMode.NEGATIVE for value in values if value.is_generated)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def generate_parameter(
|
|
497
|
+
location: ParameterLocation,
|
|
498
|
+
explicit: dict[str, Any] | None,
|
|
499
|
+
operation: APIOperation,
|
|
500
|
+
draw: st.DrawFn,
|
|
501
|
+
ctx: HookContext,
|
|
502
|
+
hooks: HookDispatcher | None,
|
|
503
|
+
generator: GenerationMode,
|
|
504
|
+
generation_config: GenerationConfig,
|
|
505
|
+
) -> ValueContainer:
|
|
506
|
+
"""Generate a value for a parameter.
|
|
507
|
+
|
|
508
|
+
Fallback to positive data generator if parameter can not be negated.
|
|
509
|
+
"""
|
|
510
|
+
if generator.is_negative and (
|
|
511
|
+
(location == ParameterLocation.PATH and not can_negate_path_parameters(operation))
|
|
512
|
+
or (location.is_in_header and not can_negate_headers(operation, location))
|
|
513
|
+
):
|
|
514
|
+
# If we can't negate any parameter, generate positive ones
|
|
515
|
+
# If nothing else will be negated, then skip the test completely
|
|
516
|
+
generator = GenerationMode.POSITIVE
|
|
517
|
+
value, metadata = get_parameters_value(
|
|
518
|
+
explicit, location, draw, operation, ctx, hooks, generator, generation_config
|
|
519
|
+
)
|
|
520
|
+
used_generator: GenerationMode | None = generator
|
|
521
|
+
if value == explicit:
|
|
522
|
+
# When we pass `explicit`, then its parts are excluded from generation of the final value
|
|
523
|
+
# If the final value is the same, then other parameters were generated at all
|
|
524
|
+
if value is not None and location == ParameterLocation.PATH:
|
|
525
|
+
value = quote_all(value)
|
|
526
|
+
used_generator = None
|
|
527
|
+
return ValueContainer(value=value, location=location, generator=used_generator, meta=metadata)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def can_negate_path_parameters(operation: APIOperation) -> bool:
|
|
531
|
+
"""Check if any path parameter can be negated."""
|
|
532
|
+
# No path parameters to negate
|
|
533
|
+
parameters = cast(OpenApiParameterSet, operation.path_parameters).schema["properties"]
|
|
534
|
+
if not parameters:
|
|
535
|
+
return True
|
|
536
|
+
return any(can_negate(parameter) for parameter in parameters.values())
|
|
248
537
|
|
|
249
538
|
|
|
250
|
-
|
|
539
|
+
def can_negate_headers(operation: APIOperation, location: ParameterLocation) -> bool:
|
|
540
|
+
"""Check if any header can be negated."""
|
|
541
|
+
container = getattr(operation, location.container_name)
|
|
542
|
+
# No headers to negate
|
|
543
|
+
headers = container.schema["properties"]
|
|
544
|
+
if not headers:
|
|
545
|
+
return True
|
|
546
|
+
return any(
|
|
547
|
+
header not in ({"type": "string"}, {"type": "string", "format": HEADER_FORMAT}) for header in headers.values()
|
|
548
|
+
)
|
|
251
549
|
|
|
252
550
|
|
|
253
551
|
def get_parameters_strategy(
|
|
254
552
|
operation: APIOperation,
|
|
255
|
-
|
|
256
|
-
location:
|
|
553
|
+
generation_mode: GenerationMode,
|
|
554
|
+
location: ParameterLocation,
|
|
555
|
+
generation_config: GenerationConfig,
|
|
257
556
|
exclude: Iterable[str] = (),
|
|
258
557
|
) -> st.SearchStrategy:
|
|
259
558
|
"""Create a new strategy for the case's component from the API operation parameters."""
|
|
260
|
-
|
|
261
|
-
if
|
|
262
|
-
|
|
263
|
-
nested_cache_key = (to_strategy, location, tuple(sorted(exclude)))
|
|
264
|
-
if operation in _PARAMETER_STRATEGIES_CACHE and nested_cache_key in _PARAMETER_STRATEGIES_CACHE[operation]:
|
|
265
|
-
return _PARAMETER_STRATEGIES_CACHE[operation][nested_cache_key]
|
|
266
|
-
schema = parameters_to_json_schema(parameters)
|
|
267
|
-
if not operation.schema.validate_schema and location == "path":
|
|
268
|
-
# If schema validation is disabled, we try to generate data even if the parameter definition
|
|
269
|
-
# contains errors.
|
|
270
|
-
# In this case, we know that the `required` keyword should always be `True`.
|
|
271
|
-
schema["required"] = list(schema["properties"])
|
|
272
|
-
schema = operation.schema.prepare_schema(schema)
|
|
273
|
-
for name in exclude:
|
|
274
|
-
# Values from `exclude` are not necessarily valid for the schema - they come from user-defined examples
|
|
275
|
-
# that may be invalid
|
|
276
|
-
schema["properties"].pop(name, None)
|
|
277
|
-
with suppress(ValueError):
|
|
278
|
-
schema["required"].remove(name)
|
|
279
|
-
strategy = to_strategy(schema, operation.verbose_name, location, None)
|
|
280
|
-
serialize = operation.get_parameter_serializer(location)
|
|
281
|
-
if serialize is not None:
|
|
282
|
-
strategy = strategy.map(serialize)
|
|
283
|
-
filter_func = {
|
|
284
|
-
"path": is_valid_path,
|
|
285
|
-
"header": is_valid_header,
|
|
286
|
-
"cookie": is_valid_header,
|
|
287
|
-
"query": is_valid_query,
|
|
288
|
-
}[location]
|
|
289
|
-
# Headers with special format do not need filtration
|
|
290
|
-
if not (is_header_location(location) and _can_skip_header_filter(schema)):
|
|
291
|
-
strategy = strategy.filter(filter_func)
|
|
292
|
-
# Path & query parameters will be cast to string anyway, but having their JSON equivalents for
|
|
293
|
-
# `True` / `False` / `None` improves chances of them passing validation in apps that expect boolean / null types
|
|
294
|
-
# and not aware of Python-specific representation of those types
|
|
295
|
-
map_func = {
|
|
296
|
-
"path": compose(quote_all, jsonify_python_specific_types),
|
|
297
|
-
"query": jsonify_python_specific_types,
|
|
298
|
-
}.get(location)
|
|
299
|
-
if map_func:
|
|
300
|
-
strategy = strategy.map(map_func) # type: ignore
|
|
301
|
-
_PARAMETER_STRATEGIES_CACHE.setdefault(operation, {})[nested_cache_key] = strategy
|
|
302
|
-
return strategy
|
|
559
|
+
container = getattr(operation, location.container_name)
|
|
560
|
+
if container:
|
|
561
|
+
return container.get_strategy(operation, generation_config, generation_mode, exclude)
|
|
303
562
|
# No parameters defined for this location
|
|
304
563
|
return st.none()
|
|
305
564
|
|
|
306
565
|
|
|
307
|
-
def
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
566
|
+
def jsonify_python_specific_types(value: dict[str, Any]) -> dict[str, Any]:
|
|
567
|
+
"""Convert Python-specific values to their JSON equivalents."""
|
|
568
|
+
stack: list = [value]
|
|
569
|
+
while stack:
|
|
570
|
+
item = stack.pop()
|
|
571
|
+
if isinstance(item, dict):
|
|
572
|
+
for key, sub_item in item.items():
|
|
573
|
+
if isinstance(sub_item, bool):
|
|
574
|
+
item[key] = "true" if sub_item else "false"
|
|
575
|
+
elif sub_item is None:
|
|
576
|
+
item[key] = "null"
|
|
577
|
+
elif isinstance(sub_item, dict):
|
|
578
|
+
stack.append(sub_item)
|
|
579
|
+
elif isinstance(sub_item, list):
|
|
580
|
+
stack.extend(item)
|
|
581
|
+
elif isinstance(item, list):
|
|
582
|
+
stack.extend(item)
|
|
317
583
|
return value
|
|
318
584
|
|
|
319
585
|
|
|
320
|
-
def
|
|
321
|
-
|
|
322
|
-
|
|
586
|
+
def _build_custom_formats(generation_config: GenerationConfig) -> dict[str, st.SearchStrategy]:
|
|
587
|
+
custom_formats = {**get_default_format_strategies(), **STRING_FORMATS}
|
|
588
|
+
header_values_kwargs = {}
|
|
589
|
+
if generation_config.exclude_header_characters is not None:
|
|
590
|
+
header_values_kwargs["exclude_characters"] = generation_config.exclude_header_characters
|
|
591
|
+
if not generation_config.allow_x00:
|
|
592
|
+
header_values_kwargs["exclude_characters"] += "\x00"
|
|
593
|
+
elif not generation_config.allow_x00:
|
|
594
|
+
header_values_kwargs["exclude_characters"] = DEFAULT_HEADER_EXCLUDE_CHARACTERS + "\x00"
|
|
595
|
+
if generation_config.codec is not None:
|
|
596
|
+
header_values_kwargs["codec"] = generation_config.codec
|
|
597
|
+
if header_values_kwargs:
|
|
598
|
+
custom_formats[HEADER_FORMAT] = header_values(**header_values_kwargs)
|
|
599
|
+
return custom_formats
|
|
323
600
|
|
|
324
601
|
|
|
325
602
|
def make_positive_strategy(
|
|
326
|
-
schema:
|
|
603
|
+
schema: JsonSchema,
|
|
604
|
+
operation_name: str,
|
|
605
|
+
location: ParameterLocation,
|
|
606
|
+
media_type: str | None,
|
|
607
|
+
generation_config: GenerationConfig,
|
|
608
|
+
validator_cls: type[jsonschema.protocols.Validator],
|
|
327
609
|
) -> st.SearchStrategy:
|
|
328
610
|
"""Strategy for generating values that fit the schema."""
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
return from_schema(schema, custom_formats=STRING_FORMATS)
|
|
611
|
+
custom_formats = _build_custom_formats(generation_config)
|
|
612
|
+
return from_schema(
|
|
613
|
+
schema,
|
|
614
|
+
custom_formats=custom_formats,
|
|
615
|
+
allow_x00=generation_config.allow_x00,
|
|
616
|
+
codec=generation_config.codec,
|
|
617
|
+
)
|
|
337
618
|
|
|
338
619
|
|
|
339
|
-
def _can_skip_header_filter(schema:
|
|
620
|
+
def _can_skip_header_filter(schema: dict[str, Any]) -> bool:
|
|
340
621
|
# All headers should contain HEADER_FORMAT in order to avoid header filter
|
|
341
622
|
return all(sub_schema.get("format") == HEADER_FORMAT for sub_schema in schema.get("properties", {}).values())
|
|
342
623
|
|
|
343
624
|
|
|
344
625
|
def make_negative_strategy(
|
|
345
|
-
schema:
|
|
626
|
+
schema: JsonSchema,
|
|
627
|
+
operation_name: str,
|
|
628
|
+
location: ParameterLocation,
|
|
629
|
+
media_type: str | None,
|
|
630
|
+
generation_config: GenerationConfig,
|
|
631
|
+
validator_cls: type[jsonschema.protocols.Validator],
|
|
346
632
|
) -> st.SearchStrategy:
|
|
633
|
+
custom_formats = _build_custom_formats(generation_config)
|
|
347
634
|
return negative_schema(
|
|
348
|
-
schema,
|
|
635
|
+
schema,
|
|
636
|
+
operation_name=operation_name,
|
|
637
|
+
location=location,
|
|
638
|
+
media_type=media_type,
|
|
639
|
+
custom_formats=custom_formats,
|
|
640
|
+
generation_config=generation_config,
|
|
641
|
+
validator_cls=validator_cls,
|
|
349
642
|
)
|
|
350
643
|
|
|
351
644
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
645
|
+
GENERATOR_MODE_TO_STRATEGY_FACTORY = {
|
|
646
|
+
GenerationMode.POSITIVE: make_positive_strategy,
|
|
647
|
+
GenerationMode.NEGATIVE: make_negative_strategy,
|
|
355
648
|
}
|
|
356
649
|
|
|
357
650
|
|
|
358
|
-
def
|
|
359
|
-
"""Empty strings ("") are excluded from path by urllib3.
|
|
360
|
-
|
|
361
|
-
A path containing to "/" or "%2F" will lead to ambiguous path resolution in
|
|
362
|
-
many frameworks and libraries, such behaviour have been observed in both
|
|
363
|
-
WSGI and ASGI applications.
|
|
364
|
-
|
|
365
|
-
In this case one variable in the path template will be empty, which will lead to 404 in most of the cases.
|
|
366
|
-
Because of it this case doesn't bring much value and might lead to false positives results of Schemathesis runs.
|
|
367
|
-
"""
|
|
368
|
-
disallowed_values = (SLASH, "")
|
|
369
|
-
|
|
370
|
-
return not any(
|
|
371
|
-
(value in disallowed_values or is_illegal_surrogate(value) or isinstance(value, str) and SLASH in value)
|
|
372
|
-
for value in parameters.values()
|
|
373
|
-
)
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
def quote_all(parameters: Dict[str, Any]) -> Dict[str, Any]:
|
|
651
|
+
def quote_all(parameters: dict[str, Any]) -> dict[str, Any]:
|
|
377
652
|
"""Apply URL quotation for all values in a dictionary."""
|
|
378
653
|
# Even though, "." is an unreserved character, it has a special meaning in "." and ".." strings.
|
|
379
654
|
# It will change the path:
|
|
@@ -381,37 +656,23 @@ def quote_all(parameters: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
381
656
|
# - http://localhost/foo/../ -> http://localhost/
|
|
382
657
|
# Which is not desired as we need to test precisely the original path structure.
|
|
383
658
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
return
|
|
659
|
+
for key, value in parameters.items():
|
|
660
|
+
if isinstance(value, str):
|
|
661
|
+
if value == ".":
|
|
662
|
+
parameters[key] = "%2E"
|
|
663
|
+
elif value == "..":
|
|
664
|
+
parameters[key] = "%2E%2E"
|
|
665
|
+
else:
|
|
666
|
+
parameters[key] = quote_plus(value)
|
|
667
|
+
return parameters
|
|
393
668
|
|
|
394
669
|
|
|
395
670
|
def apply_hooks(
|
|
396
671
|
operation: APIOperation,
|
|
397
|
-
|
|
398
|
-
hooks:
|
|
672
|
+
ctx: HookContext,
|
|
673
|
+
hooks: HookDispatcher | None,
|
|
399
674
|
strategy: st.SearchStrategy,
|
|
400
|
-
location:
|
|
401
|
-
) -> st.SearchStrategy:
|
|
402
|
-
"""Apply all `before_generate_` hooks related to the given location."""
|
|
403
|
-
strategy = _apply_hooks(context, GLOBAL_HOOK_DISPATCHER, strategy, location)
|
|
404
|
-
strategy = _apply_hooks(context, operation.schema.hooks, strategy, location)
|
|
405
|
-
if hooks is not None:
|
|
406
|
-
strategy = _apply_hooks(context, hooks, strategy, location)
|
|
407
|
-
return strategy
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
def _apply_hooks(
|
|
411
|
-
context: HookContext, hooks: HookDispatcher, strategy: st.SearchStrategy, location: str
|
|
675
|
+
location: ParameterLocation,
|
|
412
676
|
) -> st.SearchStrategy:
|
|
413
|
-
"""Apply all
|
|
414
|
-
|
|
415
|
-
for hook in hooks.get_all_by_name(f"before_generate_{container}"):
|
|
416
|
-
strategy = hook(context, strategy)
|
|
417
|
-
return strategy
|
|
677
|
+
"""Apply all hooks related to the given location."""
|
|
678
|
+
return apply_to_all_dispatchers(operation, ctx, hooks, strategy, location.container_name)
|