schemathesis 3.25.5__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 -1766
- 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/{cli → engine/phases}/probes.py +63 -70
- 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 +153 -39
- 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 +483 -367
- 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.5.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
- {schemathesis-3.25.5.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 -55
- 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 -765
- schemathesis/cli/output/short.py +0 -40
- 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 -1231
- schemathesis/parameters.py +0 -86
- schemathesis/runner/__init__.py +0 -555
- schemathesis/runner/events.py +0 -309
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -986
- 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 -315
- 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 -184
- 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.5.dist-info/METADATA +0 -356
- schemathesis-3.25.5.dist-info/RECORD +0 -134
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
- {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -1,13 +1,17 @@
|
|
1
1
|
"""Processing of ``securityDefinitions`` or ``securitySchemes`` keywords."""
|
2
|
+
|
2
3
|
from __future__ import annotations
|
3
|
-
from dataclasses import dataclass
|
4
|
-
from typing import Any, ClassVar, Generator
|
5
4
|
|
6
|
-
from
|
5
|
+
from dataclasses import dataclass
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Generator
|
7
7
|
|
8
|
-
from ...models import APIOperation
|
9
8
|
from .parameters import OpenAPI20Parameter, OpenAPI30Parameter, OpenAPIParameter
|
10
9
|
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
from jsonschema import RefResolver
|
12
|
+
|
13
|
+
from schemathesis.schemas import APIOperation
|
14
|
+
|
11
15
|
|
12
16
|
@dataclass
|
13
17
|
class BaseSecurityProcessor:
|
@@ -124,9 +128,23 @@ class OpenAPISecurityProcessor(BaseSecurityProcessor):
|
|
124
128
|
"""In Open API 3 security definitions are located in ``components`` and may have references inside."""
|
125
129
|
components = schema.get("components", {})
|
126
130
|
security_schemes = components.get("securitySchemes", {})
|
127
|
-
|
128
|
-
|
129
|
-
|
131
|
+
# At this point, the resolution scope could differ from the root scope, that's why we need to restore it
|
132
|
+
# as now we resolve root-level references
|
133
|
+
if len(resolver._scopes_stack) > 1:
|
134
|
+
scope = resolver.resolution_scope
|
135
|
+
resolver.pop_scope()
|
136
|
+
else:
|
137
|
+
scope = None
|
138
|
+
resolve = resolver.resolve
|
139
|
+
try:
|
140
|
+
if "$ref" in security_schemes:
|
141
|
+
return resolve(security_schemes["$ref"])[1]
|
142
|
+
return {
|
143
|
+
key: resolve(value["$ref"])[1] if "$ref" in value else value for key, value in security_schemes.items()
|
144
|
+
}
|
145
|
+
finally:
|
146
|
+
if scope is not None:
|
147
|
+
resolver._scopes_stack.append(scope)
|
130
148
|
|
131
149
|
def _make_http_auth_parameter(self, definition: dict[str, Any]) -> dict[str, Any]:
|
132
150
|
schema = make_auth_header_schema(definition)
|
@@ -1,9 +1,8 @@
|
|
1
1
|
from __future__ import annotations
|
2
|
+
|
2
3
|
import json
|
3
4
|
from typing import Any, Callable, Dict, Generator, List
|
4
5
|
|
5
|
-
from ...utils import compose
|
6
|
-
|
7
6
|
Generated = Dict[str, Any]
|
8
7
|
Definition = Dict[str, Any]
|
9
8
|
DefinitionList = List[Definition]
|
@@ -16,10 +15,16 @@ def make_serializer(
|
|
16
15
|
"""A maker function to avoid code duplication."""
|
17
16
|
|
18
17
|
def _wrapper(definitions: DefinitionList) -> Callable | None:
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
18
|
+
functions = list(func(definitions))
|
19
|
+
|
20
|
+
def composed(x: Any) -> Any:
|
21
|
+
result = x
|
22
|
+
for func in reversed(functions):
|
23
|
+
if func is not None:
|
24
|
+
result = func(result)
|
25
|
+
return result
|
26
|
+
|
27
|
+
return composed
|
23
28
|
|
24
29
|
return _wrapper
|
25
30
|
|
@@ -1,30 +1,50 @@
|
|
1
1
|
from __future__ import annotations
|
2
|
+
|
2
3
|
from collections import defaultdict
|
3
|
-
from
|
4
|
+
from functools import lru_cache
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, Iterator
|
4
6
|
|
5
7
|
from hypothesis import strategies as st
|
6
8
|
from hypothesis.stateful import Bundle, Rule, precondition, rule
|
7
|
-
from requests.structures import CaseInsensitiveDict
|
8
9
|
|
9
|
-
from
|
10
|
-
from
|
11
|
-
from
|
10
|
+
from schemathesis.core import NOT_SET, NotSet
|
11
|
+
from schemathesis.core.result import Ok
|
12
|
+
from schemathesis.generation.case import Case
|
13
|
+
from schemathesis.generation.hypothesis import strategies
|
14
|
+
from schemathesis.generation.stateful.state_machine import APIStateMachine, Direction, StepResult, _normalize_name
|
15
|
+
|
16
|
+
from ....generation import GenerationMode
|
12
17
|
from .. import expressions
|
13
|
-
from
|
14
|
-
from
|
18
|
+
from ..links import get_all_links
|
19
|
+
from ..utils import expand_status_code
|
15
20
|
|
16
21
|
if TYPE_CHECKING:
|
17
|
-
from
|
22
|
+
from schemathesis.generation.stateful.state_machine import StepResult
|
23
|
+
|
18
24
|
from ..schemas import BaseOpenAPISchema
|
19
25
|
|
26
|
+
FilterFunction = Callable[["StepResult"], bool]
|
27
|
+
|
20
28
|
|
21
29
|
class OpenAPIStateMachine(APIStateMachine):
|
30
|
+
_response_matchers: dict[str, Callable[[StepResult], str | None]]
|
31
|
+
|
32
|
+
def _get_target_for_result(self, result: StepResult) -> str | None:
|
33
|
+
matcher = self._response_matchers.get(result.case.operation.label)
|
34
|
+
if matcher is None:
|
35
|
+
return None
|
36
|
+
return matcher(result)
|
37
|
+
|
22
38
|
def transform(self, result: StepResult, direction: Direction, case: Case) -> Case:
|
23
39
|
context = expressions.ExpressionContext(case=result.case, response=result.response)
|
24
|
-
direction.set_data(case,
|
40
|
+
direction.set_data(case, context=context)
|
25
41
|
return case
|
26
42
|
|
27
43
|
|
44
|
+
# The proportion of negative tests generated for "root" transitions
|
45
|
+
NEGATIVE_TEST_CASES_THRESHOLD = 20
|
46
|
+
|
47
|
+
|
28
48
|
def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
|
29
49
|
"""Create a state machine class.
|
30
50
|
|
@@ -34,75 +54,167 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
|
|
34
54
|
|
35
55
|
This state machine won't make calls to (2) without having a proper response from (1) first.
|
36
56
|
"""
|
37
|
-
# Bundles are special strategies, allowing us to draw responses from previous calls
|
38
|
-
bundles = init_bundles(schema)
|
39
|
-
connections: APIOperationConnections = defaultdict(list)
|
40
57
|
operations = [result.ok() for result in schema.get_all_operations() if isinstance(result, Ok)]
|
58
|
+
bundles = {}
|
59
|
+
incoming_transitions = defaultdict(list)
|
60
|
+
_response_matchers: dict[str, Callable[[StepResult], str | None]] = {}
|
61
|
+
# Statistic structure follows the links and count for each response status code
|
41
62
|
for operation in operations:
|
42
|
-
|
63
|
+
all_status_codes = tuple(operation.definition.raw["responses"])
|
64
|
+
bundle_matchers = []
|
65
|
+
for _, link in get_all_links(operation):
|
66
|
+
bundle_name = f"{operation.label} -> {link.status_code}"
|
67
|
+
bundles[bundle_name] = Bundle(bundle_name)
|
68
|
+
target_operation = link.get_target_operation()
|
69
|
+
incoming_transitions[target_operation.label].append(link)
|
70
|
+
bundle_matchers.append((bundle_name, make_response_filter(link.status_code, all_status_codes)))
|
71
|
+
if bundle_matchers:
|
72
|
+
_response_matchers[operation.label] = make_response_matcher(bundle_matchers)
|
73
|
+
rules = {}
|
74
|
+
catch_all = Bundle("catch_all")
|
75
|
+
|
76
|
+
for target in operations:
|
77
|
+
incoming = incoming_transitions.get(target.label)
|
78
|
+
if incoming is not None:
|
79
|
+
for link in incoming:
|
80
|
+
source = link.operation
|
81
|
+
bundle_name = f"{source.label} -> {link.status_code}"
|
82
|
+
name = _normalize_name(f"{target.label} -> {link.status_code}")
|
83
|
+
case_strategy = strategies.combine(
|
84
|
+
[target.as_strategy(generation_mode=mode) for mode in schema.generation_config.modes]
|
85
|
+
)
|
86
|
+
bundle = bundles[bundle_name]
|
87
|
+
rules[name] = transition(
|
88
|
+
name=name,
|
89
|
+
target=catch_all,
|
90
|
+
previous=bundle,
|
91
|
+
case=case_strategy,
|
92
|
+
link=st.just(link),
|
93
|
+
)
|
94
|
+
elif any(
|
95
|
+
incoming.operation.label == target.label
|
96
|
+
for transitions in incoming_transitions.values()
|
97
|
+
for incoming in transitions
|
98
|
+
):
|
99
|
+
# No incoming transitions, but has at least one outgoing transition
|
100
|
+
# For example, POST /users/ -> GET /users/{id}/
|
101
|
+
# The source operation has no prerequisite, but we need to allow this rule to be executed
|
102
|
+
# in order to reach other transitions
|
103
|
+
name = _normalize_name(f"{target.label} -> X")
|
104
|
+
if len(schema.generation_config.modes) == 1:
|
105
|
+
case_strategy = target.as_strategy(generation_mode=schema.generation_config.modes[0])
|
106
|
+
else:
|
107
|
+
_strategies = {
|
108
|
+
method: target.as_strategy(generation_mode=method) for method in schema.generation_config.modes
|
109
|
+
}
|
110
|
+
|
111
|
+
@st.composite # type: ignore[misc]
|
112
|
+
def case_strategy_factory(
|
113
|
+
draw: st.DrawFn, strategies: dict[GenerationMode, st.SearchStrategy] = _strategies
|
114
|
+
) -> Case:
|
115
|
+
if draw(st.integers(min_value=0, max_value=99)) < NEGATIVE_TEST_CASES_THRESHOLD:
|
116
|
+
return draw(strategies[GenerationMode.NEGATIVE])
|
117
|
+
return draw(strategies[GenerationMode.POSITIVE])
|
118
|
+
|
119
|
+
case_strategy = case_strategy_factory()
|
120
|
+
|
121
|
+
rules[name] = precondition(ensure_links_followed)(
|
122
|
+
transition(
|
123
|
+
name=name,
|
124
|
+
target=catch_all,
|
125
|
+
previous=st.none(),
|
126
|
+
case=case_strategy,
|
127
|
+
)
|
128
|
+
)
|
129
|
+
|
130
|
+
return type(
|
131
|
+
"APIWorkflow",
|
132
|
+
(OpenAPIStateMachine,),
|
133
|
+
{
|
134
|
+
"schema": schema,
|
135
|
+
"bundles": bundles,
|
136
|
+
"_response_matchers": _response_matchers,
|
137
|
+
**rules,
|
138
|
+
},
|
139
|
+
)
|
140
|
+
|
141
|
+
|
142
|
+
def ensure_links_followed(machine: APIStateMachine) -> bool:
|
143
|
+
# If there are responses that have links to follow, reject any rule without incoming transitions
|
144
|
+
for bundle in machine.bundles.values():
|
145
|
+
if bundle:
|
146
|
+
return False
|
147
|
+
return True
|
148
|
+
|
149
|
+
|
150
|
+
def transition(
|
151
|
+
*,
|
152
|
+
name: str,
|
153
|
+
target: Bundle,
|
154
|
+
previous: Bundle | st.SearchStrategy,
|
155
|
+
case: st.SearchStrategy,
|
156
|
+
link: st.SearchStrategy | NotSet = NOT_SET,
|
157
|
+
) -> Callable[[Callable], Rule]:
|
158
|
+
def step_function(*args_: Any, **kwargs_: Any) -> StepResult | None:
|
159
|
+
return APIStateMachine._step(*args_, **kwargs_)
|
160
|
+
|
161
|
+
step_function.__name__ = name
|
162
|
+
|
163
|
+
kwargs = {"target": target, "previous": previous, "case": case}
|
164
|
+
if not isinstance(link, NotSet):
|
165
|
+
kwargs["link"] = link
|
166
|
+
|
167
|
+
return rule(**kwargs)(step_function)
|
168
|
+
|
169
|
+
|
170
|
+
def make_response_matcher(matchers: list[tuple[str, FilterFunction]]) -> Callable[[StepResult], str | None]:
|
171
|
+
def compare(result: StepResult) -> str | None:
|
172
|
+
for bundle_name, response_filter in matchers:
|
173
|
+
if response_filter(result):
|
174
|
+
return bundle_name
|
175
|
+
return None
|
176
|
+
|
177
|
+
return compare
|
178
|
+
|
179
|
+
|
180
|
+
@lru_cache
|
181
|
+
def make_response_filter(status_code: str, all_status_codes: Iterator[str]) -> FilterFunction:
|
182
|
+
"""Create a filter for stored responses.
|
183
|
+
|
184
|
+
This filter will decide whether some response is suitable to use as a source for requesting some API operation.
|
185
|
+
"""
|
186
|
+
if status_code == "default":
|
187
|
+
return default_status_code(all_status_codes)
|
188
|
+
return match_status_code(status_code)
|
43
189
|
|
44
|
-
rules = make_all_rules(operations, bundles, connections)
|
45
190
|
|
46
|
-
|
47
|
-
|
191
|
+
def match_status_code(status_code: str) -> FilterFunction:
|
192
|
+
"""Create a filter function that matches all responses with the given status code.
|
193
|
+
|
194
|
+
Note that the status code can contain "X", which means any digit.
|
195
|
+
For example, 50X will match all status codes from 500 to 509.
|
196
|
+
"""
|
197
|
+
status_codes = set(expand_status_code(status_code))
|
198
|
+
|
199
|
+
def compare(result: StepResult) -> bool:
|
200
|
+
return result.response.status_code in status_codes
|
201
|
+
|
202
|
+
compare.__name__ = f"match_{status_code}_response"
|
203
|
+
|
204
|
+
return compare
|
48
205
|
|
49
206
|
|
50
|
-
def
|
51
|
-
"""Create
|
207
|
+
def default_status_code(status_codes: Iterator[str]) -> FilterFunction:
|
208
|
+
"""Create a filter that matches all "default" responses.
|
52
209
|
|
53
|
-
|
54
|
-
|
210
|
+
In Open API, the "default" response is the one that is used if no other options were matched.
|
211
|
+
Therefore, we need to match only responses that were not matched by other listed status codes.
|
55
212
|
"""
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
def make_all_rules(
|
66
|
-
operations: list[APIOperation],
|
67
|
-
bundles: dict[str, CaseInsensitiveDict],
|
68
|
-
connections: APIOperationConnections,
|
69
|
-
) -> dict[str, Rule]:
|
70
|
-
"""Create rules for all API operations, based on the provided connections."""
|
71
|
-
rules = {}
|
72
|
-
for operation in operations:
|
73
|
-
new_rule = make_rule(operation, bundles[operation.path][operation.method.upper()], connections)
|
74
|
-
if new_rule is not None:
|
75
|
-
rules[f"rule {operation.verbose_name}"] = new_rule
|
76
|
-
return rules
|
77
|
-
|
78
|
-
|
79
|
-
def make_rule(
|
80
|
-
operation: APIOperation,
|
81
|
-
bundle: Bundle,
|
82
|
-
connections: APIOperationConnections,
|
83
|
-
) -> Rule | None:
|
84
|
-
"""Create a rule for an API operation."""
|
85
|
-
|
86
|
-
def _make_rule(previous: st.SearchStrategy) -> Rule:
|
87
|
-
decorator = rule(target=bundle, previous=previous, case=operation.as_strategy()) # type: ignore
|
88
|
-
return decorator(APIStateMachine._step)
|
89
|
-
|
90
|
-
incoming = connections.get(operation.verbose_name)
|
91
|
-
if incoming is not None:
|
92
|
-
incoming_connections = cast(List[Connection], incoming)
|
93
|
-
strategies = [connection.strategy for connection in incoming_connections]
|
94
|
-
_rule = _make_rule(combine_strategies(strategies))
|
95
|
-
|
96
|
-
def has_source_response(self: OpenAPIStateMachine) -> bool:
|
97
|
-
# To trigger this transition, there should be matching responses from the source operations
|
98
|
-
return any(connection.source in self.bundles for connection in incoming_connections)
|
99
|
-
|
100
|
-
return precondition(has_source_response)(_rule)
|
101
|
-
# No incoming transitions - make rules only for operations that have at least one outgoing transition
|
102
|
-
if any(
|
103
|
-
connection.source == operation.verbose_name
|
104
|
-
for operation_connections in connections.values()
|
105
|
-
for connection in operation_connections
|
106
|
-
):
|
107
|
-
return _make_rule(st.none())
|
108
|
-
return None
|
213
|
+
expanded_status_codes = {
|
214
|
+
status_code for value in status_codes if value != "default" for status_code in expand_status_code(value)
|
215
|
+
}
|
216
|
+
|
217
|
+
def match_default_response(result: StepResult) -> bool:
|
218
|
+
return result.response.status_code not in expanded_status_codes
|
219
|
+
|
220
|
+
return match_default_response
|
@@ -1,6 +1,7 @@
|
|
1
1
|
from __future__ import annotations
|
2
|
+
|
2
3
|
import string
|
3
|
-
from itertools import product
|
4
|
+
from itertools import chain, product
|
4
5
|
from typing import Any, Generator
|
5
6
|
|
6
7
|
|
@@ -10,6 +11,10 @@ def expand_status_code(status_code: str | int) -> Generator[int, None, None]:
|
|
10
11
|
yield int("".join(expanded))
|
11
12
|
|
12
13
|
|
14
|
+
def expand_status_codes(status_codes: list[str]) -> set[int]:
|
15
|
+
return set(chain.from_iterable(expand_status_code(code) for code in status_codes))
|
16
|
+
|
17
|
+
|
13
18
|
def is_header_location(location: str) -> bool:
|
14
19
|
"""Whether this location affects HTTP headers."""
|
15
20
|
return location in ("header", "cookie")
|
@@ -0,0 +1,104 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from inspect import iscoroutinefunction
|
5
|
+
from typing import Any, Callable, Generic, Iterator, TypeVar
|
6
|
+
|
7
|
+
from schemathesis.core import media_types
|
8
|
+
from schemathesis.core.errors import SerializationNotPossible
|
9
|
+
|
10
|
+
|
11
|
+
def get(app: Any) -> BaseTransport:
|
12
|
+
"""Get transport to send the data to the application."""
|
13
|
+
from schemathesis.transport.asgi import ASGI_TRANSPORT
|
14
|
+
from schemathesis.transport.requests import REQUESTS_TRANSPORT
|
15
|
+
from schemathesis.transport.wsgi import WSGI_TRANSPORT
|
16
|
+
|
17
|
+
if app is None:
|
18
|
+
return REQUESTS_TRANSPORT
|
19
|
+
if iscoroutinefunction(app) or (
|
20
|
+
hasattr(app, "__call__") and iscoroutinefunction(app.__call__) # noqa: B004
|
21
|
+
):
|
22
|
+
return ASGI_TRANSPORT
|
23
|
+
return WSGI_TRANSPORT
|
24
|
+
|
25
|
+
|
26
|
+
C = TypeVar("C", contravariant=True)
|
27
|
+
R = TypeVar("R", covariant=True)
|
28
|
+
S = TypeVar("S", contravariant=True)
|
29
|
+
|
30
|
+
|
31
|
+
@dataclass
|
32
|
+
class SerializationContext(Generic[C]):
|
33
|
+
"""Generic context for serialization process."""
|
34
|
+
|
35
|
+
case: C
|
36
|
+
|
37
|
+
__slots__ = ("case",)
|
38
|
+
|
39
|
+
|
40
|
+
Serializer = Callable[[SerializationContext[C], Any], Any]
|
41
|
+
|
42
|
+
|
43
|
+
class BaseTransport(Generic[C, R, S]):
|
44
|
+
"""Base implementation with serializer registration."""
|
45
|
+
|
46
|
+
def __init__(self) -> None:
|
47
|
+
self._serializers: dict[str, Serializer[C]] = {}
|
48
|
+
|
49
|
+
def serialize_case(self, case: C, **kwargs: Any) -> dict[str, Any]:
|
50
|
+
"""Prepare the case for sending."""
|
51
|
+
raise NotImplementedError
|
52
|
+
|
53
|
+
def send(self, case: C, *, session: S | None = None, **kwargs: Any) -> R:
|
54
|
+
"""Send the case using this transport."""
|
55
|
+
raise NotImplementedError
|
56
|
+
|
57
|
+
def serializer(self, *media_types: str) -> Callable[[Serializer[C]], Serializer[C]]:
|
58
|
+
"""Register a serializer for given media types."""
|
59
|
+
|
60
|
+
def decorator(func: Serializer[C]) -> Serializer[C]:
|
61
|
+
for media_type in media_types:
|
62
|
+
self._serializers[media_type] = func
|
63
|
+
return func
|
64
|
+
|
65
|
+
return decorator
|
66
|
+
|
67
|
+
def unregister_serializer(self, *media_types: str) -> None:
|
68
|
+
for media_type in media_types:
|
69
|
+
self._serializers.pop(media_type, None)
|
70
|
+
|
71
|
+
def _copy_serializers_from(self, transport: BaseTransport) -> None:
|
72
|
+
self._serializers.update(transport._serializers)
|
73
|
+
|
74
|
+
def get_first_matching_media_type(self, media_type: str) -> tuple[str, Serializer[C]] | None:
|
75
|
+
return next(self.get_matching_media_types(media_type), None)
|
76
|
+
|
77
|
+
def get_matching_media_types(self, media_type: str) -> Iterator[tuple[str, Serializer[C]]]:
|
78
|
+
"""Get all registered media types matching the given media type."""
|
79
|
+
if media_type == "*/*":
|
80
|
+
# Shortcut to avoid comparing all values
|
81
|
+
yield from iter(self._serializers.items())
|
82
|
+
else:
|
83
|
+
main, sub = media_types.parse(media_type)
|
84
|
+
checks = [
|
85
|
+
media_types.is_json,
|
86
|
+
media_types.is_xml,
|
87
|
+
media_types.is_plain_text,
|
88
|
+
media_types.is_yaml,
|
89
|
+
]
|
90
|
+
for registered_media_type, serializer in self._serializers.items():
|
91
|
+
# Try known variations for popular media types and fallback to comparison
|
92
|
+
if any(check(media_type) and check(registered_media_type) for check in checks):
|
93
|
+
yield media_type, serializer
|
94
|
+
else:
|
95
|
+
target_main, target_sub = media_types.parse(registered_media_type)
|
96
|
+
if main in ("*", target_main) and sub in ("*", target_sub):
|
97
|
+
yield registered_media_type, serializer
|
98
|
+
|
99
|
+
def _get_serializer(self, input_media_type: str) -> Serializer[C]:
|
100
|
+
pair = self.get_first_matching_media_type(input_media_type)
|
101
|
+
if pair is None:
|
102
|
+
# This media type is set manually. Otherwise, it should have been rejected during the data generation
|
103
|
+
raise SerializationNotPossible.for_media_type(input_media_type)
|
104
|
+
return pair[1]
|
@@ -0,0 +1,26 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Any
|
4
|
+
|
5
|
+
from schemathesis.core.transport import Response
|
6
|
+
from schemathesis.generation.case import Case
|
7
|
+
from schemathesis.python import asgi
|
8
|
+
from schemathesis.transport.prepare import normalize_base_url
|
9
|
+
from schemathesis.transport.requests import REQUESTS_TRANSPORT, RequestsTransport
|
10
|
+
|
11
|
+
if TYPE_CHECKING:
|
12
|
+
import requests
|
13
|
+
|
14
|
+
|
15
|
+
class ASGITransport(RequestsTransport):
|
16
|
+
def send(self, case: Case, *, session: requests.Session | None = None, **kwargs: Any) -> Response:
|
17
|
+
if kwargs.get("base_url") is None:
|
18
|
+
kwargs["base_url"] = normalize_base_url(case.operation.base_url)
|
19
|
+
application = kwargs.pop("app", case.operation.app)
|
20
|
+
|
21
|
+
with asgi.get_client(application) as client:
|
22
|
+
return super().send(case, session=client, **kwargs)
|
23
|
+
|
24
|
+
|
25
|
+
ASGI_TRANSPORT = ASGITransport()
|
26
|
+
ASGI_TRANSPORT._copy_serializers_from(REQUESTS_TRANSPORT)
|
@@ -0,0 +1,99 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Any, Mapping, cast
|
4
|
+
from urllib.parse import quote, unquote, urljoin, urlsplit, urlunsplit
|
5
|
+
|
6
|
+
from schemathesis.core import SCHEMATHESIS_TEST_CASE_HEADER, NotSet
|
7
|
+
from schemathesis.core.errors import InvalidSchema
|
8
|
+
from schemathesis.core.output.sanitization import sanitize_url, sanitize_value
|
9
|
+
from schemathesis.core.transport import USER_AGENT
|
10
|
+
|
11
|
+
if TYPE_CHECKING:
|
12
|
+
from requests import PreparedRequest
|
13
|
+
from requests.structures import CaseInsensitiveDict
|
14
|
+
|
15
|
+
from schemathesis.generation.case import Case
|
16
|
+
|
17
|
+
|
18
|
+
def prepare_headers(case: Case, headers: dict[str, str] | None = None) -> CaseInsensitiveDict:
|
19
|
+
from requests.structures import CaseInsensitiveDict
|
20
|
+
|
21
|
+
final_headers = case.headers.copy() if case.headers is not None else CaseInsensitiveDict()
|
22
|
+
if headers:
|
23
|
+
final_headers.update(headers)
|
24
|
+
final_headers.setdefault("User-Agent", USER_AGENT)
|
25
|
+
final_headers.setdefault(SCHEMATHESIS_TEST_CASE_HEADER, case.id)
|
26
|
+
return final_headers
|
27
|
+
|
28
|
+
|
29
|
+
def prepare_url(case: Case, base_url: str | None) -> str:
|
30
|
+
"""Prepare URL based on case type."""
|
31
|
+
from schemathesis.specs.graphql.schemas import GraphQLSchema
|
32
|
+
|
33
|
+
base_url = base_url or case.operation.base_url
|
34
|
+
assert base_url is not None
|
35
|
+
path = prepare_path(case.path, case.path_parameters)
|
36
|
+
|
37
|
+
if isinstance(case.operation.schema, GraphQLSchema):
|
38
|
+
parts = list(urlsplit(base_url))
|
39
|
+
parts[2] = path
|
40
|
+
return urlunsplit(parts)
|
41
|
+
else:
|
42
|
+
path = path.lstrip("/")
|
43
|
+
if not base_url.endswith("/"):
|
44
|
+
base_url += "/"
|
45
|
+
return unquote(urljoin(base_url, quote(path)))
|
46
|
+
|
47
|
+
|
48
|
+
def prepare_body(case: Case) -> list | dict[str, Any] | str | int | float | bool | bytes | NotSet:
|
49
|
+
"""Prepare body based on case type."""
|
50
|
+
from schemathesis.specs.graphql.schemas import GraphQLSchema
|
51
|
+
|
52
|
+
if isinstance(case.operation.schema, GraphQLSchema):
|
53
|
+
return case.body if isinstance(case.body, (NotSet, bytes)) else {"query": case.body}
|
54
|
+
return case.body
|
55
|
+
|
56
|
+
|
57
|
+
def normalize_base_url(base_url: str | None) -> str | None:
|
58
|
+
"""Normalize base URL by ensuring proper hostname for local URLs.
|
59
|
+
|
60
|
+
If URL has no hostname (typical for WSGI apps), adds "localhost" as default hostname.
|
61
|
+
"""
|
62
|
+
if base_url is None:
|
63
|
+
return None
|
64
|
+
parts = urlsplit(base_url)
|
65
|
+
if not parts.hostname:
|
66
|
+
path = cast(str, parts.path or "")
|
67
|
+
return urlunsplit(("http", "localhost", path or "", "", ""))
|
68
|
+
return base_url
|
69
|
+
|
70
|
+
|
71
|
+
def prepare_path(path: str, parameters: dict[str, Any] | None) -> str:
|
72
|
+
try:
|
73
|
+
return path.format(**parameters or {})
|
74
|
+
except KeyError as exc:
|
75
|
+
# This may happen when a path template has a placeholder for variable "X", but parameter "X" is not defined
|
76
|
+
# in the parameters list.
|
77
|
+
# When `exc` is formatted, it is the missing key name in quotes. E.g. 'id'
|
78
|
+
raise InvalidSchema(f"Path parameter {exc} is not defined") from exc
|
79
|
+
except (IndexError, ValueError) as exc:
|
80
|
+
# A single unmatched `}` inside the path template may cause this
|
81
|
+
raise InvalidSchema(f"Malformed path template: `{path}`\n\n {exc}") from exc
|
82
|
+
|
83
|
+
|
84
|
+
def prepare_request(case: Case, headers: Mapping[str, Any] | None, sanitize: bool) -> PreparedRequest:
|
85
|
+
import requests
|
86
|
+
|
87
|
+
from schemathesis.transport.requests import REQUESTS_TRANSPORT
|
88
|
+
|
89
|
+
base_url = normalize_base_url(case.operation.base_url)
|
90
|
+
kwargs = REQUESTS_TRANSPORT.serialize_case(case, base_url=base_url, headers=headers)
|
91
|
+
if sanitize:
|
92
|
+
kwargs["url"] = sanitize_url(kwargs["url"])
|
93
|
+
sanitize_value(kwargs["headers"])
|
94
|
+
if kwargs["cookies"]:
|
95
|
+
sanitize_value(kwargs["cookies"])
|
96
|
+
if kwargs["params"]:
|
97
|
+
sanitize_value(kwargs["params"])
|
98
|
+
|
99
|
+
return requests.Request(**kwargs).prepare()
|