schemathesis 3.25.5__py3-none-any.whl → 3.39.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemathesis/__init__.py +6 -6
- schemathesis/_compat.py +2 -2
- schemathesis/_dependency_versions.py +4 -2
- schemathesis/_hypothesis.py +369 -56
- schemathesis/_lazy_import.py +1 -0
- schemathesis/_override.py +5 -4
- schemathesis/_patches.py +21 -0
- schemathesis/_rate_limiter.py +7 -0
- schemathesis/_xml.py +75 -22
- schemathesis/auths.py +78 -16
- schemathesis/checks.py +21 -9
- schemathesis/cli/__init__.py +793 -448
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/callbacks.py +58 -13
- schemathesis/cli/cassettes.py +233 -47
- schemathesis/cli/constants.py +8 -2
- schemathesis/cli/context.py +24 -4
- schemathesis/cli/debug.py +2 -1
- schemathesis/cli/handlers.py +4 -1
- schemathesis/cli/junitxml.py +103 -22
- schemathesis/cli/options.py +15 -4
- schemathesis/cli/output/default.py +286 -115
- schemathesis/cli/output/short.py +25 -6
- schemathesis/cli/reporting.py +79 -0
- schemathesis/cli/sanitization.py +6 -0
- schemathesis/code_samples.py +5 -3
- schemathesis/constants.py +1 -0
- schemathesis/contrib/openapi/__init__.py +1 -1
- schemathesis/contrib/openapi/fill_missing_examples.py +3 -1
- schemathesis/contrib/openapi/formats/uuid.py +2 -1
- schemathesis/contrib/unique_data.py +3 -3
- schemathesis/exceptions.py +76 -65
- schemathesis/experimental/__init__.py +35 -0
- schemathesis/extra/_aiohttp.py +1 -0
- schemathesis/extra/_flask.py +4 -1
- schemathesis/extra/_server.py +1 -0
- schemathesis/extra/pytest_plugin.py +17 -25
- schemathesis/failures.py +77 -9
- schemathesis/filters.py +185 -8
- schemathesis/fixups/__init__.py +1 -0
- schemathesis/fixups/fast_api.py +2 -2
- schemathesis/fixups/utf8_bom.py +1 -2
- schemathesis/generation/__init__.py +20 -36
- schemathesis/generation/_hypothesis.py +59 -0
- schemathesis/generation/_methods.py +44 -0
- schemathesis/generation/coverage.py +931 -0
- schemathesis/graphql.py +0 -1
- schemathesis/hooks.py +89 -12
- schemathesis/internal/checks.py +84 -0
- schemathesis/internal/copy.py +22 -3
- schemathesis/internal/deprecation.py +6 -2
- schemathesis/internal/diff.py +15 -0
- schemathesis/internal/extensions.py +27 -0
- schemathesis/internal/jsonschema.py +2 -1
- schemathesis/internal/output.py +68 -0
- schemathesis/internal/result.py +1 -1
- schemathesis/internal/transformation.py +11 -0
- schemathesis/lazy.py +138 -25
- schemathesis/loaders.py +7 -5
- schemathesis/models.py +323 -213
- schemathesis/parameters.py +4 -0
- schemathesis/runner/__init__.py +72 -22
- schemathesis/runner/events.py +86 -6
- schemathesis/runner/impl/context.py +104 -0
- schemathesis/runner/impl/core.py +447 -187
- schemathesis/runner/impl/solo.py +19 -29
- schemathesis/runner/impl/threadpool.py +70 -79
- schemathesis/{cli → runner}/probes.py +37 -25
- schemathesis/runner/serialization.py +150 -17
- schemathesis/sanitization.py +5 -1
- schemathesis/schemas.py +170 -102
- schemathesis/serializers.py +17 -4
- schemathesis/service/ci.py +1 -0
- schemathesis/service/client.py +39 -6
- schemathesis/service/events.py +5 -1
- schemathesis/service/extensions.py +224 -0
- schemathesis/service/hosts.py +6 -2
- schemathesis/service/metadata.py +25 -0
- schemathesis/service/models.py +211 -2
- schemathesis/service/report.py +6 -6
- schemathesis/service/serialization.py +60 -71
- schemathesis/service/usage.py +1 -0
- schemathesis/specs/graphql/_cache.py +26 -0
- schemathesis/specs/graphql/loaders.py +25 -5
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +2 -2
- schemathesis/specs/graphql/schemas.py +130 -100
- schemathesis/specs/graphql/validation.py +1 -2
- schemathesis/specs/openapi/__init__.py +1 -0
- schemathesis/specs/openapi/_cache.py +123 -0
- schemathesis/specs/openapi/_hypothesis.py +79 -61
- schemathesis/specs/openapi/checks.py +504 -25
- schemathesis/specs/openapi/converter.py +31 -4
- schemathesis/specs/openapi/definitions.py +10 -17
- schemathesis/specs/openapi/examples.py +143 -31
- schemathesis/specs/openapi/expressions/__init__.py +37 -2
- schemathesis/specs/openapi/expressions/context.py +1 -1
- schemathesis/specs/openapi/expressions/extractors.py +26 -0
- schemathesis/specs/openapi/expressions/lexer.py +20 -18
- schemathesis/specs/openapi/expressions/nodes.py +29 -6
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/formats.py +44 -0
- schemathesis/specs/openapi/links.py +125 -42
- schemathesis/specs/openapi/loaders.py +77 -36
- schemathesis/specs/openapi/media_types.py +34 -0
- schemathesis/specs/openapi/negative/__init__.py +6 -3
- schemathesis/specs/openapi/negative/mutations.py +21 -6
- schemathesis/specs/openapi/parameters.py +39 -25
- schemathesis/specs/openapi/patterns.py +137 -0
- schemathesis/specs/openapi/references.py +37 -7
- schemathesis/specs/openapi/schemas.py +368 -242
- schemathesis/specs/openapi/security.py +25 -7
- schemathesis/specs/openapi/serialization.py +1 -0
- schemathesis/specs/openapi/stateful/__init__.py +198 -70
- schemathesis/specs/openapi/stateful/statistic.py +198 -0
- schemathesis/specs/openapi/stateful/types.py +14 -0
- schemathesis/specs/openapi/utils.py +6 -1
- schemathesis/specs/openapi/validation.py +1 -0
- schemathesis/stateful/__init__.py +35 -21
- schemathesis/stateful/config.py +97 -0
- schemathesis/stateful/context.py +135 -0
- schemathesis/stateful/events.py +274 -0
- schemathesis/stateful/runner.py +309 -0
- schemathesis/stateful/sink.py +68 -0
- schemathesis/stateful/state_machine.py +67 -38
- schemathesis/stateful/statistic.py +22 -0
- schemathesis/stateful/validation.py +100 -0
- schemathesis/targets.py +33 -1
- schemathesis/throttling.py +25 -5
- schemathesis/transports/__init__.py +354 -0
- schemathesis/transports/asgi.py +7 -0
- schemathesis/transports/auth.py +25 -2
- schemathesis/transports/content_types.py +3 -1
- schemathesis/transports/headers.py +2 -1
- schemathesis/transports/responses.py +9 -4
- schemathesis/types.py +9 -0
- schemathesis/utils.py +11 -16
- schemathesis-3.39.7.dist-info/METADATA +293 -0
- schemathesis-3.39.7.dist-info/RECORD +160 -0
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/WHEEL +1 -1
- schemathesis/specs/openapi/filters.py +0 -49
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis-3.25.5.dist-info/METADATA +0 -356
- schemathesis-3.25.5.dist-info/RECORD +0 -134
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.25.5.dist-info → schemathesis-3.39.7.dist-info}/licenses/LICENSE +0 -0
|
@@ -8,6 +8,7 @@ from difflib import get_close_matches
|
|
|
8
8
|
from hashlib import sha1
|
|
9
9
|
from json import JSONDecodeError
|
|
10
10
|
from threading import RLock
|
|
11
|
+
from types import SimpleNamespace
|
|
11
12
|
from typing import (
|
|
12
13
|
TYPE_CHECKING,
|
|
13
14
|
Any,
|
|
@@ -15,56 +16,49 @@ from typing import (
|
|
|
15
16
|
ClassVar,
|
|
16
17
|
Generator,
|
|
17
18
|
Iterable,
|
|
19
|
+
Iterator,
|
|
20
|
+
Mapping,
|
|
18
21
|
NoReturn,
|
|
19
22
|
Sequence,
|
|
20
23
|
TypeVar,
|
|
24
|
+
cast,
|
|
21
25
|
)
|
|
22
26
|
from urllib.parse import urlsplit
|
|
23
27
|
|
|
24
28
|
import jsonschema
|
|
25
|
-
from hypothesis.strategies import SearchStrategy
|
|
26
29
|
from packaging import version
|
|
27
30
|
from requests.structures import CaseInsensitiveDict
|
|
28
31
|
|
|
29
32
|
from ... import experimental, failures
|
|
30
33
|
from ..._compat import MultipleFailures
|
|
31
|
-
from ..._override import CaseOverride,
|
|
32
|
-
from ...auths import AuthStorage
|
|
33
|
-
from ...generation import DataGenerationMethod, GenerationConfig
|
|
34
|
+
from ..._override import CaseOverride, check_no_override_mark, set_override_mark
|
|
34
35
|
from ...constants import HTTP_METHODS, NOT_SET
|
|
35
36
|
from ...exceptions import (
|
|
37
|
+
InternalError,
|
|
38
|
+
OperationNotFound,
|
|
36
39
|
OperationSchemaError,
|
|
37
|
-
|
|
40
|
+
SchemaError,
|
|
41
|
+
SchemaErrorType,
|
|
38
42
|
get_missing_content_type_error,
|
|
39
43
|
get_response_parsing_error,
|
|
40
44
|
get_schema_validation_error,
|
|
41
|
-
SchemaError,
|
|
42
|
-
SchemaErrorType,
|
|
43
|
-
OperationNotFound,
|
|
44
45
|
)
|
|
46
|
+
from ...generation import DataGenerationMethod, GenerationConfig
|
|
45
47
|
from ...hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, should_skip_operation
|
|
46
48
|
from ...internal.copy import fast_deepcopy
|
|
47
49
|
from ...internal.jsonschema import traverse_schema
|
|
48
50
|
from ...internal.result import Err, Ok, Result
|
|
49
51
|
from ...models import APIOperation, Case, OperationDefinition
|
|
50
|
-
from ...schemas import
|
|
52
|
+
from ...schemas import APIOperationMap, BaseSchema
|
|
51
53
|
from ...stateful import Stateful, StatefulTest
|
|
52
|
-
from ...stateful.state_machine import APIStateMachine
|
|
53
54
|
from ...transports.content_types import is_json_media_type, parse_content_type
|
|
54
55
|
from ...transports.responses import get_json
|
|
55
|
-
from ...types import Body, Cookies, FormData, Headers, NotSet, PathParameters, Query, GenericTest
|
|
56
56
|
from . import links, serialization
|
|
57
|
+
from ._cache import OperationCache
|
|
57
58
|
from ._hypothesis import get_case_strategy
|
|
58
59
|
from .converter import to_json_schema, to_json_schema_recursive
|
|
59
60
|
from .definitions import OPENAPI_30_VALIDATOR, OPENAPI_31_VALIDATOR, SWAGGER_20_VALIDATOR
|
|
60
61
|
from .examples import get_strategies_from_examples
|
|
61
|
-
from .filters import (
|
|
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
62
|
from .parameters import (
|
|
69
63
|
OpenAPI20Body,
|
|
70
64
|
OpenAPI20CompositeBody,
|
|
@@ -73,12 +67,23 @@ from .parameters import (
|
|
|
73
67
|
OpenAPI30Parameter,
|
|
74
68
|
OpenAPIParameter,
|
|
75
69
|
)
|
|
76
|
-
from .references import
|
|
70
|
+
from .references import (
|
|
71
|
+
RECURSION_DEPTH_LIMIT,
|
|
72
|
+
UNRESOLVABLE,
|
|
73
|
+
ConvertingResolver,
|
|
74
|
+
InliningResolver,
|
|
75
|
+
resolve_pointer,
|
|
76
|
+
)
|
|
77
77
|
from .security import BaseSecurityProcessor, OpenAPISecurityProcessor, SwaggerSecurityProcessor
|
|
78
78
|
from .stateful import create_state_machine
|
|
79
79
|
|
|
80
80
|
if TYPE_CHECKING:
|
|
81
|
+
from hypothesis.strategies import SearchStrategy
|
|
82
|
+
|
|
83
|
+
from ...auths import AuthStorage
|
|
84
|
+
from ...stateful.state_machine import APIStateMachine
|
|
81
85
|
from ...transports.responses import GenericResponse
|
|
86
|
+
from ...types import Body, Cookies, FormData, GenericTest, Headers, NotSet, PathParameters, Query
|
|
82
87
|
|
|
83
88
|
SCHEMA_ERROR_MESSAGE = "Ensure that the definition complies with the OpenAPI specification"
|
|
84
89
|
SCHEMA_PARSING_ERRORS = (KeyError, AttributeError, jsonschema.exceptions.RefResolutionError)
|
|
@@ -90,12 +95,11 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
90
95
|
links_field: ClassVar[str] = ""
|
|
91
96
|
header_required_field: ClassVar[str] = ""
|
|
92
97
|
security: ClassVar[BaseSecurityProcessor] = None # type: ignore
|
|
93
|
-
|
|
98
|
+
_operation_cache: OperationCache = field(default_factory=OperationCache)
|
|
94
99
|
_inline_reference_cache: dict[str, Any] = field(default_factory=dict)
|
|
95
100
|
# Inline references cache can be populated from multiple threads, therefore we need some synchronisation to avoid
|
|
96
101
|
# excessive resolving
|
|
97
102
|
_inline_reference_cache_lock: RLock = field(default_factory=RLock)
|
|
98
|
-
_override: CaseOverride | None = field(default=None)
|
|
99
103
|
component_locations: ClassVar[tuple[tuple[str, ...], ...]] = ()
|
|
100
104
|
|
|
101
105
|
@property
|
|
@@ -113,13 +117,24 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
113
117
|
info = self.raw_schema["info"]
|
|
114
118
|
return f"<{self.__class__.__name__} for {info['title']} {info['version']}>"
|
|
115
119
|
|
|
116
|
-
def
|
|
117
|
-
self,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
+
def __iter__(self) -> Iterator[str]:
|
|
121
|
+
return iter(self.raw_schema.get("paths", {}))
|
|
122
|
+
|
|
123
|
+
def _get_operation_map(self, path: str) -> APIOperationMap:
|
|
124
|
+
cache = self._operation_cache
|
|
125
|
+
map = cache.get_map(path)
|
|
126
|
+
if map is not None:
|
|
127
|
+
return map
|
|
128
|
+
path_item = self.raw_schema.get("paths", {})[path]
|
|
129
|
+
scope, path_item = self._resolve_path_item(path_item)
|
|
130
|
+
self.dispatch_hook("before_process_path", HookContext(), path, path_item)
|
|
131
|
+
map = APIOperationMap(self, {})
|
|
132
|
+
map._data = MethodMap(map, scope, path, CaseInsensitiveDict(path_item))
|
|
133
|
+
cache.insert_map(path, map)
|
|
134
|
+
return map
|
|
120
135
|
|
|
121
136
|
def on_missing_operation(self, item: str, exc: KeyError) -> NoReturn:
|
|
122
|
-
matches = get_close_matches(item, list(self
|
|
137
|
+
matches = get_close_matches(item, list(self))
|
|
123
138
|
self._on_missing_operation(item, exc, matches)
|
|
124
139
|
|
|
125
140
|
def _on_missing_operation(self, item: str, exc: KeyError, matches: list[str]) -> NoReturn:
|
|
@@ -128,14 +143,35 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
128
143
|
message += f". Did you mean `{matches[0]}`?"
|
|
129
144
|
raise OperationNotFound(message=message, item=item) from exc
|
|
130
145
|
|
|
131
|
-
def _should_skip(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
146
|
+
def _should_skip(
|
|
147
|
+
self,
|
|
148
|
+
path: str,
|
|
149
|
+
method: str,
|
|
150
|
+
definition: dict[str, Any],
|
|
151
|
+
_ctx_cache: SimpleNamespace = SimpleNamespace(
|
|
152
|
+
operation=APIOperation(
|
|
153
|
+
method="",
|
|
154
|
+
path="",
|
|
155
|
+
verbose_name="",
|
|
156
|
+
definition=OperationDefinition(raw=None, resolved=None, scope=""),
|
|
157
|
+
schema=None, # type: ignore
|
|
158
|
+
)
|
|
159
|
+
),
|
|
160
|
+
) -> bool:
|
|
161
|
+
if method not in HTTP_METHODS:
|
|
162
|
+
return True
|
|
163
|
+
if self.filter_set.is_empty():
|
|
164
|
+
return False
|
|
165
|
+
path = self.get_full_path(path)
|
|
166
|
+
# Attribute assignment is way faster than creating a new namespace every time
|
|
167
|
+
operation = _ctx_cache.operation
|
|
168
|
+
operation.method = method
|
|
169
|
+
operation.path = path
|
|
170
|
+
operation.verbose_name = f"{method.upper()} {path}"
|
|
171
|
+
operation.definition.raw = definition
|
|
172
|
+
operation.definition.resolved = definition
|
|
173
|
+
operation.schema = self
|
|
174
|
+
return not self.filter_set.match(_ctx_cache)
|
|
139
175
|
|
|
140
176
|
def _operation_iter(self) -> Generator[dict[str, Any], None, None]:
|
|
141
177
|
try:
|
|
@@ -143,18 +179,14 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
143
179
|
except KeyError:
|
|
144
180
|
return
|
|
145
181
|
resolve = self.resolver.resolve
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if should_skip_endpoint(full_path, self.endpoint):
|
|
149
|
-
continue
|
|
182
|
+
should_skip = self._should_skip
|
|
183
|
+
for path, path_item in paths.items():
|
|
150
184
|
try:
|
|
151
|
-
if "$ref" in
|
|
152
|
-
_,
|
|
153
|
-
else:
|
|
154
|
-
resolved_methods = methods
|
|
185
|
+
if "$ref" in path_item:
|
|
186
|
+
_, path_item = resolve(path_item["$ref"])
|
|
155
187
|
# Straightforward iteration is faster than converting to a set & calculating length.
|
|
156
|
-
for method, definition in
|
|
157
|
-
if
|
|
188
|
+
for method, definition in path_item.items():
|
|
189
|
+
if should_skip(path, method, definition):
|
|
158
190
|
continue
|
|
159
191
|
yield definition
|
|
160
192
|
except SCHEMA_PARSING_ERRORS:
|
|
@@ -172,11 +204,13 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
172
204
|
@property
|
|
173
205
|
def links_count(self) -> int:
|
|
174
206
|
total = 0
|
|
207
|
+
resolve = self.resolver.resolve
|
|
208
|
+
links_field = self.links_field
|
|
175
209
|
for definition in self._operation_iter():
|
|
176
210
|
for response in definition.get("responses", {}).values():
|
|
177
211
|
if "$ref" in response:
|
|
178
|
-
_, response =
|
|
179
|
-
defined_links = response.get(
|
|
212
|
+
_, response = resolve(response["$ref"])
|
|
213
|
+
defined_links = response.get(links_field)
|
|
180
214
|
if defined_links is not None:
|
|
181
215
|
total += len(defined_links)
|
|
182
216
|
return total
|
|
@@ -201,8 +235,26 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
201
235
|
|
|
202
236
|
return _add_override
|
|
203
237
|
|
|
238
|
+
def _resolve_until_no_references(self, value: dict[str, Any]) -> dict[str, Any]:
|
|
239
|
+
while "$ref" in value:
|
|
240
|
+
_, value = self.resolver.resolve(value["$ref"])
|
|
241
|
+
return value
|
|
242
|
+
|
|
243
|
+
def _resolve_shared_parameters(self, path_item: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
244
|
+
return self.resolver.resolve_all(path_item.get("parameters", []), RECURSION_DEPTH_LIMIT - 8)
|
|
245
|
+
|
|
246
|
+
def _resolve_operation(self, operation: dict[str, Any]) -> dict[str, Any]:
|
|
247
|
+
return self.resolver.resolve_all(operation, RECURSION_DEPTH_LIMIT - 8)
|
|
248
|
+
|
|
249
|
+
def _collect_operation_parameters(
|
|
250
|
+
self, path_item: Mapping[str, Any], operation: dict[str, Any]
|
|
251
|
+
) -> list[OpenAPIParameter]:
|
|
252
|
+
shared_parameters = self._resolve_shared_parameters(path_item)
|
|
253
|
+
parameters = operation.get("parameters", ())
|
|
254
|
+
return self.collect_parameters(itertools.chain(parameters, shared_parameters), operation)
|
|
255
|
+
|
|
204
256
|
def get_all_operations(
|
|
205
|
-
self, hooks: HookDispatcher | None = None
|
|
257
|
+
self, hooks: HookDispatcher | None = None, generation_config: GenerationConfig | None = None
|
|
206
258
|
) -> Generator[Result[APIOperation, OperationSchemaError], None, None]:
|
|
207
259
|
"""Iterate over all operations defined in the API.
|
|
208
260
|
|
|
@@ -230,47 +282,52 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
230
282
|
self._raise_invalid_schema(exc)
|
|
231
283
|
|
|
232
284
|
context = HookContext()
|
|
233
|
-
|
|
285
|
+
# Optimization: local variables are faster than attribute access
|
|
286
|
+
dispatch_hook = self.dispatch_hook
|
|
287
|
+
resolve_path_item = self._resolve_path_item
|
|
288
|
+
resolve_shared_parameters = self._resolve_shared_parameters
|
|
289
|
+
resolve_operation = self._resolve_operation
|
|
290
|
+
should_skip = self._should_skip
|
|
291
|
+
collect_parameters = self.collect_parameters
|
|
292
|
+
make_operation = self.make_operation
|
|
293
|
+
hooks = self.hooks
|
|
294
|
+
for path, path_item in paths.items():
|
|
234
295
|
method = None
|
|
235
296
|
try:
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
for method, definition in raw_methods.items():
|
|
243
|
-
try:
|
|
244
|
-
# Setting a low recursion limit doesn't solve the problem with recursive references & inlining
|
|
245
|
-
# too much but decreases the number of cases when Schemathesis stuck on this step.
|
|
246
|
-
self.resolver.push_scope(scope)
|
|
247
|
-
try:
|
|
248
|
-
resolved_definition = self.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 8)
|
|
249
|
-
finally:
|
|
250
|
-
self.resolver.pop_scope()
|
|
251
|
-
# Only method definitions are parsed
|
|
252
|
-
if self._should_skip(method, resolved_definition):
|
|
297
|
+
dispatch_hook("before_process_path", context, path, path_item)
|
|
298
|
+
scope, path_item = resolve_path_item(path_item)
|
|
299
|
+
with in_scope(self.resolver, scope):
|
|
300
|
+
shared_parameters = resolve_shared_parameters(path_item)
|
|
301
|
+
for method, entry in path_item.items():
|
|
302
|
+
if method not in HTTP_METHODS:
|
|
253
303
|
continue
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
304
|
+
try:
|
|
305
|
+
resolved = resolve_operation(entry)
|
|
306
|
+
if should_skip(path, method, resolved):
|
|
307
|
+
continue
|
|
308
|
+
parameters = resolved.get("parameters", ())
|
|
309
|
+
parameters = collect_parameters(itertools.chain(parameters, shared_parameters), resolved)
|
|
310
|
+
operation = make_operation(
|
|
311
|
+
path,
|
|
312
|
+
method,
|
|
313
|
+
parameters,
|
|
314
|
+
entry,
|
|
315
|
+
resolved,
|
|
316
|
+
scope,
|
|
317
|
+
with_security_parameters=generation_config.with_security_parameters
|
|
318
|
+
if generation_config
|
|
319
|
+
else None,
|
|
320
|
+
)
|
|
321
|
+
context = HookContext(operation=operation)
|
|
322
|
+
if (
|
|
323
|
+
should_skip_operation(GLOBAL_HOOK_DISPATCHER, context)
|
|
324
|
+
or should_skip_operation(hooks, context)
|
|
325
|
+
or (hooks and should_skip_operation(hooks, context))
|
|
326
|
+
):
|
|
327
|
+
continue
|
|
328
|
+
yield Ok(operation)
|
|
329
|
+
except SCHEMA_PARSING_ERRORS as exc:
|
|
330
|
+
yield self._into_err(exc, path, method)
|
|
274
331
|
except SCHEMA_PARSING_ERRORS as exc:
|
|
275
332
|
yield self._into_err(exc, path, method)
|
|
276
333
|
|
|
@@ -319,20 +376,23 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
319
376
|
"""
|
|
320
377
|
raise NotImplementedError
|
|
321
378
|
|
|
322
|
-
def
|
|
323
|
-
#
|
|
324
|
-
#
|
|
325
|
-
#
|
|
379
|
+
def _resolve_path_item(self, methods: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
380
|
+
# The path item could be behind a reference
|
|
381
|
+
# In this case, we need to resolve it to get the proper scope for reference inside the item.
|
|
382
|
+
# It is mostly for validating responses.
|
|
326
383
|
if "$ref" in methods:
|
|
327
|
-
return
|
|
328
|
-
return self.resolver.resolution_scope,
|
|
384
|
+
return self.resolver.resolve(methods["$ref"])
|
|
385
|
+
return self.resolver.resolution_scope, methods
|
|
329
386
|
|
|
330
387
|
def make_operation(
|
|
331
388
|
self,
|
|
332
389
|
path: str,
|
|
333
390
|
method: str,
|
|
334
391
|
parameters: list[OpenAPIParameter],
|
|
335
|
-
|
|
392
|
+
raw: dict[str, Any],
|
|
393
|
+
resolved: dict[str, Any],
|
|
394
|
+
scope: str,
|
|
395
|
+
with_security_parameters: bool | None = None,
|
|
336
396
|
) -> APIOperation:
|
|
337
397
|
"""Create JSON schemas for the query, body, etc from Swagger parameters definitions."""
|
|
338
398
|
__tracebackhide__ = True
|
|
@@ -340,14 +400,20 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
340
400
|
operation: APIOperation[OpenAPIParameter, Case] = APIOperation(
|
|
341
401
|
path=path,
|
|
342
402
|
method=method,
|
|
343
|
-
definition=
|
|
403
|
+
definition=OperationDefinition(raw, resolved, scope),
|
|
344
404
|
base_url=base_url,
|
|
345
405
|
app=self.app,
|
|
346
406
|
schema=self,
|
|
347
407
|
)
|
|
348
408
|
for parameter in parameters:
|
|
349
409
|
operation.add_parameter(parameter)
|
|
350
|
-
|
|
410
|
+
with_security_parameters = (
|
|
411
|
+
with_security_parameters
|
|
412
|
+
if with_security_parameters is not None
|
|
413
|
+
else self.generation_config.with_security_parameters
|
|
414
|
+
)
|
|
415
|
+
if with_security_parameters:
|
|
416
|
+
self.security.process_definitions(self.raw_schema, operation, self.resolver)
|
|
351
417
|
self.dispatch_hook("before_init_operation", HookContext(operation=operation), operation)
|
|
352
418
|
return operation
|
|
353
419
|
|
|
@@ -361,7 +427,9 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
361
427
|
"""Content types available for this API operation."""
|
|
362
428
|
raise NotImplementedError
|
|
363
429
|
|
|
364
|
-
def get_strategies_from_examples(
|
|
430
|
+
def get_strategies_from_examples(
|
|
431
|
+
self, operation: APIOperation, as_strategy_kwargs: dict[str, Any] | None = None
|
|
432
|
+
) -> list[SearchStrategy[Case]]:
|
|
365
433
|
"""Get examples from the API operation."""
|
|
366
434
|
raise NotImplementedError
|
|
367
435
|
|
|
@@ -375,49 +443,78 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
375
443
|
|
|
376
444
|
def get_operation_by_id(self, operation_id: str) -> APIOperation:
|
|
377
445
|
"""Get an `APIOperation` instance by its `operationId`."""
|
|
378
|
-
|
|
379
|
-
|
|
446
|
+
cache = self._operation_cache
|
|
447
|
+
cached = cache.get_operation_by_id(operation_id)
|
|
448
|
+
if cached is not None:
|
|
449
|
+
return cached
|
|
450
|
+
# Operation has not been accessed yet, need to populate the cache
|
|
451
|
+
if not cache.has_ids_to_definitions:
|
|
452
|
+
self._populate_operation_id_cache(cache)
|
|
380
453
|
try:
|
|
381
|
-
|
|
454
|
+
entry = cache.get_definition_by_id(operation_id)
|
|
382
455
|
except KeyError as exc:
|
|
383
|
-
matches = get_close_matches(operation_id,
|
|
456
|
+
matches = get_close_matches(operation_id, cache.known_operation_ids)
|
|
384
457
|
self._on_missing_operation(operation_id, exc, matches)
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
458
|
+
# It could've been already accessed in a different place
|
|
459
|
+
traversal_key = (entry.scope, entry.path, entry.method)
|
|
460
|
+
instance = cache.get_operation_by_traversal_key(traversal_key)
|
|
461
|
+
if instance is not None:
|
|
462
|
+
return instance
|
|
463
|
+
resolved = self._resolve_operation(entry.operation)
|
|
464
|
+
parameters = self._collect_operation_parameters(entry.path_item, resolved)
|
|
465
|
+
initialized = self.make_operation(entry.path, entry.method, parameters, entry.operation, resolved, entry.scope)
|
|
466
|
+
cache.insert_operation(initialized, traversal_key=traversal_key, operation_id=operation_id)
|
|
467
|
+
return initialized
|
|
468
|
+
|
|
469
|
+
def _populate_operation_id_cache(self, cache: OperationCache) -> None:
|
|
470
|
+
"""Collect all operation IDs from the schema."""
|
|
471
|
+
resolve = self.resolver.resolve
|
|
472
|
+
default_scope = self.resolver.resolution_scope
|
|
473
|
+
for path, path_item in self.raw_schema.get("paths", {}).items():
|
|
474
|
+
# If the path is behind a reference we have to keep the scope
|
|
475
|
+
# The scope is used to resolve nested components later on
|
|
476
|
+
if "$ref" in path_item:
|
|
477
|
+
scope, path_item = resolve(path_item["$ref"])
|
|
478
|
+
else:
|
|
479
|
+
scope = default_scope
|
|
480
|
+
for key, entry in path_item.items():
|
|
481
|
+
if key not in HTTP_METHODS:
|
|
392
482
|
continue
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
yield resolved_definition["operationId"], self.make_operation(path, method, parameters, raw_definition)
|
|
483
|
+
if "operationId" in entry:
|
|
484
|
+
cache.insert_definition_by_id(
|
|
485
|
+
entry["operationId"],
|
|
486
|
+
path=path,
|
|
487
|
+
method=key,
|
|
488
|
+
scope=scope,
|
|
489
|
+
path_item=path_item,
|
|
490
|
+
operation=entry,
|
|
491
|
+
)
|
|
403
492
|
|
|
404
493
|
def get_operation_by_reference(self, reference: str) -> APIOperation:
|
|
405
494
|
"""Get local or external `APIOperation` instance by reference.
|
|
406
495
|
|
|
407
496
|
Reference example: #/paths/~1users~1{user_id}/patch
|
|
408
497
|
"""
|
|
409
|
-
|
|
498
|
+
cache = self._operation_cache
|
|
499
|
+
cached = cache.get_operation_by_reference(reference)
|
|
500
|
+
if cached is not None:
|
|
501
|
+
return cached
|
|
502
|
+
scope, operation = self.resolver.resolve(reference)
|
|
410
503
|
path, method = scope.rsplit("/", maxsplit=2)[-2:]
|
|
411
504
|
path = path.replace("~1", "/").replace("~0", "~")
|
|
412
|
-
|
|
505
|
+
# Check the traversal cache as it could've been populated in other places
|
|
506
|
+
traversal_key = (self.resolver.resolution_scope, path, method)
|
|
507
|
+
cached = cache.get_operation_by_traversal_key(traversal_key)
|
|
508
|
+
if cached is not None:
|
|
509
|
+
return cached
|
|
510
|
+
with in_scope(self.resolver, scope):
|
|
511
|
+
resolved = self._resolve_operation(operation)
|
|
413
512
|
parent_ref, _ = reference.rsplit("/", maxsplit=1)
|
|
414
|
-
_,
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
raw_definition = OperationDefinition(data, resolved_definition, scope, parameters)
|
|
420
|
-
return self.make_operation(path, method, parameters, raw_definition)
|
|
513
|
+
_, path_item = self.resolver.resolve(parent_ref)
|
|
514
|
+
parameters = self._collect_operation_parameters(path_item, resolved)
|
|
515
|
+
initialized = self.make_operation(path, method, parameters, operation, resolved, scope)
|
|
516
|
+
cache.insert_operation(initialized, traversal_key=traversal_key, reference=reference)
|
|
517
|
+
return initialized
|
|
421
518
|
|
|
422
519
|
def get_case_strategy(
|
|
423
520
|
self,
|
|
@@ -438,13 +535,14 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
438
535
|
)
|
|
439
536
|
|
|
440
537
|
def get_parameter_serializer(self, operation: APIOperation, location: str) -> Callable | None:
|
|
441
|
-
definitions = [item for item in operation.
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
538
|
+
definitions = [item.definition for item in operation.iter_parameters() if item.location == location]
|
|
539
|
+
if self.generation_config.with_security_parameters:
|
|
540
|
+
security_parameters = self.security.get_security_definitions_as_parameters(
|
|
541
|
+
self.raw_schema, operation, self.resolver, location
|
|
542
|
+
)
|
|
543
|
+
security_parameters = [item for item in security_parameters if item["in"] == location]
|
|
544
|
+
if security_parameters:
|
|
545
|
+
definitions.extend(security_parameters)
|
|
448
546
|
if definitions:
|
|
449
547
|
return self._get_parameter_serializer(definitions)
|
|
450
548
|
return None
|
|
@@ -452,9 +550,11 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
452
550
|
def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
|
|
453
551
|
raise NotImplementedError
|
|
454
552
|
|
|
455
|
-
def _get_response_definitions(
|
|
553
|
+
def _get_response_definitions(
|
|
554
|
+
self, operation: APIOperation, response: GenericResponse
|
|
555
|
+
) -> tuple[list[str], dict[str, Any]] | None:
|
|
456
556
|
try:
|
|
457
|
-
responses = operation.definition.
|
|
557
|
+
responses = operation.definition.raw["responses"]
|
|
458
558
|
except KeyError as exc:
|
|
459
559
|
# Possible to get if `validate_schema=False` is passed during schema creation
|
|
460
560
|
path = operation.path
|
|
@@ -462,16 +562,19 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
462
562
|
self._raise_invalid_schema(exc, full_path, path, operation.method)
|
|
463
563
|
status_code = str(response.status_code)
|
|
464
564
|
if status_code in responses:
|
|
465
|
-
return responses[status_code]
|
|
565
|
+
return self.resolver.resolve_in_scope(responses[status_code], operation.definition.scope)
|
|
466
566
|
if "default" in responses:
|
|
467
|
-
return responses["default"]
|
|
567
|
+
return self.resolver.resolve_in_scope(responses["default"], operation.definition.scope)
|
|
468
568
|
return None
|
|
469
569
|
|
|
470
|
-
def get_headers(
|
|
471
|
-
|
|
472
|
-
|
|
570
|
+
def get_headers(
|
|
571
|
+
self, operation: APIOperation, response: GenericResponse
|
|
572
|
+
) -> tuple[list[str], dict[str, dict[str, Any]] | None] | None:
|
|
573
|
+
resolved = self._get_response_definitions(operation, response)
|
|
574
|
+
if not resolved:
|
|
473
575
|
return None
|
|
474
|
-
|
|
576
|
+
scopes, definitions = resolved
|
|
577
|
+
return scopes, definitions.get("headers")
|
|
475
578
|
|
|
476
579
|
def as_state_machine(self) -> type[APIStateMachine]:
|
|
477
580
|
try:
|
|
@@ -517,46 +620,16 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
517
620
|
"""
|
|
518
621
|
if parameters is None and request_body is None:
|
|
519
622
|
raise ValueError("You need to provide `parameters` or `request_body`.")
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
found = True
|
|
531
|
-
links.add_link(
|
|
532
|
-
responses=definition["responses"],
|
|
533
|
-
links_field=self.links_field,
|
|
534
|
-
parameters=parameters,
|
|
535
|
-
request_body=request_body,
|
|
536
|
-
status_code=status_code,
|
|
537
|
-
target=target,
|
|
538
|
-
name=name,
|
|
539
|
-
)
|
|
540
|
-
# If methods are behind a reference, then on the next resolving they will miss the new link
|
|
541
|
-
# Therefore we need to modify it this way
|
|
542
|
-
self.raw_schema["paths"][operation][method] = definition
|
|
543
|
-
# The reference should be removed completely, otherwise new keys in this dictionary will be ignored
|
|
544
|
-
# due to the `$ref` keyword behavior
|
|
545
|
-
self.raw_schema["paths"][operation].pop("$ref", None)
|
|
546
|
-
if found:
|
|
547
|
-
return
|
|
548
|
-
name = f"{source.method.upper()} {source.path}"
|
|
549
|
-
# Use a name without basePath, as the user doesn't use it.
|
|
550
|
-
# E.g. `source=schema["/users/"]["POST"]` without a prefix
|
|
551
|
-
message = f"No such API operation: `{name}`."
|
|
552
|
-
possibilities = [
|
|
553
|
-
f"{op.ok().method.upper()} {op.ok().path}" for op in self.get_all_operations() if isinstance(op, Ok)
|
|
554
|
-
]
|
|
555
|
-
matches = get_close_matches(name, possibilities)
|
|
556
|
-
if matches:
|
|
557
|
-
message += f" Did you mean `{matches[0]}`?"
|
|
558
|
-
message += " Check if the requested API operation passes the filters in the schema."
|
|
559
|
-
raise ValueError(message)
|
|
623
|
+
links.add_link(
|
|
624
|
+
resolver=self.resolver,
|
|
625
|
+
responses=self[source.path][source.method].definition.raw["responses"],
|
|
626
|
+
links_field=self.links_field,
|
|
627
|
+
parameters=parameters,
|
|
628
|
+
request_body=request_body,
|
|
629
|
+
status_code=status_code,
|
|
630
|
+
target=target,
|
|
631
|
+
name=name,
|
|
632
|
+
)
|
|
560
633
|
|
|
561
634
|
def get_links(self, operation: APIOperation) -> dict[str, dict[str, Any]]:
|
|
562
635
|
result: dict[str, dict[str, Any]] = defaultdict(dict)
|
|
@@ -565,7 +638,13 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
565
638
|
return result
|
|
566
639
|
|
|
567
640
|
def get_tags(self, operation: APIOperation) -> list[str] | None:
|
|
568
|
-
return operation.definition.
|
|
641
|
+
return operation.definition.raw.get("tags")
|
|
642
|
+
|
|
643
|
+
@property
|
|
644
|
+
def validator_cls(self) -> type[jsonschema.Validator]:
|
|
645
|
+
if self.spec_version.startswith("3.1") and experimental.OPEN_API_3_1.is_enabled:
|
|
646
|
+
return jsonschema.Draft202012Validator
|
|
647
|
+
return jsonschema.Draft4Validator
|
|
569
648
|
|
|
570
649
|
def validate_response(self, operation: APIOperation, response: GenericResponse) -> bool | None:
|
|
571
650
|
responses = {str(key): value for key, value in operation.definition.raw.get("responses", {}).items()}
|
|
@@ -588,7 +667,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
588
667
|
formatted_content_types = [f"\n- `{content_type}`" for content_type in media_types]
|
|
589
668
|
message = f"The following media types are documented in the schema:{''.join(formatted_content_types)}"
|
|
590
669
|
try:
|
|
591
|
-
raise get_missing_content_type_error()(
|
|
670
|
+
raise get_missing_content_type_error(operation.verbose_name)(
|
|
592
671
|
failures.MissingContentType.title,
|
|
593
672
|
context=failures.MissingContentType(message=message, media_types=media_types),
|
|
594
673
|
)
|
|
@@ -600,26 +679,26 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
600
679
|
try:
|
|
601
680
|
data = get_json(response)
|
|
602
681
|
except JSONDecodeError as exc:
|
|
603
|
-
exc_class = get_response_parsing_error(exc)
|
|
682
|
+
exc_class = get_response_parsing_error(operation.verbose_name, exc)
|
|
604
683
|
context = failures.JSONDecodeErrorContext.from_exception(exc)
|
|
605
684
|
try:
|
|
606
685
|
raise exc_class(context.title, context=context) from exc
|
|
607
686
|
except Exception as exc:
|
|
608
687
|
errors.append(exc)
|
|
609
688
|
_maybe_raise_one_or_more(errors)
|
|
610
|
-
|
|
611
|
-
self.location or "", self.raw_schema, nullable_name=self.nullable_name, is_response_schema=True
|
|
612
|
-
)
|
|
613
|
-
if self.spec_version.startswith("3.1") and experimental.OPEN_API_3_1.is_enabled:
|
|
614
|
-
cls = jsonschema.Draft202012Validator
|
|
615
|
-
else:
|
|
616
|
-
cls = jsonschema.Draft4Validator
|
|
617
|
-
with in_scopes(resolver, scopes):
|
|
689
|
+
with self._validating_response(scopes) as resolver:
|
|
618
690
|
try:
|
|
619
|
-
jsonschema.validate(
|
|
691
|
+
jsonschema.validate(
|
|
692
|
+
data,
|
|
693
|
+
schema,
|
|
694
|
+
cls=self.validator_cls,
|
|
695
|
+
resolver=resolver,
|
|
696
|
+
# Use a recent JSON Schema format checker to get most of formats checked for older drafts as well
|
|
697
|
+
format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER,
|
|
698
|
+
)
|
|
620
699
|
except jsonschema.ValidationError as exc:
|
|
621
|
-
exc_class = get_schema_validation_error(exc)
|
|
622
|
-
ctx = failures.ValidationErrorContext.from_exception(exc)
|
|
700
|
+
exc_class = get_schema_validation_error(operation.verbose_name, exc)
|
|
701
|
+
ctx = failures.ValidationErrorContext.from_exception(exc, output_config=operation.schema.output_config)
|
|
623
702
|
try:
|
|
624
703
|
raise exc_class(ctx.title, context=ctx) from exc
|
|
625
704
|
except Exception as exc:
|
|
@@ -627,6 +706,14 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
627
706
|
_maybe_raise_one_or_more(errors)
|
|
628
707
|
return None # explicitly return None for mypy
|
|
629
708
|
|
|
709
|
+
@contextmanager
|
|
710
|
+
def _validating_response(self, scopes: list[str]) -> Generator[ConvertingResolver, None, None]:
|
|
711
|
+
resolver = ConvertingResolver(
|
|
712
|
+
self.location or "", self.raw_schema, nullable_name=self.nullable_name, is_response_schema=True
|
|
713
|
+
)
|
|
714
|
+
with in_scopes(resolver, scopes):
|
|
715
|
+
yield resolver
|
|
716
|
+
|
|
630
717
|
@property
|
|
631
718
|
def rewritten_components(self) -> dict[str, Any]:
|
|
632
719
|
if not hasattr(self, "_rewritten_components"):
|
|
@@ -720,10 +807,9 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
720
807
|
def _maybe_raise_one_or_more(errors: list[Exception]) -> None:
|
|
721
808
|
if not errors:
|
|
722
809
|
return
|
|
723
|
-
|
|
810
|
+
if len(errors) == 1:
|
|
724
811
|
raise errors[0]
|
|
725
|
-
|
|
726
|
-
raise MultipleFailures("\n\n".join(str(error) for error in errors), errors)
|
|
812
|
+
raise MultipleFailures("\n\n".join(str(error) for error in errors), errors)
|
|
727
813
|
|
|
728
814
|
|
|
729
815
|
def _make_reference_key(scopes: list[str], reference: str) -> str:
|
|
@@ -765,30 +851,58 @@ def in_scopes(resolver: jsonschema.RefResolver, scopes: list[str]) -> Generator[
|
|
|
765
851
|
yield
|
|
766
852
|
|
|
767
853
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
) -> dict[str, APIOperationMap]:
|
|
771
|
-
output: dict[str, APIOperationMap] = {}
|
|
772
|
-
for result in operations:
|
|
773
|
-
if isinstance(result, Ok):
|
|
774
|
-
operation = result.ok()
|
|
775
|
-
output.setdefault(operation.path, APIOperationMap(MethodMap()))
|
|
776
|
-
output[operation.path][operation.method] = operation
|
|
777
|
-
return output
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
class MethodMap(CaseInsensitiveDict):
|
|
854
|
+
@dataclass
|
|
855
|
+
class MethodMap(Mapping):
|
|
781
856
|
"""Container for accessing API operations.
|
|
782
857
|
|
|
783
858
|
Provides a more specific error message if API operation is not found.
|
|
784
859
|
"""
|
|
785
860
|
|
|
861
|
+
_parent: APIOperationMap
|
|
862
|
+
# Reference resolution scope
|
|
863
|
+
_scope: str
|
|
864
|
+
# Methods are stored for this path
|
|
865
|
+
_path: str
|
|
866
|
+
# Storage for definitions
|
|
867
|
+
_path_item: CaseInsensitiveDict
|
|
868
|
+
|
|
869
|
+
__slots__ = ("_parent", "_scope", "_path", "_path_item")
|
|
870
|
+
|
|
871
|
+
def __len__(self) -> int:
|
|
872
|
+
return len(self._path_item)
|
|
873
|
+
|
|
874
|
+
def __iter__(self) -> Iterator[str]:
|
|
875
|
+
return iter(self._path_item)
|
|
876
|
+
|
|
877
|
+
def _init_operation(self, method: str) -> APIOperation:
|
|
878
|
+
method = method.lower()
|
|
879
|
+
operation = self._path_item[method]
|
|
880
|
+
schema = cast(BaseOpenAPISchema, self._parent._schema)
|
|
881
|
+
cache = schema._operation_cache
|
|
882
|
+
path = self._path
|
|
883
|
+
scope = self._scope
|
|
884
|
+
traversal_key = (scope, path, method)
|
|
885
|
+
cached = cache.get_operation_by_traversal_key(traversal_key)
|
|
886
|
+
if cached is not None:
|
|
887
|
+
return cached
|
|
888
|
+
schema.resolver.push_scope(scope)
|
|
889
|
+
try:
|
|
890
|
+
resolved = schema._resolve_operation(operation)
|
|
891
|
+
finally:
|
|
892
|
+
schema.resolver.pop_scope()
|
|
893
|
+
parameters = schema._collect_operation_parameters(self._path_item, resolved)
|
|
894
|
+
initialized = schema.make_operation(path, method, parameters, operation, resolved, scope)
|
|
895
|
+
cache.insert_operation(initialized, traversal_key=traversal_key, operation_id=resolved.get("operationId"))
|
|
896
|
+
return initialized
|
|
897
|
+
|
|
786
898
|
def __getitem__(self, item: str) -> APIOperation:
|
|
787
899
|
try:
|
|
788
|
-
return
|
|
900
|
+
return self._init_operation(item)
|
|
789
901
|
except KeyError as exc:
|
|
790
902
|
available_methods = ", ".join(map(str.upper, self))
|
|
791
|
-
message = f"Method `{item}` not found.
|
|
903
|
+
message = f"Method `{item.upper()}` not found."
|
|
904
|
+
if available_methods:
|
|
905
|
+
message += f" Available methods: {available_methods}"
|
|
792
906
|
raise KeyError(message) from exc
|
|
793
907
|
|
|
794
908
|
|
|
@@ -808,7 +922,7 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
808
922
|
|
|
809
923
|
@property
|
|
810
924
|
def spec_version(self) -> str:
|
|
811
|
-
return self.raw_schema
|
|
925
|
+
return self.raw_schema.get("swagger", "2.0")
|
|
812
926
|
|
|
813
927
|
@property
|
|
814
928
|
def verbose_name(self) -> str:
|
|
@@ -858,18 +972,22 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
858
972
|
)
|
|
859
973
|
return collected
|
|
860
974
|
|
|
861
|
-
def get_strategies_from_examples(
|
|
975
|
+
def get_strategies_from_examples(
|
|
976
|
+
self, operation: APIOperation, as_strategy_kwargs: dict[str, Any] | None = None
|
|
977
|
+
) -> list[SearchStrategy[Case]]:
|
|
862
978
|
"""Get examples from the API operation."""
|
|
863
|
-
return get_strategies_from_examples(operation,
|
|
979
|
+
return get_strategies_from_examples(operation, as_strategy_kwargs=as_strategy_kwargs)
|
|
864
980
|
|
|
865
981
|
def get_response_schema(self, definition: dict[str, Any], scope: str) -> tuple[list[str], dict[str, Any] | None]:
|
|
866
|
-
scopes, definition = self.resolver.resolve_in_scope(
|
|
982
|
+
scopes, definition = self.resolver.resolve_in_scope(definition, scope)
|
|
867
983
|
schema = definition.get("schema")
|
|
868
984
|
if not schema:
|
|
869
985
|
return scopes, None
|
|
870
986
|
# Extra conversion to JSON Schema is needed here if there was one $ref in the input
|
|
871
987
|
# because it is not converted
|
|
872
|
-
return scopes, to_json_schema_recursive(
|
|
988
|
+
return scopes, to_json_schema_recursive(
|
|
989
|
+
schema, self.nullable_name, is_response_schema=True, update_quantifiers=False
|
|
990
|
+
)
|
|
873
991
|
|
|
874
992
|
def get_content_types(self, operation: APIOperation, response: GenericResponse) -> list[str]:
|
|
875
993
|
produces = operation.definition.raw.get("produces", None)
|
|
@@ -902,7 +1020,7 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
902
1020
|
else:
|
|
903
1021
|
files.append((name, file_value))
|
|
904
1022
|
|
|
905
|
-
for parameter in operation.
|
|
1023
|
+
for parameter in operation.body:
|
|
906
1024
|
if isinstance(parameter, OpenAPI20CompositeBody):
|
|
907
1025
|
for form_parameter in parameter.definition:
|
|
908
1026
|
name = form_parameter.name
|
|
@@ -917,7 +1035,7 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
917
1035
|
return files or None, data or None
|
|
918
1036
|
|
|
919
1037
|
def get_request_payload_content_types(self, operation: APIOperation) -> list[str]:
|
|
920
|
-
return self._get_consumes_for_operation(operation.definition.
|
|
1038
|
+
return self._get_consumes_for_operation(operation.definition.raw)
|
|
921
1039
|
|
|
922
1040
|
def make_case(
|
|
923
1041
|
self,
|
|
@@ -930,20 +1048,10 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
930
1048
|
query: Query | None = None,
|
|
931
1049
|
body: Body | NotSet = NOT_SET,
|
|
932
1050
|
media_type: str | None = None,
|
|
1051
|
+
generation_time: float = 0.0,
|
|
933
1052
|
) -> C:
|
|
934
1053
|
if body is not NOT_SET and media_type is None:
|
|
935
|
-
|
|
936
|
-
media_types = operation.get_request_payload_content_types()
|
|
937
|
-
if len(media_types) == 1:
|
|
938
|
-
# The only available option
|
|
939
|
-
media_type = media_types[0]
|
|
940
|
-
else:
|
|
941
|
-
media_types_repr = ", ".join(media_types)
|
|
942
|
-
raise UsageError(
|
|
943
|
-
"Can not detect appropriate media type. "
|
|
944
|
-
"You can either specify one of the defined media types "
|
|
945
|
-
f"or pass any other media type available for serialization. Defined media types: {media_types_repr}"
|
|
946
|
-
)
|
|
1054
|
+
media_type = operation._get_default_media_type()
|
|
947
1055
|
return case_cls(
|
|
948
1056
|
operation=operation,
|
|
949
1057
|
path_parameters=path_parameters,
|
|
@@ -952,6 +1060,7 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
952
1060
|
query=query,
|
|
953
1061
|
body=body,
|
|
954
1062
|
media_type=media_type,
|
|
1063
|
+
generation_time=generation_time,
|
|
955
1064
|
)
|
|
956
1065
|
|
|
957
1066
|
def _get_consumes_for_operation(self, definition: dict[str, Any]) -> list[str]:
|
|
@@ -1022,31 +1131,37 @@ class OpenApi30(SwaggerV20):
|
|
|
1022
1131
|
return collected
|
|
1023
1132
|
|
|
1024
1133
|
def get_response_schema(self, definition: dict[str, Any], scope: str) -> tuple[list[str], dict[str, Any] | None]:
|
|
1025
|
-
scopes, definition = self.resolver.resolve_in_scope(
|
|
1134
|
+
scopes, definition = self.resolver.resolve_in_scope(definition, scope)
|
|
1026
1135
|
options = iter(definition.get("content", {}).values())
|
|
1027
1136
|
option = next(options, None)
|
|
1028
1137
|
# "schema" is an optional key in the `MediaType` object
|
|
1029
1138
|
if option and "schema" in option:
|
|
1030
1139
|
# Extra conversion to JSON Schema is needed here if there was one $ref in the input
|
|
1031
1140
|
# because it is not converted
|
|
1032
|
-
return scopes, to_json_schema_recursive(
|
|
1141
|
+
return scopes, to_json_schema_recursive(
|
|
1142
|
+
option["schema"], self.nullable_name, is_response_schema=True, update_quantifiers=False
|
|
1143
|
+
)
|
|
1033
1144
|
return scopes, None
|
|
1034
1145
|
|
|
1035
|
-
def get_strategies_from_examples(
|
|
1146
|
+
def get_strategies_from_examples(
|
|
1147
|
+
self, operation: APIOperation, as_strategy_kwargs: dict[str, Any] | None = None
|
|
1148
|
+
) -> list[SearchStrategy[Case]]:
|
|
1036
1149
|
"""Get examples from the API operation."""
|
|
1037
|
-
return get_strategies_from_examples(operation,
|
|
1150
|
+
return get_strategies_from_examples(operation, as_strategy_kwargs=as_strategy_kwargs)
|
|
1038
1151
|
|
|
1039
1152
|
def get_content_types(self, operation: APIOperation, response: GenericResponse) -> list[str]:
|
|
1040
|
-
|
|
1041
|
-
if not
|
|
1153
|
+
resolved = self._get_response_definitions(operation, response)
|
|
1154
|
+
if not resolved:
|
|
1042
1155
|
return []
|
|
1156
|
+
_, definitions = resolved
|
|
1043
1157
|
return list(definitions.get("content", {}).keys())
|
|
1044
1158
|
|
|
1045
1159
|
def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
|
|
1046
1160
|
return serialization.serialize_openapi3_parameters(definitions)
|
|
1047
1161
|
|
|
1048
1162
|
def get_request_payload_content_types(self, operation: APIOperation) -> list[str]:
|
|
1049
|
-
|
|
1163
|
+
request_body = self._resolve_until_no_references(operation.definition.raw["requestBody"])
|
|
1164
|
+
return list(request_body["content"])
|
|
1050
1165
|
|
|
1051
1166
|
def prepare_multipart(
|
|
1052
1167
|
self, form_data: FormData, operation: APIOperation
|
|
@@ -1058,11 +1173,22 @@ class OpenApi30(SwaggerV20):
|
|
|
1058
1173
|
:return: `files` and `data` values for `requests.request`.
|
|
1059
1174
|
"""
|
|
1060
1175
|
files = []
|
|
1061
|
-
|
|
1176
|
+
definition = operation.definition.raw
|
|
1177
|
+
if "$ref" in definition["requestBody"]:
|
|
1178
|
+
body = self.resolver.resolve_all(definition["requestBody"], RECURSION_DEPTH_LIMIT)
|
|
1179
|
+
else:
|
|
1180
|
+
body = definition["requestBody"]
|
|
1181
|
+
content = body["content"]
|
|
1062
1182
|
# Open API 3.0 requires media types to be present. We can get here only if the schema defines
|
|
1063
|
-
# the "multipart/form-data" media type
|
|
1064
|
-
|
|
1065
|
-
|
|
1183
|
+
# the "multipart/form-data" media type, or any other more general media type that matches it (like `*/*`)
|
|
1184
|
+
for media_type, entry in content.items():
|
|
1185
|
+
main, sub = parse_content_type(media_type)
|
|
1186
|
+
if main in ("*", "multipart") and sub in ("*", "form-data", "mixed"):
|
|
1187
|
+
schema = entry.get("schema")
|
|
1188
|
+
break
|
|
1189
|
+
else:
|
|
1190
|
+
raise InternalError("No 'multipart/form-data' media type found in the schema")
|
|
1191
|
+
for name, property_schema in (schema or {}).get("properties", {}).items():
|
|
1066
1192
|
if name in form_data:
|
|
1067
1193
|
if isinstance(form_data[name], list):
|
|
1068
1194
|
files.extend([(name, item) for item in form_data[name]])
|