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,152 +1,251 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
from contextlib import
|
|
5
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import string
|
|
4
|
+
from contextlib import contextmanager, suppress
|
|
5
|
+
from dataclasses import dataclass
|
|
6
6
|
from difflib import get_close_matches
|
|
7
|
-
from
|
|
7
|
+
from functools import cached_property, lru_cache
|
|
8
8
|
from json import JSONDecodeError
|
|
9
|
-
from
|
|
10
|
-
from typing import
|
|
11
|
-
Any,
|
|
12
|
-
Callable,
|
|
13
|
-
ClassVar,
|
|
14
|
-
Dict,
|
|
15
|
-
Generator,
|
|
16
|
-
Iterable,
|
|
17
|
-
List,
|
|
18
|
-
Optional,
|
|
19
|
-
Sequence,
|
|
20
|
-
Tuple,
|
|
21
|
-
Type,
|
|
22
|
-
TypeVar,
|
|
23
|
-
Union,
|
|
24
|
-
)
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Callable, Generator, Iterator, Mapping, NoReturn, Sequence, cast
|
|
25
11
|
from urllib.parse import urlsplit
|
|
26
12
|
|
|
27
|
-
import attr
|
|
28
13
|
import jsonschema
|
|
29
|
-
import
|
|
30
|
-
from hypothesis.strategies import SearchStrategy
|
|
14
|
+
from packaging import version
|
|
31
15
|
from requests.structures import CaseInsensitiveDict
|
|
32
16
|
|
|
33
|
-
from
|
|
34
|
-
from
|
|
35
|
-
from
|
|
17
|
+
from schemathesis.core import INJECTED_PATH_PARAMETER_KEY, NOT_SET, NotSet, Specification, deserialization, media_types
|
|
18
|
+
from schemathesis.core.adapter import OperationParameter, ResponsesContainer
|
|
19
|
+
from schemathesis.core.compat import RefResolutionError
|
|
20
|
+
from schemathesis.core.errors import (
|
|
21
|
+
SCHEMA_ERROR_SUGGESTION,
|
|
22
|
+
InfiniteRecursiveReference,
|
|
36
23
|
InvalidSchema,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
get_response_parsing_error,
|
|
40
|
-
get_schema_validation_error,
|
|
24
|
+
OperationNotFound,
|
|
25
|
+
SchemaLocation,
|
|
41
26
|
)
|
|
42
|
-
from
|
|
43
|
-
from
|
|
44
|
-
from
|
|
45
|
-
from
|
|
46
|
-
from
|
|
47
|
-
from
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
traverse_schema,
|
|
27
|
+
from schemathesis.core.failures import Failure, FailureGroup, MalformedJson
|
|
28
|
+
from schemathesis.core.parameters import ParameterLocation
|
|
29
|
+
from schemathesis.core.result import Err, Ok, Result
|
|
30
|
+
from schemathesis.core.transport import Response
|
|
31
|
+
from schemathesis.generation.case import Case
|
|
32
|
+
from schemathesis.generation.meta import CaseMetadata
|
|
33
|
+
from schemathesis.openapi.checks import JsonSchemaError, MissingContentType
|
|
34
|
+
from schemathesis.specs.openapi import adapter
|
|
35
|
+
from schemathesis.specs.openapi.adapter import OpenApiResponses
|
|
36
|
+
from schemathesis.specs.openapi.adapter.parameters import (
|
|
37
|
+
COMBINED_FORM_DATA_MARKER,
|
|
38
|
+
OpenApiParameter,
|
|
39
|
+
OpenApiParameterSet,
|
|
56
40
|
)
|
|
57
|
-
from . import
|
|
58
|
-
from .
|
|
59
|
-
from .
|
|
41
|
+
from schemathesis.specs.openapi.adapter.protocol import SpecificationAdapter
|
|
42
|
+
from schemathesis.specs.openapi.adapter.security import OpenApiSecurityParameters
|
|
43
|
+
from schemathesis.specs.openapi.analysis import OpenAPIAnalysis
|
|
44
|
+
|
|
45
|
+
from ...generation import GenerationMode
|
|
46
|
+
from ...hooks import HookContext, HookDispatcher
|
|
47
|
+
from ...schemas import APIOperation, APIOperationMap, ApiStatistic, BaseSchema, OperationDefinition
|
|
48
|
+
from . import serialization
|
|
49
|
+
from ._hypothesis import openapi_cases
|
|
50
|
+
from .definitions import OPENAPI_30_VALIDATOR, OPENAPI_31_VALIDATOR, SWAGGER_20_VALIDATOR
|
|
60
51
|
from .examples import get_strategies_from_examples
|
|
61
|
-
from .
|
|
62
|
-
should_skip_by_operation_id,
|
|
63
|
-
should_skip_by_tag,
|
|
64
|
-
should_skip_deprecated,
|
|
65
|
-
should_skip_endpoint,
|
|
66
|
-
should_skip_method,
|
|
67
|
-
)
|
|
68
|
-
from .parameters import (
|
|
69
|
-
OpenAPI20Body,
|
|
70
|
-
OpenAPI20CompositeBody,
|
|
71
|
-
OpenAPI20Parameter,
|
|
72
|
-
OpenAPI30Body,
|
|
73
|
-
OpenAPI30Parameter,
|
|
74
|
-
OpenAPIParameter,
|
|
75
|
-
)
|
|
76
|
-
from .references import RECURSION_DEPTH_LIMIT, ConvertingResolver, InliningResolver
|
|
77
|
-
from .security import BaseSecurityProcessor, OpenAPISecurityProcessor, SwaggerSecurityProcessor
|
|
52
|
+
from .references import ReferenceResolver
|
|
78
53
|
from .stateful import create_state_machine
|
|
79
54
|
|
|
80
|
-
|
|
81
|
-
|
|
55
|
+
if TYPE_CHECKING:
|
|
56
|
+
from hypothesis.strategies import SearchStrategy
|
|
57
|
+
|
|
58
|
+
from schemathesis.auths import AuthStorage
|
|
59
|
+
from schemathesis.generation.stateful import APIStateMachine
|
|
60
|
+
|
|
61
|
+
HTTP_METHODS = frozenset({"get", "put", "post", "delete", "options", "head", "patch", "trace"})
|
|
62
|
+
SCHEMA_PARSING_ERRORS = (KeyError, AttributeError, RefResolutionError, InvalidSchema, InfiniteRecursiveReference)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@lru_cache()
|
|
66
|
+
def get_template_fields(template: str) -> set[str]:
|
|
67
|
+
"""Extract named placeholders from a string template."""
|
|
68
|
+
try:
|
|
69
|
+
parameters = {name for _, name, _, _ in string.Formatter().parse(template) if name is not None}
|
|
70
|
+
# Check for malformed params to avoid injecting them - they will be checked later on in the workflow
|
|
71
|
+
template.format(**dict.fromkeys(parameters, ""))
|
|
72
|
+
return parameters
|
|
73
|
+
except (ValueError, IndexError):
|
|
74
|
+
return set()
|
|
82
75
|
|
|
83
76
|
|
|
84
|
-
@
|
|
77
|
+
@dataclass(eq=False, repr=False)
|
|
85
78
|
class BaseOpenAPISchema(BaseSchema):
|
|
86
|
-
|
|
87
|
-
links_field: str
|
|
88
|
-
security: BaseSecurityProcessor
|
|
89
|
-
component_locations: ClassVar[Tuple[Tuple[str, ...], ...]] = ()
|
|
90
|
-
_operations_by_id: Dict[str, APIOperation]
|
|
91
|
-
_inline_reference_cache: Dict[str, Any]
|
|
92
|
-
# Inline references cache can be populated from multiple threads, therefore we need some synchronisation to avoid
|
|
93
|
-
# excessive resolving
|
|
94
|
-
_inline_reference_cache_lock: RLock
|
|
95
|
-
|
|
96
|
-
def __attrs_post_init__(self) -> None:
|
|
97
|
-
self._inline_reference_cache = {}
|
|
98
|
-
self._inline_reference_cache_lock = RLock()
|
|
99
|
-
|
|
100
|
-
@property # pragma: no mutate
|
|
101
|
-
def spec_version(self) -> str:
|
|
102
|
-
raise NotImplementedError
|
|
79
|
+
adapter: SpecificationAdapter = None # type: ignore[assignment]
|
|
103
80
|
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
super().__post_init__()
|
|
83
|
+
self.analysis = OpenAPIAnalysis(self)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def specification(self) -> Specification:
|
|
87
|
+
raise NotImplementedError
|
|
110
88
|
|
|
111
89
|
def __repr__(self) -> str:
|
|
112
90
|
info = self.raw_schema["info"]
|
|
113
|
-
return f"{self.__class__.__name__} for {info['title']}
|
|
114
|
-
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
91
|
+
return f"<{self.__class__.__name__} for {info['title']} {info['version']}>"
|
|
92
|
+
|
|
93
|
+
def __iter__(self) -> Iterator[str]:
|
|
94
|
+
paths = self._get_paths()
|
|
95
|
+
if paths is None:
|
|
96
|
+
return iter(())
|
|
97
|
+
return iter(paths)
|
|
98
|
+
|
|
99
|
+
@cached_property
|
|
100
|
+
def default_media_types(self) -> list[str]:
|
|
101
|
+
raise NotImplementedError
|
|
102
|
+
|
|
103
|
+
def _get_paths(self) -> Mapping[str, Any] | None:
|
|
104
|
+
paths = self.raw_schema.get("paths")
|
|
105
|
+
if paths is None:
|
|
106
|
+
return None
|
|
107
|
+
assert isinstance(paths, Mapping)
|
|
108
|
+
return cast(Mapping[str, Any], paths)
|
|
109
|
+
|
|
110
|
+
def _get_operation_map(self, path: str) -> APIOperationMap:
|
|
111
|
+
paths = self._get_paths()
|
|
112
|
+
if paths is None:
|
|
113
|
+
raise KeyError(path)
|
|
114
|
+
path_item = paths[path]
|
|
115
|
+
with in_scope(self.resolver, self.location or ""):
|
|
116
|
+
scope, path_item = self._resolve_path_item(path_item)
|
|
117
|
+
self.dispatch_hook("before_process_path", HookContext(), path, path_item)
|
|
118
|
+
map = APIOperationMap(self, {})
|
|
119
|
+
map._data = MethodMap(map, scope, path, CaseInsensitiveDict(path_item))
|
|
120
|
+
return map
|
|
121
|
+
|
|
122
|
+
def find_operation_by_label(self, label: str) -> APIOperation | None:
|
|
123
|
+
method, path = label.split(" ", maxsplit=1)
|
|
124
|
+
return self[path][method]
|
|
125
|
+
|
|
126
|
+
def on_missing_operation(self, item: str, exc: KeyError) -> NoReturn:
|
|
127
|
+
matches = get_close_matches(item, list(self))
|
|
128
|
+
self._on_missing_operation(item, exc, matches)
|
|
129
|
+
|
|
130
|
+
def _on_missing_operation(self, item: str, exc: KeyError | None, matches: list[str]) -> NoReturn:
|
|
131
|
+
message = f"`{item}` not found"
|
|
132
|
+
if matches:
|
|
133
|
+
message += f". Did you mean `{matches[0]}`?"
|
|
134
|
+
raise OperationNotFound(message=message, item=item) from exc
|
|
135
|
+
|
|
136
|
+
def _should_skip(
|
|
137
|
+
self,
|
|
138
|
+
path: str,
|
|
139
|
+
method: str,
|
|
140
|
+
definition: dict[str, Any],
|
|
141
|
+
_ctx_cache: SimpleNamespace = SimpleNamespace(
|
|
142
|
+
operation=APIOperation(
|
|
143
|
+
method="",
|
|
144
|
+
path="",
|
|
145
|
+
label="",
|
|
146
|
+
definition=OperationDefinition(raw=None),
|
|
147
|
+
schema=None, # type: ignore[arg-type]
|
|
148
|
+
responses=None,
|
|
149
|
+
security=None,
|
|
150
|
+
)
|
|
151
|
+
),
|
|
152
|
+
) -> bool:
|
|
153
|
+
if method not in HTTP_METHODS:
|
|
154
|
+
return True
|
|
155
|
+
if self.filter_set.is_empty():
|
|
156
|
+
return False
|
|
157
|
+
# Attribute assignment is way faster than creating a new namespace every time
|
|
158
|
+
operation = _ctx_cache.operation
|
|
159
|
+
operation.method = method
|
|
160
|
+
operation.path = path
|
|
161
|
+
operation.label = f"{method.upper()} {path}"
|
|
162
|
+
operation.definition.raw = definition
|
|
163
|
+
operation.schema = self
|
|
164
|
+
return not self.filter_set.match(_ctx_cache)
|
|
165
|
+
|
|
166
|
+
def _measure_statistic(self) -> ApiStatistic:
|
|
167
|
+
statistic = ApiStatistic()
|
|
168
|
+
paths = self._get_paths()
|
|
169
|
+
if paths is None:
|
|
170
|
+
return statistic
|
|
123
171
|
|
|
124
|
-
@property
|
|
125
|
-
def operations_count(self) -> int:
|
|
126
|
-
try:
|
|
127
|
-
paths = self.raw_schema["paths"]
|
|
128
|
-
except KeyError:
|
|
129
|
-
return 0
|
|
130
|
-
total = 0
|
|
131
172
|
resolve = self.resolver.resolve
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
173
|
+
resolve_path_item = self._resolve_path_item
|
|
174
|
+
should_skip = self._should_skip
|
|
175
|
+
links_keyword = self.adapter.links_keyword
|
|
176
|
+
|
|
177
|
+
# For operationId lookup
|
|
178
|
+
selected_operations_by_id: set[str] = set()
|
|
179
|
+
# Tuples of (method, path)
|
|
180
|
+
selected_operations_by_path: set[tuple[str, str]] = set()
|
|
181
|
+
collected_links: list[dict] = []
|
|
182
|
+
|
|
183
|
+
for path, path_item in paths.items():
|
|
184
|
+
try:
|
|
185
|
+
scope, path_item = resolve_path_item(path_item)
|
|
186
|
+
self.resolver.push_scope(scope)
|
|
187
|
+
try:
|
|
188
|
+
for method, definition in path_item.items():
|
|
189
|
+
if method not in HTTP_METHODS or not definition:
|
|
190
|
+
continue
|
|
191
|
+
statistic.operations.total += 1
|
|
192
|
+
is_selected = not should_skip(path, method, definition)
|
|
193
|
+
if is_selected:
|
|
194
|
+
statistic.operations.selected += 1
|
|
195
|
+
# Store both identifiers
|
|
196
|
+
if "operationId" in definition:
|
|
197
|
+
selected_operations_by_id.add(definition["operationId"])
|
|
198
|
+
selected_operations_by_path.add((method, path))
|
|
199
|
+
for response in definition.get("responses", {}).values():
|
|
200
|
+
if "$ref" in response:
|
|
201
|
+
_, response = resolve(response["$ref"])
|
|
202
|
+
defined_links = response.get(links_keyword)
|
|
203
|
+
if defined_links is not None:
|
|
204
|
+
statistic.links.total += len(defined_links)
|
|
205
|
+
if is_selected:
|
|
206
|
+
collected_links.extend(defined_links.values())
|
|
207
|
+
finally:
|
|
208
|
+
self.resolver.pop_scope()
|
|
209
|
+
except SCHEMA_PARSING_ERRORS:
|
|
135
210
|
continue
|
|
211
|
+
|
|
212
|
+
def is_link_selected(link: dict) -> bool:
|
|
213
|
+
if "$ref" in link:
|
|
214
|
+
_, link = resolve(link["$ref"])
|
|
215
|
+
|
|
216
|
+
if "operationId" in link:
|
|
217
|
+
return link["operationId"] in selected_operations_by_id
|
|
218
|
+
else:
|
|
219
|
+
try:
|
|
220
|
+
scope, _ = resolve(link["operationRef"])
|
|
221
|
+
path, method = scope.rsplit("/", maxsplit=2)[-2:]
|
|
222
|
+
path = path.replace("~1", "/").replace("~0", "~")
|
|
223
|
+
return (method, path) in selected_operations_by_path
|
|
224
|
+
except Exception:
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
for link in collected_links:
|
|
228
|
+
if is_link_selected(link):
|
|
229
|
+
statistic.links.selected += 1
|
|
230
|
+
|
|
231
|
+
return statistic
|
|
232
|
+
|
|
233
|
+
def _operation_iter(self) -> Iterator[tuple[str, str, dict[str, Any]]]:
|
|
234
|
+
paths = self._get_paths()
|
|
235
|
+
if paths is None:
|
|
236
|
+
return
|
|
237
|
+
resolve = self.resolver.resolve
|
|
238
|
+
should_skip = self._should_skip
|
|
239
|
+
for path, path_item in paths.items():
|
|
136
240
|
try:
|
|
137
|
-
if "$ref" in
|
|
138
|
-
_,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# Straightforward iteration is faster than converting to a set & calculating length.
|
|
142
|
-
for method, definition in resolved_methods.items():
|
|
143
|
-
if self._should_skip(method, definition):
|
|
241
|
+
if "$ref" in path_item:
|
|
242
|
+
_, path_item = resolve(path_item["$ref"])
|
|
243
|
+
for method, definition in path_item.items():
|
|
244
|
+
if should_skip(path, method, definition):
|
|
144
245
|
continue
|
|
145
|
-
|
|
246
|
+
yield (method, path, definition)
|
|
146
247
|
except SCHEMA_PARSING_ERRORS:
|
|
147
|
-
# Ignore errors
|
|
148
248
|
continue
|
|
149
|
-
return total
|
|
150
249
|
|
|
151
250
|
def get_all_operations(self) -> Generator[Result[APIOperation, InvalidSchema], None, None]:
|
|
152
251
|
"""Iterate over all operations defined in the API.
|
|
@@ -164,411 +263,355 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
164
263
|
In both cases, Schemathesis lets the callee decide what to do with these variants. It allows it to test valid
|
|
165
264
|
operations and show errors for invalid ones.
|
|
166
265
|
"""
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
266
|
+
__tracebackhide__ = True
|
|
267
|
+
paths = self._get_paths()
|
|
268
|
+
if paths is None:
|
|
269
|
+
if version.parse(self.specification.version) >= version.parse("3.1"):
|
|
270
|
+
return
|
|
271
|
+
self._raise_invalid_schema(KeyError("paths"))
|
|
172
272
|
|
|
173
273
|
context = HookContext()
|
|
174
|
-
|
|
274
|
+
# Optimization: local variables are faster than attribute access
|
|
275
|
+
dispatch_hook = self.dispatch_hook
|
|
276
|
+
resolve_path_item = self._resolve_path_item
|
|
277
|
+
should_skip = self._should_skip
|
|
278
|
+
iter_parameters = self._iter_parameters
|
|
279
|
+
make_operation = self.make_operation
|
|
280
|
+
for path, path_item in paths.items():
|
|
175
281
|
method = None
|
|
176
282
|
try:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
for method, definition in raw_methods.items():
|
|
184
|
-
try:
|
|
185
|
-
# Setting a low recursion limit doesn't solve the problem with recursive references & inlining
|
|
186
|
-
# too much but decreases the number of cases when Schemathesis stuck on this step.
|
|
187
|
-
self.resolver.push_scope(scope)
|
|
188
|
-
try:
|
|
189
|
-
resolved_definition = self.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 5)
|
|
190
|
-
finally:
|
|
191
|
-
self.resolver.pop_scope()
|
|
192
|
-
# Only method definitions are parsed
|
|
193
|
-
if self._should_skip(method, resolved_definition):
|
|
283
|
+
dispatch_hook("before_process_path", context, path, path_item)
|
|
284
|
+
scope, path_item = resolve_path_item(path_item)
|
|
285
|
+
with in_scope(self.resolver, scope):
|
|
286
|
+
shared_parameters = path_item.get("parameters", [])
|
|
287
|
+
for method, entry in path_item.items():
|
|
288
|
+
if method not in HTTP_METHODS:
|
|
194
289
|
continue
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
290
|
+
try:
|
|
291
|
+
if should_skip(path, method, entry):
|
|
292
|
+
continue
|
|
293
|
+
parameters = iter_parameters(entry, shared_parameters)
|
|
294
|
+
operation = make_operation(
|
|
295
|
+
path,
|
|
296
|
+
method,
|
|
297
|
+
parameters,
|
|
298
|
+
entry,
|
|
299
|
+
scope,
|
|
300
|
+
)
|
|
301
|
+
yield Ok(operation)
|
|
302
|
+
except SCHEMA_PARSING_ERRORS as exc:
|
|
303
|
+
yield self._into_err(exc, path, method)
|
|
207
304
|
except SCHEMA_PARSING_ERRORS as exc:
|
|
208
305
|
yield self._into_err(exc, path, method)
|
|
209
306
|
|
|
210
|
-
def _into_err(self, error: Exception, path:
|
|
307
|
+
def _into_err(self, error: Exception, path: str | None, method: str | None) -> Err[InvalidSchema]:
|
|
308
|
+
__tracebackhide__ = True
|
|
211
309
|
try:
|
|
212
|
-
|
|
213
|
-
raise InvalidSchema(SCHEMA_ERROR_MESSAGE, path=path, method=method, full_path=full_path) from error
|
|
310
|
+
self._raise_invalid_schema(error, path, method)
|
|
214
311
|
except InvalidSchema as exc:
|
|
215
312
|
return Err(exc)
|
|
216
313
|
|
|
217
|
-
def
|
|
218
|
-
self,
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
314
|
+
def _raise_invalid_schema(
|
|
315
|
+
self,
|
|
316
|
+
error: Exception,
|
|
317
|
+
path: str | None = None,
|
|
318
|
+
method: str | None = None,
|
|
319
|
+
) -> NoReturn:
|
|
320
|
+
__tracebackhide__ = True
|
|
321
|
+
if isinstance(error, InfiniteRecursiveReference):
|
|
322
|
+
raise InvalidSchema(str(error), path=path, method=method) from None
|
|
323
|
+
if isinstance(error, RefResolutionError):
|
|
324
|
+
raise InvalidSchema.from_reference_resolution_error(error, path=path, method=method) from None
|
|
325
|
+
try:
|
|
326
|
+
self.validate()
|
|
327
|
+
except jsonschema.ValidationError as exc:
|
|
328
|
+
raise InvalidSchema.from_jsonschema_error(
|
|
329
|
+
exc,
|
|
330
|
+
path=path,
|
|
331
|
+
method=method,
|
|
332
|
+
config=self.config.output,
|
|
333
|
+
location=SchemaLocation.maybe_from_error_path(list(exc.absolute_path), self.specification.version),
|
|
334
|
+
) from None
|
|
335
|
+
raise InvalidSchema(SCHEMA_ERROR_SUGGESTION, path=path, method=method) from error
|
|
336
|
+
|
|
337
|
+
def validate(self) -> None:
|
|
338
|
+
with suppress(TypeError):
|
|
339
|
+
self._validate()
|
|
340
|
+
|
|
341
|
+
def _validate(self) -> None:
|
|
225
342
|
raise NotImplementedError
|
|
226
343
|
|
|
227
|
-
def
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
344
|
+
def _iter_parameters(
|
|
345
|
+
self, definition: dict[str, Any], shared_parameters: Sequence[dict[str, Any]]
|
|
346
|
+
) -> list[OperationParameter]:
|
|
347
|
+
return list(
|
|
348
|
+
self.adapter.iter_parameters(
|
|
349
|
+
definition, shared_parameters, self.default_media_types, self.resolver, self.adapter
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def _parse_responses(self, definition: dict[str, Any], scope: str) -> OpenApiResponses:
|
|
354
|
+
responses = definition.get("responses", {})
|
|
355
|
+
return OpenApiResponses.from_definition(
|
|
356
|
+
definition=responses, resolver=self.resolver, scope=scope, adapter=self.adapter
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def _parse_security(self, definition: dict[str, Any]) -> OpenApiSecurityParameters:
|
|
360
|
+
return OpenApiSecurityParameters.from_definition(
|
|
361
|
+
schema=self.raw_schema,
|
|
362
|
+
operation=definition,
|
|
363
|
+
resolver=self.resolver,
|
|
364
|
+
adapter=self.adapter,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def _resolve_path_item(self, methods: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
368
|
+
# The path item could be behind a reference
|
|
369
|
+
# In this case, we need to resolve it to get the proper scope for reference inside the item.
|
|
370
|
+
# It is mostly for validating responses.
|
|
231
371
|
if "$ref" in methods:
|
|
232
|
-
return
|
|
233
|
-
return self.resolver.resolution_scope,
|
|
372
|
+
return self.resolver.resolve(methods["$ref"])
|
|
373
|
+
return self.resolver.resolution_scope, methods
|
|
234
374
|
|
|
235
375
|
def make_operation(
|
|
236
376
|
self,
|
|
237
377
|
path: str,
|
|
238
378
|
method: str,
|
|
239
|
-
parameters:
|
|
240
|
-
|
|
379
|
+
parameters: list[OperationParameter],
|
|
380
|
+
definition: dict[str, Any],
|
|
381
|
+
scope: str,
|
|
241
382
|
) -> APIOperation:
|
|
242
|
-
|
|
383
|
+
__tracebackhide__ = True
|
|
243
384
|
base_url = self.get_base_url()
|
|
244
|
-
|
|
385
|
+
responses = self._parse_responses(definition, scope)
|
|
386
|
+
security = self._parse_security(definition)
|
|
387
|
+
operation: APIOperation[OperationParameter, ResponsesContainer, OpenApiSecurityParameters] = APIOperation(
|
|
245
388
|
path=path,
|
|
246
389
|
method=method,
|
|
247
|
-
definition=
|
|
390
|
+
definition=OperationDefinition(definition),
|
|
248
391
|
base_url=base_url,
|
|
249
392
|
app=self.app,
|
|
250
393
|
schema=self,
|
|
394
|
+
responses=responses,
|
|
395
|
+
security=security,
|
|
396
|
+
path_parameters=OpenApiParameterSet(ParameterLocation.PATH),
|
|
397
|
+
query=OpenApiParameterSet(ParameterLocation.QUERY),
|
|
398
|
+
headers=OpenApiParameterSet(ParameterLocation.HEADER),
|
|
399
|
+
cookies=OpenApiParameterSet(ParameterLocation.COOKIE),
|
|
251
400
|
)
|
|
252
401
|
for parameter in parameters:
|
|
253
402
|
operation.add_parameter(parameter)
|
|
254
|
-
|
|
403
|
+
# Inject unconstrained path parameters if any is missing
|
|
404
|
+
missing_parameter_names = get_template_fields(operation.path) - {
|
|
405
|
+
parameter.name for parameter in operation.path_parameters
|
|
406
|
+
}
|
|
407
|
+
for name in missing_parameter_names:
|
|
408
|
+
operation.add_parameter(
|
|
409
|
+
self.adapter.build_path_parameter({"name": name, INJECTED_PATH_PARAMETER_KEY: True})
|
|
410
|
+
)
|
|
411
|
+
config = self.config.generation_for(operation=operation)
|
|
412
|
+
if config.with_security_parameters:
|
|
413
|
+
for param in operation.security.iter_parameters():
|
|
414
|
+
param_name = param.get("name")
|
|
415
|
+
param_location = param.get("in")
|
|
416
|
+
if (
|
|
417
|
+
param_name is not None
|
|
418
|
+
and param_location is not None
|
|
419
|
+
and operation.get_parameter(name=param_name, location=param_location) is not None
|
|
420
|
+
):
|
|
421
|
+
continue
|
|
422
|
+
operation.add_parameter(
|
|
423
|
+
OpenApiParameter.from_definition(definition=param, name_to_uri={}, adapter=self.adapter)
|
|
424
|
+
)
|
|
255
425
|
self.dispatch_hook("before_init_operation", HookContext(operation=operation), operation)
|
|
256
426
|
return operation
|
|
257
427
|
|
|
258
428
|
@property
|
|
259
|
-
def resolver(self) ->
|
|
429
|
+
def resolver(self) -> ReferenceResolver:
|
|
260
430
|
if not hasattr(self, "_resolver"):
|
|
261
|
-
|
|
262
|
-
self._resolver = InliningResolver(self.location or "", self.raw_schema)
|
|
431
|
+
self._resolver = ReferenceResolver(self.location or "", self.raw_schema)
|
|
263
432
|
return self._resolver
|
|
264
433
|
|
|
265
|
-
def get_content_types(self, operation: APIOperation, response:
|
|
434
|
+
def get_content_types(self, operation: APIOperation, response: Response) -> list[str]:
|
|
266
435
|
"""Content types available for this API operation."""
|
|
267
436
|
raise NotImplementedError
|
|
268
437
|
|
|
269
|
-
def get_strategies_from_examples(self, operation: APIOperation) ->
|
|
438
|
+
def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
|
|
270
439
|
"""Get examples from the API operation."""
|
|
271
440
|
raise NotImplementedError
|
|
272
441
|
|
|
273
|
-
def
|
|
274
|
-
"""
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
442
|
+
def find_operation_by_id(self, operation_id: str) -> APIOperation:
|
|
443
|
+
"""Find an `APIOperation` instance by its `operationId`."""
|
|
444
|
+
resolve = self.resolver.resolve
|
|
445
|
+
default_scope = self.resolver.resolution_scope
|
|
446
|
+
paths = self._get_paths()
|
|
447
|
+
if paths is None:
|
|
448
|
+
self._on_missing_operation(operation_id, None, [])
|
|
449
|
+
assert paths is not None
|
|
450
|
+
for path, path_item in paths.items():
|
|
451
|
+
# If the path is behind a reference we have to keep the scope
|
|
452
|
+
# The scope is used to resolve nested components later on
|
|
453
|
+
if "$ref" in path_item:
|
|
454
|
+
scope, path_item = resolve(path_item["$ref"])
|
|
455
|
+
else:
|
|
456
|
+
scope = default_scope
|
|
457
|
+
for method, operation in path_item.items():
|
|
458
|
+
if method not in HTTP_METHODS:
|
|
289
459
|
continue
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
self.resolver.pop_scope()
|
|
295
|
-
parameters = self.collect_parameters(
|
|
296
|
-
itertools.chain(resolved_definition.get("parameters", ()), common_parameters), resolved_definition
|
|
297
|
-
)
|
|
298
|
-
raw_definition = OperationDefinition(raw_methods[method], resolved_definition, scope, parameters)
|
|
299
|
-
yield resolved_definition["operationId"], self.make_operation(path, method, parameters, raw_definition)
|
|
460
|
+
if "operationId" in operation and operation["operationId"] == operation_id:
|
|
461
|
+
parameters = self._iter_parameters(operation, path_item.get("parameters", []))
|
|
462
|
+
return self.make_operation(path, method, parameters, operation, scope)
|
|
463
|
+
self._on_missing_operation(operation_id, None, [])
|
|
300
464
|
|
|
301
|
-
def
|
|
302
|
-
"""
|
|
465
|
+
def find_operation_by_reference(self, reference: str) -> APIOperation:
|
|
466
|
+
"""Find local or external `APIOperation` instance by reference.
|
|
303
467
|
|
|
304
468
|
Reference example: #/paths/~1users~1{user_id}/patch
|
|
305
469
|
"""
|
|
306
|
-
scope,
|
|
470
|
+
scope, operation = self.resolver.resolve(reference)
|
|
307
471
|
path, method = scope.rsplit("/", maxsplit=2)[-2:]
|
|
308
472
|
path = path.replace("~1", "/").replace("~0", "~")
|
|
309
|
-
resolved_definition = self.resolver.resolve_all(data)
|
|
310
473
|
parent_ref, _ = reference.rsplit("/", maxsplit=1)
|
|
311
|
-
_,
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
474
|
+
_, path_item = self.resolver.resolve(parent_ref)
|
|
475
|
+
with in_scope(self.resolver, scope):
|
|
476
|
+
parameters = self._iter_parameters(operation, path_item.get("parameters", []))
|
|
477
|
+
return self.make_operation(path, method, parameters, operation, scope)
|
|
478
|
+
|
|
479
|
+
def find_operation_by_path(self, method: str, path: str) -> APIOperation | None:
|
|
480
|
+
"""Find an `APIOperation` by matching an actual request path.
|
|
481
|
+
|
|
482
|
+
Matches path templates with parameters, e.g., /users/42 matches /users/{user_id}.
|
|
483
|
+
Returns None if no operation matches.
|
|
484
|
+
"""
|
|
485
|
+
from werkzeug.exceptions import MethodNotAllowed, NotFound
|
|
486
|
+
|
|
487
|
+
from schemathesis.specs.openapi.stateful.inference import OperationById
|
|
488
|
+
|
|
489
|
+
# Match path and method using werkzeug router
|
|
490
|
+
try:
|
|
491
|
+
operation_ref, _ = self.analysis.inferencer._adapter.match(path, method=method.upper())
|
|
492
|
+
except (NotFound, MethodNotAllowed):
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
if isinstance(operation_ref, OperationById):
|
|
496
|
+
return self.find_operation_by_id(operation_ref.value)
|
|
497
|
+
return self.find_operation_by_reference(operation_ref.value)
|
|
318
498
|
|
|
319
499
|
def get_case_strategy(
|
|
320
500
|
self,
|
|
321
501
|
operation: APIOperation,
|
|
322
|
-
hooks:
|
|
323
|
-
|
|
502
|
+
hooks: HookDispatcher | None = None,
|
|
503
|
+
auth_storage: AuthStorage | None = None,
|
|
504
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
|
505
|
+
**kwargs: Any,
|
|
324
506
|
) -> SearchStrategy:
|
|
325
|
-
return
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
507
|
+
return openapi_cases(
|
|
508
|
+
operation=operation,
|
|
509
|
+
hooks=hooks,
|
|
510
|
+
auth_storage=auth_storage,
|
|
511
|
+
generation_mode=generation_mode,
|
|
512
|
+
**kwargs,
|
|
331
513
|
)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
514
|
+
|
|
515
|
+
def get_parameter_serializer(self, operation: APIOperation, location: str) -> Callable | None:
|
|
516
|
+
definitions = [item.definition for item in operation.iter_parameters() if item.location == location]
|
|
517
|
+
config = self.config.generation_for(operation=operation)
|
|
518
|
+
if config.with_security_parameters:
|
|
519
|
+
security_parameters = [param for param in operation.security.iter_parameters() if param["in"] == location]
|
|
520
|
+
if security_parameters:
|
|
521
|
+
definitions.extend(security_parameters)
|
|
335
522
|
if definitions:
|
|
336
523
|
return self._get_parameter_serializer(definitions)
|
|
337
524
|
return None
|
|
338
525
|
|
|
339
|
-
def _get_parameter_serializer(self, definitions:
|
|
526
|
+
def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
|
|
340
527
|
raise NotImplementedError
|
|
341
528
|
|
|
342
|
-
def
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
return responses[status_code]
|
|
351
|
-
if "default" in responses:
|
|
352
|
-
return responses["default"]
|
|
353
|
-
return None
|
|
529
|
+
def as_state_machine(self) -> type[APIStateMachine]:
|
|
530
|
+
# Apply dependency inference if configured and not already done
|
|
531
|
+
if self.analysis.should_inject_links():
|
|
532
|
+
self.analysis.inject_links()
|
|
533
|
+
return create_state_machine(self)
|
|
534
|
+
|
|
535
|
+
def get_tags(self, operation: APIOperation) -> list[str] | None:
|
|
536
|
+
return operation.definition.raw.get("tags")
|
|
354
537
|
|
|
355
|
-
def
|
|
356
|
-
|
|
357
|
-
|
|
538
|
+
def validate_response(
|
|
539
|
+
self,
|
|
540
|
+
operation: APIOperation,
|
|
541
|
+
response: Response,
|
|
542
|
+
*,
|
|
543
|
+
case: Case | None = None,
|
|
544
|
+
) -> bool | None:
|
|
545
|
+
__tracebackhide__ = True
|
|
546
|
+
definition = operation.responses.find_by_status_code(response.status_code)
|
|
547
|
+
if definition is None or definition.schema is None:
|
|
548
|
+
# No definition for the given HTTP response, or missing "schema" in the matching definition
|
|
358
549
|
return None
|
|
359
|
-
return definitions.get("headers")
|
|
360
550
|
|
|
361
|
-
|
|
362
|
-
return create_state_machine(self)
|
|
551
|
+
failures: list[Failure] = []
|
|
363
552
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
"""Add a new Open API link to the schema definition.
|
|
373
|
-
|
|
374
|
-
:param APIOperation source: This operation is the source of data
|
|
375
|
-
:param target: This operation will receive the data from this link.
|
|
376
|
-
Can be an ``APIOperation`` instance or a reference like this - ``#/paths/~1users~1{userId}/get``
|
|
377
|
-
:param str status_code: The link is triggered when the source API operation responds with this status code.
|
|
378
|
-
:param parameters: A dictionary that describes how parameters should be extracted from the matched response.
|
|
379
|
-
The key represents the parameter name in the target API operation, and the value is a runtime
|
|
380
|
-
expression string.
|
|
381
|
-
:param request_body: A literal value or runtime expression to use as a request body when
|
|
382
|
-
calling the target operation.
|
|
383
|
-
|
|
384
|
-
.. code-block:: python
|
|
385
|
-
|
|
386
|
-
schema = schemathesis.from_uri("http://0.0.0.0/schema.yaml")
|
|
387
|
-
|
|
388
|
-
schema.add_link(
|
|
389
|
-
source=schema["/users/"]["POST"],
|
|
390
|
-
target=schema["/users/{userId}"]["GET"],
|
|
391
|
-
status_code="201",
|
|
392
|
-
parameters={"userId": "$response.body#/id"},
|
|
393
|
-
)
|
|
394
|
-
"""
|
|
395
|
-
if parameters is None and request_body is None:
|
|
396
|
-
raise ValueError("You need to provide `parameters` or `request_body`.")
|
|
397
|
-
if hasattr(self, "_operations"):
|
|
398
|
-
delattr(self, "_operations")
|
|
399
|
-
for operation, methods in self.raw_schema["paths"].items():
|
|
400
|
-
if operation == source.path:
|
|
401
|
-
# Methods should be completely resolved now, otherwise they might miss a resolving scope when
|
|
402
|
-
# they will be fully resolved later
|
|
403
|
-
methods = self.resolver.resolve_all(methods)
|
|
404
|
-
found = False
|
|
405
|
-
for method, definition in methods.items():
|
|
406
|
-
if method.upper() == source.method.upper():
|
|
407
|
-
found = True
|
|
408
|
-
links.add_link(
|
|
409
|
-
definition["responses"], self.links_field, parameters, request_body, status_code, target
|
|
410
|
-
)
|
|
411
|
-
# If methods are behind a reference, then on the next resolving they will miss the new link
|
|
412
|
-
# Therefore we need to modify it this way
|
|
413
|
-
self.raw_schema["paths"][operation][method] = definition
|
|
414
|
-
# The reference should be removed completely, otherwise new keys in this dictionary will be ignored
|
|
415
|
-
# due to the `$ref` keyword behavior
|
|
416
|
-
self.raw_schema["paths"][operation].pop("$ref", None)
|
|
417
|
-
if found:
|
|
418
|
-
return
|
|
419
|
-
name = f"{source.method.upper()} {source.path}"
|
|
420
|
-
# Use a name without basePath, as the user doesn't use it.
|
|
421
|
-
# E.g. `source=schema["/users/"]["POST"]` without a prefix
|
|
422
|
-
message = f"No such API operation: `{name}`."
|
|
423
|
-
possibilities = [
|
|
424
|
-
f"{op.ok().method.upper()} {op.ok().path}" for op in self.get_all_operations() if isinstance(op, Ok)
|
|
425
|
-
]
|
|
426
|
-
matches = get_close_matches(name, possibilities)
|
|
427
|
-
if matches:
|
|
428
|
-
message += f" Did you mean `{matches[0]}`?"
|
|
429
|
-
message += " Check if the requested API operation passes the filters in the schema."
|
|
430
|
-
raise ValueError(message)
|
|
431
|
-
|
|
432
|
-
def get_links(self, operation: APIOperation) -> Dict[str, Dict[str, Any]]:
|
|
433
|
-
result: Dict[str, Dict[str, Any]] = defaultdict(dict)
|
|
434
|
-
for status_code, link in links.get_all_links(operation):
|
|
435
|
-
result[status_code][link.name] = link
|
|
436
|
-
return result
|
|
437
|
-
|
|
438
|
-
def validate_response(self, operation: APIOperation, response: GenericResponse) -> None:
|
|
439
|
-
responses = {str(key): value for key, value in operation.definition.raw.get("responses", {}).items()}
|
|
440
|
-
status_code = str(response.status_code)
|
|
441
|
-
if status_code in responses:
|
|
442
|
-
definition = responses[status_code]
|
|
443
|
-
elif "default" in responses:
|
|
444
|
-
definition = responses["default"]
|
|
553
|
+
content_types = response.headers.get("content-type")
|
|
554
|
+
if content_types is None:
|
|
555
|
+
all_media_types = self.get_content_types(operation, response)
|
|
556
|
+
formatted_content_types = [f"\n- `{content_type}`" for content_type in all_media_types]
|
|
557
|
+
message = f"The following media types are documented in the schema:{''.join(formatted_content_types)}"
|
|
558
|
+
failures.append(MissingContentType(operation=operation.label, message=message, media_types=all_media_types))
|
|
559
|
+
# Default content type
|
|
560
|
+
content_type = "application/json"
|
|
445
561
|
else:
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
# No schema to check against
|
|
451
|
-
return
|
|
452
|
-
content_type = response.headers.get("Content-Type")
|
|
453
|
-
if content_type is None:
|
|
454
|
-
media_types = self.get_content_types(operation, response)
|
|
455
|
-
formatted_media_types = "\n ".join(media_types)
|
|
456
|
-
raise get_missing_content_type_error()(
|
|
457
|
-
"The response is missing the `Content-Type` header. The schema defines the following media types:\n\n"
|
|
458
|
-
f" {formatted_media_types}",
|
|
459
|
-
context=failures.MissingContentType(media_types),
|
|
460
|
-
)
|
|
461
|
-
if not is_json_media_type(content_type):
|
|
462
|
-
return
|
|
562
|
+
content_type = content_types[0]
|
|
563
|
+
|
|
564
|
+
context = deserialization.DeserializationContext(operation=operation, case=case)
|
|
565
|
+
|
|
463
566
|
try:
|
|
464
|
-
|
|
465
|
-
data = response.json()
|
|
466
|
-
else:
|
|
467
|
-
data = response.json
|
|
567
|
+
data = deserialization.deserialize_response(response, content_type, context=context)
|
|
468
568
|
except JSONDecodeError as exc:
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
569
|
+
failures.append(MalformedJson.from_exception(operation=operation.label, exc=exc))
|
|
570
|
+
_maybe_raise_one_or_more(failures)
|
|
571
|
+
return None
|
|
572
|
+
except NotImplementedError:
|
|
573
|
+
# No deserializer available for this media type - skip validation
|
|
574
|
+
# This is expected for many media types (images, binary formats, etc.)
|
|
575
|
+
return None
|
|
576
|
+
except Exception as exc:
|
|
577
|
+
failures.append(
|
|
578
|
+
Failure(
|
|
579
|
+
operation=operation.label,
|
|
580
|
+
title="Content deserialization error",
|
|
581
|
+
message=f"Failed to deserialize response content:\n\n {exc}",
|
|
582
|
+
)
|
|
583
|
+
)
|
|
584
|
+
_maybe_raise_one_or_more(failures)
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
try:
|
|
588
|
+
definition.validator.validate(data)
|
|
589
|
+
except jsonschema.SchemaError as exc:
|
|
590
|
+
raise InvalidSchema.from_jsonschema_error(
|
|
591
|
+
exc,
|
|
592
|
+
path=operation.path,
|
|
593
|
+
method=operation.method,
|
|
594
|
+
config=self.config.output,
|
|
595
|
+
location=SchemaLocation.response_schema(self.specification.version),
|
|
476
596
|
) from exc
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
f"The received response does not conform to the defined schema!\n\nDetails: \n\n{exc}",
|
|
487
|
-
context=failures.ValidationErrorContext(
|
|
488
|
-
validation_message=exc.message,
|
|
489
|
-
schema_path=list(exc.absolute_schema_path),
|
|
490
|
-
schema=exc.schema,
|
|
491
|
-
instance_path=list(exc.absolute_path),
|
|
492
|
-
instance=exc.instance,
|
|
493
|
-
),
|
|
494
|
-
) from exc
|
|
597
|
+
except jsonschema.ValidationError as exc:
|
|
598
|
+
failures.append(
|
|
599
|
+
JsonSchemaError.from_exception(
|
|
600
|
+
operation=operation.label,
|
|
601
|
+
exc=exc,
|
|
602
|
+
config=operation.schema.config.output,
|
|
603
|
+
)
|
|
604
|
+
)
|
|
605
|
+
_maybe_raise_one_or_more(failures)
|
|
495
606
|
return None # explicitly return None for mypy
|
|
496
607
|
|
|
497
|
-
@property
|
|
498
|
-
def rewritten_components(self) -> Dict[str, Any]:
|
|
499
|
-
if not hasattr(self, "_rewritten_components"):
|
|
500
|
-
|
|
501
|
-
def callback(_schema: Dict[str, Any], nullable_name: str) -> Dict[str, Any]:
|
|
502
|
-
_schema = to_json_schema(_schema, nullable_name=nullable_name, copy=False)
|
|
503
|
-
return self._rewrite_references(_schema, self.resolver)
|
|
504
|
-
|
|
505
|
-
# pylint: disable=attribute-defined-outside-init
|
|
506
|
-
# Different spec versions allow different keywords to store possible reference targets
|
|
507
|
-
components: Dict[str, Any] = {}
|
|
508
|
-
for path in self.component_locations:
|
|
509
|
-
schema = self.raw_schema
|
|
510
|
-
target = components
|
|
511
|
-
for chunk in path:
|
|
512
|
-
if chunk in schema:
|
|
513
|
-
schema = schema[chunk]
|
|
514
|
-
target = target.setdefault(chunk, {})
|
|
515
|
-
else:
|
|
516
|
-
break
|
|
517
|
-
else:
|
|
518
|
-
target.update(traverse_schema(deepcopy(schema), callback, self.nullable_name))
|
|
519
|
-
self._rewritten_components = components
|
|
520
|
-
return self._rewritten_components
|
|
521
|
-
|
|
522
|
-
def prepare_schema(self, schema: Any) -> Any:
|
|
523
|
-
"""Inline Open API definitions.
|
|
524
608
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
# Note that not all of them might be used for data generation, but at this point it is the simplest way to go
|
|
532
|
-
if self._inline_reference_cache:
|
|
533
|
-
schema[INLINED_REFERENCES_KEY] = self._inline_reference_cache
|
|
534
|
-
return schema
|
|
535
|
-
|
|
536
|
-
def _rewrite_references(self, schema: Dict[str, Any], resolver: InliningResolver) -> Dict[str, Any]:
|
|
537
|
-
"""Rewrite references present in the schema.
|
|
538
|
-
|
|
539
|
-
The idea is to resolve references, cache the result and replace these references with new ones
|
|
540
|
-
that point to a local path which is populated from this cache later on.
|
|
541
|
-
"""
|
|
542
|
-
reference = schema.get("$ref")
|
|
543
|
-
# If `$ref` is not a property name and should be processed
|
|
544
|
-
if reference is not None and isinstance(reference, str) and not reference.startswith("#/"):
|
|
545
|
-
key = _make_reference_key(resolver._scopes_stack, reference)
|
|
546
|
-
with self._inline_reference_cache_lock:
|
|
547
|
-
if key not in self._inline_reference_cache:
|
|
548
|
-
with resolver.resolving(reference) as resolved:
|
|
549
|
-
# Resolved object also may have references
|
|
550
|
-
self._inline_reference_cache[key] = traverse_schema(
|
|
551
|
-
resolved, lambda s: self._rewrite_references(s, resolver)
|
|
552
|
-
)
|
|
553
|
-
# Rewrite the reference with the new location
|
|
554
|
-
schema["$ref"] = f"#/{INLINED_REFERENCES_KEY}/{key}"
|
|
555
|
-
return schema
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
def _make_reference_key(scopes: List[str], reference: str) -> str:
|
|
559
|
-
"""A name under which the resolved reference data will be stored."""
|
|
560
|
-
# Using a hexdigest is the simplest way to associate practically unique keys with each reference
|
|
561
|
-
digest = sha1()
|
|
562
|
-
for scope in scopes:
|
|
563
|
-
digest.update(scope.encode("utf-8"))
|
|
564
|
-
# Separator to avoid collissions like this: ["a"], "bc" vs. ["ab"], "c". Otherwise, the resulting digest
|
|
565
|
-
# will be the same for both cases
|
|
566
|
-
digest.update(b"#")
|
|
567
|
-
digest.update(reference.encode("utf-8"))
|
|
568
|
-
return digest.hexdigest()
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
INLINED_REFERENCES_KEY = "x-inlined"
|
|
609
|
+
def _maybe_raise_one_or_more(failures: list[Failure]) -> None:
|
|
610
|
+
if not failures:
|
|
611
|
+
return
|
|
612
|
+
if len(failures) == 1:
|
|
613
|
+
raise failures[0] from None
|
|
614
|
+
raise FailureGroup(failures) from None
|
|
572
615
|
|
|
573
616
|
|
|
574
617
|
@contextmanager
|
|
@@ -580,185 +623,154 @@ def in_scope(resolver: jsonschema.RefResolver, scope: str) -> Generator[None, No
|
|
|
580
623
|
resolver.pop_scope()
|
|
581
624
|
|
|
582
625
|
|
|
583
|
-
@
|
|
584
|
-
|
|
585
|
-
"""
|
|
626
|
+
@dataclass
|
|
627
|
+
class MethodMap(Mapping):
|
|
628
|
+
"""Container for accessing API operations.
|
|
586
629
|
|
|
587
|
-
|
|
588
|
-
could be a stack of two scopes maximum. This context manager handles both cases (1 or 2 scope changes) in the same
|
|
589
|
-
way.
|
|
630
|
+
Provides a more specific error message if API operation is not found.
|
|
590
631
|
"""
|
|
591
|
-
with ExitStack() as stack:
|
|
592
|
-
for scope in scopes:
|
|
593
|
-
stack.enter_context(in_scope(resolver, scope))
|
|
594
|
-
yield
|
|
595
632
|
|
|
633
|
+
_parent: APIOperationMap
|
|
634
|
+
# Reference resolution scope
|
|
635
|
+
_scope: str
|
|
636
|
+
# Methods are stored for this path
|
|
637
|
+
_path: str
|
|
638
|
+
# Storage for definitions
|
|
639
|
+
_path_item: CaseInsensitiveDict
|
|
640
|
+
|
|
641
|
+
__slots__ = ("_parent", "_scope", "_path", "_path_item")
|
|
642
|
+
|
|
643
|
+
def __len__(self) -> int:
|
|
644
|
+
return len(self._path_item)
|
|
645
|
+
|
|
646
|
+
def __iter__(self) -> Iterator[str]:
|
|
647
|
+
return iter(self._path_item)
|
|
648
|
+
|
|
649
|
+
def _init_operation(self, method: str) -> APIOperation:
|
|
650
|
+
method = method.lower()
|
|
651
|
+
operation = self._path_item[method]
|
|
652
|
+
schema = cast(BaseOpenAPISchema, self._parent._schema)
|
|
653
|
+
path = self._path
|
|
654
|
+
scope = self._scope
|
|
655
|
+
with in_scope(schema.resolver, scope):
|
|
656
|
+
try:
|
|
657
|
+
parameters = schema._iter_parameters(operation, self._path_item.get("parameters", []))
|
|
658
|
+
except SCHEMA_PARSING_ERRORS as exc:
|
|
659
|
+
schema._raise_invalid_schema(exc, path, method)
|
|
660
|
+
return schema.make_operation(path, method, parameters, operation, scope)
|
|
596
661
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
662
|
+
def __getitem__(self, item: str) -> APIOperation:
|
|
663
|
+
try:
|
|
664
|
+
return self._init_operation(item)
|
|
665
|
+
except LookupError as exc:
|
|
666
|
+
available_methods = ", ".join(key.upper() for key in self if key in HTTP_METHODS)
|
|
667
|
+
message = f"Method `{item.upper()}` not found."
|
|
668
|
+
if available_methods:
|
|
669
|
+
message += f" Available methods: {available_methods}"
|
|
670
|
+
raise LookupError(message) from exc
|
|
600
671
|
|
|
601
672
|
|
|
602
673
|
class SwaggerV20(BaseOpenAPISchema):
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
security = SwaggerSecurityProcessor()
|
|
607
|
-
component_locations: ClassVar[Tuple[Tuple[str, ...], ...]] = (("definitions",),)
|
|
608
|
-
links_field = "x-links"
|
|
674
|
+
def __post_init__(self) -> None:
|
|
675
|
+
self.adapter = adapter.v2
|
|
676
|
+
super().__post_init__()
|
|
609
677
|
|
|
610
678
|
@property
|
|
611
|
-
def
|
|
612
|
-
|
|
679
|
+
def specification(self) -> Specification:
|
|
680
|
+
version = self.raw_schema.get("swagger", "2.0")
|
|
681
|
+
return Specification.openapi(version=version)
|
|
613
682
|
|
|
614
|
-
@
|
|
615
|
-
def
|
|
616
|
-
return
|
|
683
|
+
@cached_property
|
|
684
|
+
def default_media_types(self) -> list[str]:
|
|
685
|
+
return self.raw_schema.get("consumes", [])
|
|
686
|
+
|
|
687
|
+
def _validate(self) -> None:
|
|
688
|
+
SWAGGER_20_VALIDATOR.validate(self.raw_schema)
|
|
617
689
|
|
|
618
690
|
def _get_base_path(self) -> str:
|
|
619
691
|
return self.raw_schema.get("basePath", "/")
|
|
620
692
|
|
|
621
|
-
def
|
|
622
|
-
self, parameters: Iterable[Dict[str, Any]], definition: Dict[str, Any]
|
|
623
|
-
) -> List[OpenAPIParameter]:
|
|
624
|
-
# The main difference with Open API 3.0 is that it has `body` and `form` parameters that we need to handle
|
|
625
|
-
# differently.
|
|
626
|
-
collected: List[OpenAPIParameter] = []
|
|
627
|
-
# NOTE. The Open API 2.0 spec doesn't strictly imply having media types in the "consumes" keyword.
|
|
628
|
-
# It is not enforced by the meta schema and has no "MUST" verb in the spec text.
|
|
629
|
-
# Also, not every API has operations with payload (they might have only GET operations without payloads).
|
|
630
|
-
# For these reasons, it might be (and often is) absent, and we need to provide the proper media type in case
|
|
631
|
-
# we have operations with a payload.
|
|
632
|
-
media_types = self._get_consumes_for_operation(definition)
|
|
633
|
-
# For `in=body` parameters, we imply `application/json` as the default media type because it is the most common.
|
|
634
|
-
body_media_types = media_types or (OPENAPI_20_DEFAULT_BODY_MEDIA_TYPE,)
|
|
635
|
-
# If an API operation has parameters with `in=formData`, Schemathesis should know how to serialize it.
|
|
636
|
-
# We can't be 100% sure what media type is expected by the server and chose `multipart/form-data` as
|
|
637
|
-
# the default because it is broader since it allows us to upload files.
|
|
638
|
-
form_data_media_types = media_types or (OPENAPI_20_DEFAULT_FORM_MEDIA_TYPE,)
|
|
639
|
-
|
|
640
|
-
form_parameters = []
|
|
641
|
-
for parameter in parameters:
|
|
642
|
-
if parameter["in"] == "formData":
|
|
643
|
-
# We need to gather form parameters first before creating a composite parameter for them
|
|
644
|
-
form_parameters.append(parameter)
|
|
645
|
-
elif parameter["in"] == "body":
|
|
646
|
-
for media_type in body_media_types:
|
|
647
|
-
collected.append(OpenAPI20Body(definition=parameter, media_type=media_type))
|
|
648
|
-
else:
|
|
649
|
-
collected.append(OpenAPI20Parameter(definition=parameter))
|
|
650
|
-
|
|
651
|
-
if form_parameters:
|
|
652
|
-
for media_type in form_data_media_types:
|
|
653
|
-
collected.append(
|
|
654
|
-
# Individual `formData` parameters are joined into a single "composite" one.
|
|
655
|
-
OpenAPI20CompositeBody.from_parameters(*form_parameters, media_type=media_type)
|
|
656
|
-
)
|
|
657
|
-
return collected
|
|
658
|
-
|
|
659
|
-
def get_strategies_from_examples(self, operation: APIOperation) -> List[SearchStrategy[Case]]:
|
|
693
|
+
def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
|
|
660
694
|
"""Get examples from the API operation."""
|
|
661
|
-
return get_strategies_from_examples(operation,
|
|
662
|
-
|
|
663
|
-
def
|
|
664
|
-
scopes, definition = self.resolver.resolve_in_scope(deepcopy(definition), scope)
|
|
665
|
-
schema = definition.get("schema")
|
|
666
|
-
if not schema:
|
|
667
|
-
return scopes, None
|
|
668
|
-
# Extra conversion to JSON Schema is needed here if there was one $ref in the input
|
|
669
|
-
# because it is not converted
|
|
670
|
-
return scopes, to_json_schema_recursive(schema, self.nullable_name, is_response_schema=True)
|
|
671
|
-
|
|
672
|
-
def get_content_types(self, operation: APIOperation, response: GenericResponse) -> List[str]:
|
|
695
|
+
return get_strategies_from_examples(operation, **kwargs)
|
|
696
|
+
|
|
697
|
+
def get_content_types(self, operation: APIOperation, response: Response) -> list[str]:
|
|
673
698
|
produces = operation.definition.raw.get("produces", None)
|
|
674
699
|
if produces:
|
|
675
700
|
return produces
|
|
676
701
|
return self.raw_schema.get("produces", [])
|
|
677
702
|
|
|
678
|
-
def _get_parameter_serializer(self, definitions:
|
|
703
|
+
def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
|
|
679
704
|
return serialization.serialize_swagger2_parameters(definitions)
|
|
680
705
|
|
|
681
706
|
def prepare_multipart(
|
|
682
|
-
self, form_data:
|
|
683
|
-
) ->
|
|
684
|
-
"""Prepare form data for sending with `requests`.
|
|
685
|
-
|
|
686
|
-
:param form_data: Raw generated data as a dictionary.
|
|
687
|
-
:param operation: The tested API operation for which the data was generated.
|
|
688
|
-
:return: `files` and `data` values for `requests.request`.
|
|
689
|
-
"""
|
|
707
|
+
self, form_data: dict[str, Any], operation: APIOperation
|
|
708
|
+
) -> tuple[list | None, dict[str, Any] | None]:
|
|
690
709
|
files, data = [], {}
|
|
691
710
|
# If there is no content types specified for the request or "application/x-www-form-urlencoded" is specified
|
|
692
711
|
# explicitly, then use it., but if "multipart/form-data" is specified, then use it
|
|
693
712
|
content_types = self.get_request_payload_content_types(operation)
|
|
694
713
|
is_multipart = "multipart/form-data" in content_types
|
|
695
714
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
715
|
+
known_fields: dict[str, dict] = {}
|
|
716
|
+
|
|
717
|
+
for parameter in operation.body:
|
|
718
|
+
if COMBINED_FORM_DATA_MARKER in parameter.definition:
|
|
719
|
+
known_fields.update(parameter.definition["schema"].get("properties", {}))
|
|
720
|
+
|
|
721
|
+
def add_file(name: str, value: Any) -> None:
|
|
722
|
+
if isinstance(value, list):
|
|
723
|
+
for item in value:
|
|
699
724
|
files.append((name, (None, item)))
|
|
700
725
|
else:
|
|
701
|
-
files.append((name,
|
|
702
|
-
|
|
703
|
-
for
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
data[name] = value
|
|
726
|
+
files.append((name, value))
|
|
727
|
+
|
|
728
|
+
for name, value in form_data.items():
|
|
729
|
+
param_def = known_fields.get(name)
|
|
730
|
+
if param_def:
|
|
731
|
+
if param_def.get("type") == "file" or is_multipart:
|
|
732
|
+
add_file(name, value)
|
|
733
|
+
else:
|
|
734
|
+
data[name] = value
|
|
735
|
+
else:
|
|
736
|
+
# Unknown field — treat it as a file (safe default under multipart/form-data)
|
|
737
|
+
add_file(name, value)
|
|
714
738
|
# `None` is the default value for `files` and `data` arguments in `requests.request`
|
|
715
739
|
return files or None, data or None
|
|
716
740
|
|
|
717
|
-
def get_request_payload_content_types(self, operation: APIOperation) ->
|
|
718
|
-
return self._get_consumes_for_operation(operation.definition.
|
|
741
|
+
def get_request_payload_content_types(self, operation: APIOperation) -> list[str]:
|
|
742
|
+
return self._get_consumes_for_operation(operation.definition.raw)
|
|
719
743
|
|
|
720
744
|
def make_case(
|
|
721
745
|
self,
|
|
722
746
|
*,
|
|
723
|
-
case_cls: Type[C],
|
|
724
747
|
operation: APIOperation,
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
748
|
+
method: str | None = None,
|
|
749
|
+
path: str | None = None,
|
|
750
|
+
path_parameters: dict[str, Any] | None = None,
|
|
751
|
+
headers: dict[str, Any] | CaseInsensitiveDict | None = None,
|
|
752
|
+
cookies: dict[str, Any] | None = None,
|
|
753
|
+
query: dict[str, Any] | None = None,
|
|
754
|
+
body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet = NOT_SET,
|
|
755
|
+
media_type: str | None = None,
|
|
756
|
+
meta: CaseMetadata | None = None,
|
|
757
|
+
) -> Case:
|
|
732
758
|
if body is not NOT_SET and media_type is None:
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
if len(media_types) == 1:
|
|
736
|
-
# The only available option
|
|
737
|
-
media_type = media_types[0]
|
|
738
|
-
else:
|
|
739
|
-
media_types_repr = ", ".join(media_types)
|
|
740
|
-
raise UsageError(
|
|
741
|
-
"Can not detect appropriate media type. "
|
|
742
|
-
"You can either specify one of the defined media types "
|
|
743
|
-
f"or pass any other media type available for serialization. Defined media types: {media_types_repr}"
|
|
744
|
-
)
|
|
745
|
-
return case_cls(
|
|
759
|
+
media_type = operation._get_default_media_type()
|
|
760
|
+
return Case(
|
|
746
761
|
operation=operation,
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
762
|
+
method=method or operation.method.upper(),
|
|
763
|
+
path=path or operation.path,
|
|
764
|
+
path_parameters=path_parameters or {},
|
|
765
|
+
headers=CaseInsensitiveDict() if headers is None else CaseInsensitiveDict(headers),
|
|
766
|
+
cookies=cookies or {},
|
|
767
|
+
query=query or {},
|
|
751
768
|
body=body,
|
|
752
769
|
media_type=media_type,
|
|
770
|
+
meta=meta,
|
|
753
771
|
)
|
|
754
772
|
|
|
755
|
-
def _get_consumes_for_operation(self, definition:
|
|
756
|
-
"""Get the `consumes` value for the given API operation.
|
|
757
|
-
|
|
758
|
-
:param definition: Raw API operation definition.
|
|
759
|
-
:return: A list of media-types for this operation.
|
|
760
|
-
:rtype: List[str]
|
|
761
|
-
"""
|
|
773
|
+
def _get_consumes_for_operation(self, definition: dict[str, Any]) -> list[str]:
|
|
762
774
|
global_consumes = self.raw_schema.get("consumes", [])
|
|
763
775
|
consumes = definition.get("consumes", [])
|
|
764
776
|
if not consumes:
|
|
@@ -766,21 +778,29 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
766
778
|
return consumes
|
|
767
779
|
|
|
768
780
|
|
|
769
|
-
class OpenApi30(SwaggerV20):
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
781
|
+
class OpenApi30(SwaggerV20):
|
|
782
|
+
def __post_init__(self) -> None:
|
|
783
|
+
if self.specification.version.startswith("3.1"):
|
|
784
|
+
self.adapter = adapter.v3_1
|
|
785
|
+
else:
|
|
786
|
+
self.adapter = adapter.v3_0
|
|
787
|
+
BaseOpenAPISchema.__post_init__(self)
|
|
776
788
|
|
|
777
789
|
@property
|
|
778
|
-
def
|
|
779
|
-
|
|
790
|
+
def specification(self) -> Specification:
|
|
791
|
+
version = self.raw_schema["openapi"]
|
|
792
|
+
return Specification.openapi(version=version)
|
|
780
793
|
|
|
781
|
-
@
|
|
782
|
-
def
|
|
783
|
-
return
|
|
794
|
+
@cached_property
|
|
795
|
+
def default_media_types(self) -> list[str]:
|
|
796
|
+
return []
|
|
797
|
+
|
|
798
|
+
def _validate(self) -> None:
|
|
799
|
+
if self.specification.version.startswith("3.1"):
|
|
800
|
+
# Currently we treat Open API 3.1 as 3.0 in some regard
|
|
801
|
+
OPENAPI_31_VALIDATOR.validate(self.raw_schema)
|
|
802
|
+
else:
|
|
803
|
+
OPENAPI_30_VALIDATOR.validate(self.raw_schema)
|
|
784
804
|
|
|
785
805
|
def _get_base_path(self) -> str:
|
|
786
806
|
servers = self.raw_schema.get("servers", [])
|
|
@@ -791,68 +811,46 @@ class OpenApi30(SwaggerV20): # pylint: disable=too-many-ancestors
|
|
|
791
811
|
return urlsplit(url).path
|
|
792
812
|
return "/"
|
|
793
813
|
|
|
794
|
-
def
|
|
795
|
-
self, parameters: Iterable[Dict[str, Any]], definition: Dict[str, Any]
|
|
796
|
-
) -> List[OpenAPIParameter]:
|
|
797
|
-
# Open API 3.0 has the `requestBody` keyword, which may contain multiple different payload variants.
|
|
798
|
-
collected: List[OpenAPIParameter] = [OpenAPI30Parameter(definition=parameter) for parameter in parameters]
|
|
799
|
-
if "requestBody" in definition:
|
|
800
|
-
required = definition["requestBody"].get("required", False)
|
|
801
|
-
description = definition["requestBody"].get("description")
|
|
802
|
-
for media_type, content in definition["requestBody"]["content"].items():
|
|
803
|
-
collected.append(
|
|
804
|
-
OpenAPI30Body(content, description=description, media_type=media_type, required=required)
|
|
805
|
-
)
|
|
806
|
-
return collected
|
|
807
|
-
|
|
808
|
-
def get_response_schema(self, definition: Dict[str, Any], scope: str) -> Tuple[List[str], Optional[Dict[str, Any]]]:
|
|
809
|
-
scopes, definition = self.resolver.resolve_in_scope(deepcopy(definition), scope)
|
|
810
|
-
options = iter(definition.get("content", {}).values())
|
|
811
|
-
option = next(options, None)
|
|
812
|
-
# "schema" is an optional key in the `MediaType` object
|
|
813
|
-
if option and "schema" in option:
|
|
814
|
-
# Extra conversion to JSON Schema is needed here if there was one $ref in the input
|
|
815
|
-
# because it is not converted
|
|
816
|
-
return scopes, to_json_schema_recursive(option["schema"], self.nullable_name, is_response_schema=True)
|
|
817
|
-
return scopes, None
|
|
818
|
-
|
|
819
|
-
def get_strategies_from_examples(self, operation: APIOperation) -> List[SearchStrategy[Case]]:
|
|
814
|
+
def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
|
|
820
815
|
"""Get examples from the API operation."""
|
|
821
|
-
return get_strategies_from_examples(operation,
|
|
816
|
+
return get_strategies_from_examples(operation, **kwargs)
|
|
822
817
|
|
|
823
|
-
def get_content_types(self, operation: APIOperation, response:
|
|
824
|
-
|
|
825
|
-
if
|
|
818
|
+
def get_content_types(self, operation: APIOperation, response: Response) -> list[str]:
|
|
819
|
+
definition = operation.responses.find_by_status_code(response.status_code)
|
|
820
|
+
if definition is None:
|
|
826
821
|
return []
|
|
827
|
-
return list(
|
|
822
|
+
return list(definition.definition.get("content", {}).keys())
|
|
828
823
|
|
|
829
|
-
def _get_parameter_serializer(self, definitions:
|
|
824
|
+
def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
|
|
830
825
|
return serialization.serialize_openapi3_parameters(definitions)
|
|
831
826
|
|
|
832
|
-
def get_request_payload_content_types(self, operation: APIOperation) ->
|
|
833
|
-
return
|
|
827
|
+
def get_request_payload_content_types(self, operation: APIOperation) -> list[str]:
|
|
828
|
+
return [body.media_type for body in operation.body]
|
|
834
829
|
|
|
835
830
|
def prepare_multipart(
|
|
836
|
-
self, form_data:
|
|
837
|
-
) ->
|
|
838
|
-
"""Prepare form data for sending with `requests`.
|
|
839
|
-
|
|
840
|
-
:param form_data: Raw generated data as a dictionary.
|
|
841
|
-
:param operation: The tested API operation for which the data was generated.
|
|
842
|
-
:return: `files` and `data` values for `requests.request`.
|
|
843
|
-
"""
|
|
831
|
+
self, form_data: dict[str, Any], operation: APIOperation
|
|
832
|
+
) -> tuple[list | None, dict[str, Any] | None]:
|
|
844
833
|
files = []
|
|
845
|
-
content = operation.definition.resolved["requestBody"]["content"]
|
|
846
834
|
# Open API 3.0 requires media types to be present. We can get here only if the schema defines
|
|
847
|
-
# the "multipart/form-data" media type
|
|
848
|
-
schema =
|
|
849
|
-
for
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
835
|
+
# the "multipart/form-data" media type, or any other more general media type that matches it (like `*/*`)
|
|
836
|
+
schema = {}
|
|
837
|
+
for body in operation.body:
|
|
838
|
+
main, sub = media_types.parse(body.media_type)
|
|
839
|
+
if main in ("*", "multipart") and sub in ("*", "form-data", "mixed"):
|
|
840
|
+
schema = body.definition.get("schema")
|
|
841
|
+
break
|
|
842
|
+
for name, value in form_data.items():
|
|
843
|
+
property_schema = (schema or {}).get("properties", {}).get(name)
|
|
844
|
+
if property_schema:
|
|
845
|
+
if isinstance(value, list):
|
|
846
|
+
files.extend([(name, item) for item in value])
|
|
853
847
|
elif property_schema.get("format") in ("binary", "base64"):
|
|
854
|
-
files.append((name,
|
|
848
|
+
files.append((name, value))
|
|
855
849
|
else:
|
|
856
|
-
files.append((name, (None,
|
|
850
|
+
files.append((name, (None, value)))
|
|
851
|
+
elif isinstance(value, list):
|
|
852
|
+
files.extend([(name, item) for item in value])
|
|
853
|
+
else:
|
|
854
|
+
files.append((name, (None, value)))
|
|
857
855
|
# `None` is the default value for `files` and `data` arguments in `requests.request`
|
|
858
856
|
return files or None, None
|