schemathesis 3.25.6__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 +783 -432
- 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 +22 -5
- 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 +258 -112
- schemathesis/cli/output/short.py +23 -8
- 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 +318 -211
- schemathesis/parameters.py +4 -0
- schemathesis/runner/__init__.py +50 -15
- schemathesis/runner/events.py +65 -5
- schemathesis/runner/impl/context.py +104 -0
- schemathesis/runner/impl/core.py +388 -177
- schemathesis/runner/impl/solo.py +19 -29
- schemathesis/runner/impl/threadpool.py +70 -79
- schemathesis/runner/probes.py +11 -9
- schemathesis/runner/serialization.py +150 -17
- schemathesis/sanitization.py +5 -1
- schemathesis/schemas.py +170 -102
- schemathesis/serializers.py +7 -2
- 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 +45 -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 +78 -60
- 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 +126 -12
- 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 +360 -241
- 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.6.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.6.dist-info/METADATA +0 -356
- schemathesis-3.25.6.dist-info/RECORD +0 -134
- {schemathesis-3.25.6.dist-info → schemathesis-3.39.7.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.25.6.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,57 +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 (
|
|
36
37
|
InternalError,
|
|
38
|
+
OperationNotFound,
|
|
37
39
|
OperationSchemaError,
|
|
38
|
-
|
|
40
|
+
SchemaError,
|
|
41
|
+
SchemaErrorType,
|
|
39
42
|
get_missing_content_type_error,
|
|
40
43
|
get_response_parsing_error,
|
|
41
44
|
get_schema_validation_error,
|
|
42
|
-
SchemaError,
|
|
43
|
-
SchemaErrorType,
|
|
44
|
-
OperationNotFound,
|
|
45
45
|
)
|
|
46
|
+
from ...generation import DataGenerationMethod, GenerationConfig
|
|
46
47
|
from ...hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, should_skip_operation
|
|
47
48
|
from ...internal.copy import fast_deepcopy
|
|
48
49
|
from ...internal.jsonschema import traverse_schema
|
|
49
50
|
from ...internal.result import Err, Ok, Result
|
|
50
51
|
from ...models import APIOperation, Case, OperationDefinition
|
|
51
|
-
from ...schemas import
|
|
52
|
+
from ...schemas import APIOperationMap, BaseSchema
|
|
52
53
|
from ...stateful import Stateful, StatefulTest
|
|
53
|
-
from ...stateful.state_machine import APIStateMachine
|
|
54
54
|
from ...transports.content_types import is_json_media_type, parse_content_type
|
|
55
55
|
from ...transports.responses import get_json
|
|
56
|
-
from ...types import Body, Cookies, FormData, Headers, NotSet, PathParameters, Query, GenericTest
|
|
57
56
|
from . import links, serialization
|
|
57
|
+
from ._cache import OperationCache
|
|
58
58
|
from ._hypothesis import get_case_strategy
|
|
59
59
|
from .converter import to_json_schema, to_json_schema_recursive
|
|
60
60
|
from .definitions import OPENAPI_30_VALIDATOR, OPENAPI_31_VALIDATOR, SWAGGER_20_VALIDATOR
|
|
61
61
|
from .examples import get_strategies_from_examples
|
|
62
|
-
from .filters import (
|
|
63
|
-
should_skip_by_operation_id,
|
|
64
|
-
should_skip_by_tag,
|
|
65
|
-
should_skip_deprecated,
|
|
66
|
-
should_skip_endpoint,
|
|
67
|
-
should_skip_method,
|
|
68
|
-
)
|
|
69
62
|
from .parameters import (
|
|
70
63
|
OpenAPI20Body,
|
|
71
64
|
OpenAPI20CompositeBody,
|
|
@@ -74,12 +67,23 @@ from .parameters import (
|
|
|
74
67
|
OpenAPI30Parameter,
|
|
75
68
|
OpenAPIParameter,
|
|
76
69
|
)
|
|
77
|
-
from .references import
|
|
70
|
+
from .references import (
|
|
71
|
+
RECURSION_DEPTH_LIMIT,
|
|
72
|
+
UNRESOLVABLE,
|
|
73
|
+
ConvertingResolver,
|
|
74
|
+
InliningResolver,
|
|
75
|
+
resolve_pointer,
|
|
76
|
+
)
|
|
78
77
|
from .security import BaseSecurityProcessor, OpenAPISecurityProcessor, SwaggerSecurityProcessor
|
|
79
78
|
from .stateful import create_state_machine
|
|
80
79
|
|
|
81
80
|
if TYPE_CHECKING:
|
|
81
|
+
from hypothesis.strategies import SearchStrategy
|
|
82
|
+
|
|
83
|
+
from ...auths import AuthStorage
|
|
84
|
+
from ...stateful.state_machine import APIStateMachine
|
|
82
85
|
from ...transports.responses import GenericResponse
|
|
86
|
+
from ...types import Body, Cookies, FormData, GenericTest, Headers, NotSet, PathParameters, Query
|
|
83
87
|
|
|
84
88
|
SCHEMA_ERROR_MESSAGE = "Ensure that the definition complies with the OpenAPI specification"
|
|
85
89
|
SCHEMA_PARSING_ERRORS = (KeyError, AttributeError, jsonschema.exceptions.RefResolutionError)
|
|
@@ -91,12 +95,11 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
91
95
|
links_field: ClassVar[str] = ""
|
|
92
96
|
header_required_field: ClassVar[str] = ""
|
|
93
97
|
security: ClassVar[BaseSecurityProcessor] = None # type: ignore
|
|
94
|
-
|
|
98
|
+
_operation_cache: OperationCache = field(default_factory=OperationCache)
|
|
95
99
|
_inline_reference_cache: dict[str, Any] = field(default_factory=dict)
|
|
96
100
|
# Inline references cache can be populated from multiple threads, therefore we need some synchronisation to avoid
|
|
97
101
|
# excessive resolving
|
|
98
102
|
_inline_reference_cache_lock: RLock = field(default_factory=RLock)
|
|
99
|
-
_override: CaseOverride | None = field(default=None)
|
|
100
103
|
component_locations: ClassVar[tuple[tuple[str, ...], ...]] = ()
|
|
101
104
|
|
|
102
105
|
@property
|
|
@@ -114,13 +117,24 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
114
117
|
info = self.raw_schema["info"]
|
|
115
118
|
return f"<{self.__class__.__name__} for {info['title']} {info['version']}>"
|
|
116
119
|
|
|
117
|
-
def
|
|
118
|
-
self,
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
121
135
|
|
|
122
136
|
def on_missing_operation(self, item: str, exc: KeyError) -> NoReturn:
|
|
123
|
-
matches = get_close_matches(item, list(self
|
|
137
|
+
matches = get_close_matches(item, list(self))
|
|
124
138
|
self._on_missing_operation(item, exc, matches)
|
|
125
139
|
|
|
126
140
|
def _on_missing_operation(self, item: str, exc: KeyError, matches: list[str]) -> NoReturn:
|
|
@@ -129,14 +143,35 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
129
143
|
message += f". Did you mean `{matches[0]}`?"
|
|
130
144
|
raise OperationNotFound(message=message, item=item) from exc
|
|
131
145
|
|
|
132
|
-
def _should_skip(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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)
|
|
140
175
|
|
|
141
176
|
def _operation_iter(self) -> Generator[dict[str, Any], None, None]:
|
|
142
177
|
try:
|
|
@@ -144,18 +179,14 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
144
179
|
except KeyError:
|
|
145
180
|
return
|
|
146
181
|
resolve = self.resolver.resolve
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if should_skip_endpoint(full_path, self.endpoint):
|
|
150
|
-
continue
|
|
182
|
+
should_skip = self._should_skip
|
|
183
|
+
for path, path_item in paths.items():
|
|
151
184
|
try:
|
|
152
|
-
if "$ref" in
|
|
153
|
-
_,
|
|
154
|
-
else:
|
|
155
|
-
resolved_methods = methods
|
|
185
|
+
if "$ref" in path_item:
|
|
186
|
+
_, path_item = resolve(path_item["$ref"])
|
|
156
187
|
# Straightforward iteration is faster than converting to a set & calculating length.
|
|
157
|
-
for method, definition in
|
|
158
|
-
if
|
|
188
|
+
for method, definition in path_item.items():
|
|
189
|
+
if should_skip(path, method, definition):
|
|
159
190
|
continue
|
|
160
191
|
yield definition
|
|
161
192
|
except SCHEMA_PARSING_ERRORS:
|
|
@@ -173,11 +204,13 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
173
204
|
@property
|
|
174
205
|
def links_count(self) -> int:
|
|
175
206
|
total = 0
|
|
207
|
+
resolve = self.resolver.resolve
|
|
208
|
+
links_field = self.links_field
|
|
176
209
|
for definition in self._operation_iter():
|
|
177
210
|
for response in definition.get("responses", {}).values():
|
|
178
211
|
if "$ref" in response:
|
|
179
|
-
_, response =
|
|
180
|
-
defined_links = response.get(
|
|
212
|
+
_, response = resolve(response["$ref"])
|
|
213
|
+
defined_links = response.get(links_field)
|
|
181
214
|
if defined_links is not None:
|
|
182
215
|
total += len(defined_links)
|
|
183
216
|
return total
|
|
@@ -202,8 +235,26 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
202
235
|
|
|
203
236
|
return _add_override
|
|
204
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
|
+
|
|
205
256
|
def get_all_operations(
|
|
206
|
-
self, hooks: HookDispatcher | None = None
|
|
257
|
+
self, hooks: HookDispatcher | None = None, generation_config: GenerationConfig | None = None
|
|
207
258
|
) -> Generator[Result[APIOperation, OperationSchemaError], None, None]:
|
|
208
259
|
"""Iterate over all operations defined in the API.
|
|
209
260
|
|
|
@@ -231,47 +282,52 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
231
282
|
self._raise_invalid_schema(exc)
|
|
232
283
|
|
|
233
284
|
context = HookContext()
|
|
234
|
-
|
|
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():
|
|
235
295
|
method = None
|
|
236
296
|
try:
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
for method, definition in raw_methods.items():
|
|
244
|
-
try:
|
|
245
|
-
# Setting a low recursion limit doesn't solve the problem with recursive references & inlining
|
|
246
|
-
# too much but decreases the number of cases when Schemathesis stuck on this step.
|
|
247
|
-
self.resolver.push_scope(scope)
|
|
248
|
-
try:
|
|
249
|
-
resolved_definition = self.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 8)
|
|
250
|
-
finally:
|
|
251
|
-
self.resolver.pop_scope()
|
|
252
|
-
# Only method definitions are parsed
|
|
253
|
-
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:
|
|
254
303
|
continue
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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)
|
|
275
331
|
except SCHEMA_PARSING_ERRORS as exc:
|
|
276
332
|
yield self._into_err(exc, path, method)
|
|
277
333
|
|
|
@@ -320,20 +376,23 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
320
376
|
"""
|
|
321
377
|
raise NotImplementedError
|
|
322
378
|
|
|
323
|
-
def
|
|
324
|
-
#
|
|
325
|
-
#
|
|
326
|
-
#
|
|
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.
|
|
327
383
|
if "$ref" in methods:
|
|
328
|
-
return
|
|
329
|
-
return self.resolver.resolution_scope,
|
|
384
|
+
return self.resolver.resolve(methods["$ref"])
|
|
385
|
+
return self.resolver.resolution_scope, methods
|
|
330
386
|
|
|
331
387
|
def make_operation(
|
|
332
388
|
self,
|
|
333
389
|
path: str,
|
|
334
390
|
method: str,
|
|
335
391
|
parameters: list[OpenAPIParameter],
|
|
336
|
-
|
|
392
|
+
raw: dict[str, Any],
|
|
393
|
+
resolved: dict[str, Any],
|
|
394
|
+
scope: str,
|
|
395
|
+
with_security_parameters: bool | None = None,
|
|
337
396
|
) -> APIOperation:
|
|
338
397
|
"""Create JSON schemas for the query, body, etc from Swagger parameters definitions."""
|
|
339
398
|
__tracebackhide__ = True
|
|
@@ -341,14 +400,20 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
341
400
|
operation: APIOperation[OpenAPIParameter, Case] = APIOperation(
|
|
342
401
|
path=path,
|
|
343
402
|
method=method,
|
|
344
|
-
definition=
|
|
403
|
+
definition=OperationDefinition(raw, resolved, scope),
|
|
345
404
|
base_url=base_url,
|
|
346
405
|
app=self.app,
|
|
347
406
|
schema=self,
|
|
348
407
|
)
|
|
349
408
|
for parameter in parameters:
|
|
350
409
|
operation.add_parameter(parameter)
|
|
351
|
-
|
|
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)
|
|
352
417
|
self.dispatch_hook("before_init_operation", HookContext(operation=operation), operation)
|
|
353
418
|
return operation
|
|
354
419
|
|
|
@@ -362,7 +427,9 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
362
427
|
"""Content types available for this API operation."""
|
|
363
428
|
raise NotImplementedError
|
|
364
429
|
|
|
365
|
-
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]]:
|
|
366
433
|
"""Get examples from the API operation."""
|
|
367
434
|
raise NotImplementedError
|
|
368
435
|
|
|
@@ -376,49 +443,78 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
376
443
|
|
|
377
444
|
def get_operation_by_id(self, operation_id: str) -> APIOperation:
|
|
378
445
|
"""Get an `APIOperation` instance by its `operationId`."""
|
|
379
|
-
|
|
380
|
-
|
|
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)
|
|
381
453
|
try:
|
|
382
|
-
|
|
454
|
+
entry = cache.get_definition_by_id(operation_id)
|
|
383
455
|
except KeyError as exc:
|
|
384
|
-
matches = get_close_matches(operation_id,
|
|
456
|
+
matches = get_close_matches(operation_id, cache.known_operation_ids)
|
|
385
457
|
self._on_missing_operation(operation_id, exc, matches)
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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:
|
|
393
482
|
continue
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
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
|
+
)
|
|
404
492
|
|
|
405
493
|
def get_operation_by_reference(self, reference: str) -> APIOperation:
|
|
406
494
|
"""Get local or external `APIOperation` instance by reference.
|
|
407
495
|
|
|
408
496
|
Reference example: #/paths/~1users~1{user_id}/patch
|
|
409
497
|
"""
|
|
410
|
-
|
|
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)
|
|
411
503
|
path, method = scope.rsplit("/", maxsplit=2)[-2:]
|
|
412
504
|
path = path.replace("~1", "/").replace("~0", "~")
|
|
413
|
-
|
|
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)
|
|
414
512
|
parent_ref, _ = reference.rsplit("/", maxsplit=1)
|
|
415
|
-
_,
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
raw_definition = OperationDefinition(data, resolved_definition, scope, parameters)
|
|
421
|
-
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
|
|
422
518
|
|
|
423
519
|
def get_case_strategy(
|
|
424
520
|
self,
|
|
@@ -439,13 +535,14 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
439
535
|
)
|
|
440
536
|
|
|
441
537
|
def get_parameter_serializer(self, operation: APIOperation, location: str) -> Callable | None:
|
|
442
|
-
definitions = [item for item in operation.
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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)
|
|
449
546
|
if definitions:
|
|
450
547
|
return self._get_parameter_serializer(definitions)
|
|
451
548
|
return None
|
|
@@ -453,9 +550,11 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
453
550
|
def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
|
|
454
551
|
raise NotImplementedError
|
|
455
552
|
|
|
456
|
-
def _get_response_definitions(
|
|
553
|
+
def _get_response_definitions(
|
|
554
|
+
self, operation: APIOperation, response: GenericResponse
|
|
555
|
+
) -> tuple[list[str], dict[str, Any]] | None:
|
|
457
556
|
try:
|
|
458
|
-
responses = operation.definition.
|
|
557
|
+
responses = operation.definition.raw["responses"]
|
|
459
558
|
except KeyError as exc:
|
|
460
559
|
# Possible to get if `validate_schema=False` is passed during schema creation
|
|
461
560
|
path = operation.path
|
|
@@ -463,16 +562,19 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
463
562
|
self._raise_invalid_schema(exc, full_path, path, operation.method)
|
|
464
563
|
status_code = str(response.status_code)
|
|
465
564
|
if status_code in responses:
|
|
466
|
-
return responses[status_code]
|
|
565
|
+
return self.resolver.resolve_in_scope(responses[status_code], operation.definition.scope)
|
|
467
566
|
if "default" in responses:
|
|
468
|
-
return responses["default"]
|
|
567
|
+
return self.resolver.resolve_in_scope(responses["default"], operation.definition.scope)
|
|
469
568
|
return None
|
|
470
569
|
|
|
471
|
-
def get_headers(
|
|
472
|
-
|
|
473
|
-
|
|
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:
|
|
474
575
|
return None
|
|
475
|
-
|
|
576
|
+
scopes, definitions = resolved
|
|
577
|
+
return scopes, definitions.get("headers")
|
|
476
578
|
|
|
477
579
|
def as_state_machine(self) -> type[APIStateMachine]:
|
|
478
580
|
try:
|
|
@@ -518,46 +620,16 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
518
620
|
"""
|
|
519
621
|
if parameters is None and request_body is None:
|
|
520
622
|
raise ValueError("You need to provide `parameters` or `request_body`.")
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
found = True
|
|
532
|
-
links.add_link(
|
|
533
|
-
responses=definition["responses"],
|
|
534
|
-
links_field=self.links_field,
|
|
535
|
-
parameters=parameters,
|
|
536
|
-
request_body=request_body,
|
|
537
|
-
status_code=status_code,
|
|
538
|
-
target=target,
|
|
539
|
-
name=name,
|
|
540
|
-
)
|
|
541
|
-
# If methods are behind a reference, then on the next resolving they will miss the new link
|
|
542
|
-
# Therefore we need to modify it this way
|
|
543
|
-
self.raw_schema["paths"][operation][method] = definition
|
|
544
|
-
# The reference should be removed completely, otherwise new keys in this dictionary will be ignored
|
|
545
|
-
# due to the `$ref` keyword behavior
|
|
546
|
-
self.raw_schema["paths"][operation].pop("$ref", None)
|
|
547
|
-
if found:
|
|
548
|
-
return
|
|
549
|
-
name = f"{source.method.upper()} {source.path}"
|
|
550
|
-
# Use a name without basePath, as the user doesn't use it.
|
|
551
|
-
# E.g. `source=schema["/users/"]["POST"]` without a prefix
|
|
552
|
-
message = f"No such API operation: `{name}`."
|
|
553
|
-
possibilities = [
|
|
554
|
-
f"{op.ok().method.upper()} {op.ok().path}" for op in self.get_all_operations() if isinstance(op, Ok)
|
|
555
|
-
]
|
|
556
|
-
matches = get_close_matches(name, possibilities)
|
|
557
|
-
if matches:
|
|
558
|
-
message += f" Did you mean `{matches[0]}`?"
|
|
559
|
-
message += " Check if the requested API operation passes the filters in the schema."
|
|
560
|
-
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
|
+
)
|
|
561
633
|
|
|
562
634
|
def get_links(self, operation: APIOperation) -> dict[str, dict[str, Any]]:
|
|
563
635
|
result: dict[str, dict[str, Any]] = defaultdict(dict)
|
|
@@ -566,7 +638,13 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
566
638
|
return result
|
|
567
639
|
|
|
568
640
|
def get_tags(self, operation: APIOperation) -> list[str] | None:
|
|
569
|
-
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
|
|
570
648
|
|
|
571
649
|
def validate_response(self, operation: APIOperation, response: GenericResponse) -> bool | None:
|
|
572
650
|
responses = {str(key): value for key, value in operation.definition.raw.get("responses", {}).items()}
|
|
@@ -589,7 +667,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
589
667
|
formatted_content_types = [f"\n- `{content_type}`" for content_type in media_types]
|
|
590
668
|
message = f"The following media types are documented in the schema:{''.join(formatted_content_types)}"
|
|
591
669
|
try:
|
|
592
|
-
raise get_missing_content_type_error()(
|
|
670
|
+
raise get_missing_content_type_error(operation.verbose_name)(
|
|
593
671
|
failures.MissingContentType.title,
|
|
594
672
|
context=failures.MissingContentType(message=message, media_types=media_types),
|
|
595
673
|
)
|
|
@@ -601,26 +679,26 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
601
679
|
try:
|
|
602
680
|
data = get_json(response)
|
|
603
681
|
except JSONDecodeError as exc:
|
|
604
|
-
exc_class = get_response_parsing_error(exc)
|
|
682
|
+
exc_class = get_response_parsing_error(operation.verbose_name, exc)
|
|
605
683
|
context = failures.JSONDecodeErrorContext.from_exception(exc)
|
|
606
684
|
try:
|
|
607
685
|
raise exc_class(context.title, context=context) from exc
|
|
608
686
|
except Exception as exc:
|
|
609
687
|
errors.append(exc)
|
|
610
688
|
_maybe_raise_one_or_more(errors)
|
|
611
|
-
|
|
612
|
-
self.location or "", self.raw_schema, nullable_name=self.nullable_name, is_response_schema=True
|
|
613
|
-
)
|
|
614
|
-
if self.spec_version.startswith("3.1") and experimental.OPEN_API_3_1.is_enabled:
|
|
615
|
-
cls = jsonschema.Draft202012Validator
|
|
616
|
-
else:
|
|
617
|
-
cls = jsonschema.Draft4Validator
|
|
618
|
-
with in_scopes(resolver, scopes):
|
|
689
|
+
with self._validating_response(scopes) as resolver:
|
|
619
690
|
try:
|
|
620
|
-
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
|
+
)
|
|
621
699
|
except jsonschema.ValidationError as exc:
|
|
622
|
-
exc_class = get_schema_validation_error(exc)
|
|
623
|
-
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)
|
|
624
702
|
try:
|
|
625
703
|
raise exc_class(ctx.title, context=ctx) from exc
|
|
626
704
|
except Exception as exc:
|
|
@@ -628,6 +706,14 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
628
706
|
_maybe_raise_one_or_more(errors)
|
|
629
707
|
return None # explicitly return None for mypy
|
|
630
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
|
+
|
|
631
717
|
@property
|
|
632
718
|
def rewritten_components(self) -> dict[str, Any]:
|
|
633
719
|
if not hasattr(self, "_rewritten_components"):
|
|
@@ -721,10 +807,9 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
721
807
|
def _maybe_raise_one_or_more(errors: list[Exception]) -> None:
|
|
722
808
|
if not errors:
|
|
723
809
|
return
|
|
724
|
-
|
|
810
|
+
if len(errors) == 1:
|
|
725
811
|
raise errors[0]
|
|
726
|
-
|
|
727
|
-
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)
|
|
728
813
|
|
|
729
814
|
|
|
730
815
|
def _make_reference_key(scopes: list[str], reference: str) -> str:
|
|
@@ -766,30 +851,58 @@ def in_scopes(resolver: jsonschema.RefResolver, scopes: list[str]) -> Generator[
|
|
|
766
851
|
yield
|
|
767
852
|
|
|
768
853
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
) -> dict[str, APIOperationMap]:
|
|
772
|
-
output: dict[str, APIOperationMap] = {}
|
|
773
|
-
for result in operations:
|
|
774
|
-
if isinstance(result, Ok):
|
|
775
|
-
operation = result.ok()
|
|
776
|
-
output.setdefault(operation.path, APIOperationMap(MethodMap()))
|
|
777
|
-
output[operation.path][operation.method] = operation
|
|
778
|
-
return output
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
class MethodMap(CaseInsensitiveDict):
|
|
854
|
+
@dataclass
|
|
855
|
+
class MethodMap(Mapping):
|
|
782
856
|
"""Container for accessing API operations.
|
|
783
857
|
|
|
784
858
|
Provides a more specific error message if API operation is not found.
|
|
785
859
|
"""
|
|
786
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
|
+
|
|
787
898
|
def __getitem__(self, item: str) -> APIOperation:
|
|
788
899
|
try:
|
|
789
|
-
return
|
|
900
|
+
return self._init_operation(item)
|
|
790
901
|
except KeyError as exc:
|
|
791
902
|
available_methods = ", ".join(map(str.upper, self))
|
|
792
|
-
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}"
|
|
793
906
|
raise KeyError(message) from exc
|
|
794
907
|
|
|
795
908
|
|
|
@@ -859,18 +972,22 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
859
972
|
)
|
|
860
973
|
return collected
|
|
861
974
|
|
|
862
|
-
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]]:
|
|
863
978
|
"""Get examples from the API operation."""
|
|
864
|
-
return get_strategies_from_examples(operation,
|
|
979
|
+
return get_strategies_from_examples(operation, as_strategy_kwargs=as_strategy_kwargs)
|
|
865
980
|
|
|
866
981
|
def get_response_schema(self, definition: dict[str, Any], scope: str) -> tuple[list[str], dict[str, Any] | None]:
|
|
867
|
-
scopes, definition = self.resolver.resolve_in_scope(
|
|
982
|
+
scopes, definition = self.resolver.resolve_in_scope(definition, scope)
|
|
868
983
|
schema = definition.get("schema")
|
|
869
984
|
if not schema:
|
|
870
985
|
return scopes, None
|
|
871
986
|
# Extra conversion to JSON Schema is needed here if there was one $ref in the input
|
|
872
987
|
# because it is not converted
|
|
873
|
-
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
|
+
)
|
|
874
991
|
|
|
875
992
|
def get_content_types(self, operation: APIOperation, response: GenericResponse) -> list[str]:
|
|
876
993
|
produces = operation.definition.raw.get("produces", None)
|
|
@@ -903,7 +1020,7 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
903
1020
|
else:
|
|
904
1021
|
files.append((name, file_value))
|
|
905
1022
|
|
|
906
|
-
for parameter in operation.
|
|
1023
|
+
for parameter in operation.body:
|
|
907
1024
|
if isinstance(parameter, OpenAPI20CompositeBody):
|
|
908
1025
|
for form_parameter in parameter.definition:
|
|
909
1026
|
name = form_parameter.name
|
|
@@ -918,7 +1035,7 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
918
1035
|
return files or None, data or None
|
|
919
1036
|
|
|
920
1037
|
def get_request_payload_content_types(self, operation: APIOperation) -> list[str]:
|
|
921
|
-
return self._get_consumes_for_operation(operation.definition.
|
|
1038
|
+
return self._get_consumes_for_operation(operation.definition.raw)
|
|
922
1039
|
|
|
923
1040
|
def make_case(
|
|
924
1041
|
self,
|
|
@@ -931,20 +1048,10 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
931
1048
|
query: Query | None = None,
|
|
932
1049
|
body: Body | NotSet = NOT_SET,
|
|
933
1050
|
media_type: str | None = None,
|
|
1051
|
+
generation_time: float = 0.0,
|
|
934
1052
|
) -> C:
|
|
935
1053
|
if body is not NOT_SET and media_type is None:
|
|
936
|
-
|
|
937
|
-
media_types = operation.get_request_payload_content_types()
|
|
938
|
-
if len(media_types) == 1:
|
|
939
|
-
# The only available option
|
|
940
|
-
media_type = media_types[0]
|
|
941
|
-
else:
|
|
942
|
-
media_types_repr = ", ".join(media_types)
|
|
943
|
-
raise UsageError(
|
|
944
|
-
"Can not detect appropriate media type. "
|
|
945
|
-
"You can either specify one of the defined media types "
|
|
946
|
-
f"or pass any other media type available for serialization. Defined media types: {media_types_repr}"
|
|
947
|
-
)
|
|
1054
|
+
media_type = operation._get_default_media_type()
|
|
948
1055
|
return case_cls(
|
|
949
1056
|
operation=operation,
|
|
950
1057
|
path_parameters=path_parameters,
|
|
@@ -953,6 +1060,7 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
953
1060
|
query=query,
|
|
954
1061
|
body=body,
|
|
955
1062
|
media_type=media_type,
|
|
1063
|
+
generation_time=generation_time,
|
|
956
1064
|
)
|
|
957
1065
|
|
|
958
1066
|
def _get_consumes_for_operation(self, definition: dict[str, Any]) -> list[str]:
|
|
@@ -1023,31 +1131,37 @@ class OpenApi30(SwaggerV20):
|
|
|
1023
1131
|
return collected
|
|
1024
1132
|
|
|
1025
1133
|
def get_response_schema(self, definition: dict[str, Any], scope: str) -> tuple[list[str], dict[str, Any] | None]:
|
|
1026
|
-
scopes, definition = self.resolver.resolve_in_scope(
|
|
1134
|
+
scopes, definition = self.resolver.resolve_in_scope(definition, scope)
|
|
1027
1135
|
options = iter(definition.get("content", {}).values())
|
|
1028
1136
|
option = next(options, None)
|
|
1029
1137
|
# "schema" is an optional key in the `MediaType` object
|
|
1030
1138
|
if option and "schema" in option:
|
|
1031
1139
|
# Extra conversion to JSON Schema is needed here if there was one $ref in the input
|
|
1032
1140
|
# because it is not converted
|
|
1033
|
-
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
|
+
)
|
|
1034
1144
|
return scopes, None
|
|
1035
1145
|
|
|
1036
|
-
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]]:
|
|
1037
1149
|
"""Get examples from the API operation."""
|
|
1038
|
-
return get_strategies_from_examples(operation,
|
|
1150
|
+
return get_strategies_from_examples(operation, as_strategy_kwargs=as_strategy_kwargs)
|
|
1039
1151
|
|
|
1040
1152
|
def get_content_types(self, operation: APIOperation, response: GenericResponse) -> list[str]:
|
|
1041
|
-
|
|
1042
|
-
if not
|
|
1153
|
+
resolved = self._get_response_definitions(operation, response)
|
|
1154
|
+
if not resolved:
|
|
1043
1155
|
return []
|
|
1156
|
+
_, definitions = resolved
|
|
1044
1157
|
return list(definitions.get("content", {}).keys())
|
|
1045
1158
|
|
|
1046
1159
|
def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
|
|
1047
1160
|
return serialization.serialize_openapi3_parameters(definitions)
|
|
1048
1161
|
|
|
1049
1162
|
def get_request_payload_content_types(self, operation: APIOperation) -> list[str]:
|
|
1050
|
-
|
|
1163
|
+
request_body = self._resolve_until_no_references(operation.definition.raw["requestBody"])
|
|
1164
|
+
return list(request_body["content"])
|
|
1051
1165
|
|
|
1052
1166
|
def prepare_multipart(
|
|
1053
1167
|
self, form_data: FormData, operation: APIOperation
|
|
@@ -1059,17 +1173,22 @@ class OpenApi30(SwaggerV20):
|
|
|
1059
1173
|
:return: `files` and `data` values for `requests.request`.
|
|
1060
1174
|
"""
|
|
1061
1175
|
files = []
|
|
1062
|
-
|
|
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"]
|
|
1063
1182
|
# Open API 3.0 requires media types to be present. We can get here only if the schema defines
|
|
1064
1183
|
# the "multipart/form-data" media type, or any other more general media type that matches it (like `*/*`)
|
|
1065
1184
|
for media_type, entry in content.items():
|
|
1066
1185
|
main, sub = parse_content_type(media_type)
|
|
1067
|
-
if main in ("*", "multipart") and sub in ("*", "form-data"):
|
|
1068
|
-
schema = entry
|
|
1186
|
+
if main in ("*", "multipart") and sub in ("*", "form-data", "mixed"):
|
|
1187
|
+
schema = entry.get("schema")
|
|
1069
1188
|
break
|
|
1070
1189
|
else:
|
|
1071
1190
|
raise InternalError("No 'multipart/form-data' media type found in the schema")
|
|
1072
|
-
for name, property_schema in schema.get("properties", {}).items():
|
|
1191
|
+
for name, property_schema in (schema or {}).get("properties", {}).items():
|
|
1073
1192
|
if name in form_data:
|
|
1074
1193
|
if isinstance(form_data[name], list):
|
|
1075
1194
|
files.extend([(name, item) for item in form_data[name]])
|