schemathesis 3.25.6__py3-none-any.whl → 4.0.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +102 -82
  3. schemathesis/checks.py +126 -46
  4. schemathesis/cli/__init__.py +11 -1760
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +37 -0
  7. schemathesis/cli/commands/run/__init__.py +662 -0
  8. schemathesis/cli/commands/run/checks.py +80 -0
  9. schemathesis/cli/commands/run/context.py +117 -0
  10. schemathesis/cli/commands/run/events.py +35 -0
  11. schemathesis/cli/commands/run/executor.py +138 -0
  12. schemathesis/cli/commands/run/filters.py +194 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +18 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  17. schemathesis/cli/commands/run/handlers/output.py +746 -0
  18. schemathesis/cli/commands/run/hypothesis.py +105 -0
  19. schemathesis/cli/commands/run/loaders.py +129 -0
  20. schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
  21. schemathesis/cli/constants.py +5 -52
  22. schemathesis/cli/core.py +17 -0
  23. schemathesis/cli/ext/fs.py +14 -0
  24. schemathesis/cli/ext/groups.py +55 -0
  25. schemathesis/cli/{options.py → ext/options.py} +39 -10
  26. schemathesis/cli/hooks.py +36 -0
  27. schemathesis/contrib/__init__.py +1 -3
  28. schemathesis/contrib/openapi/__init__.py +1 -3
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
  30. schemathesis/core/__init__.py +58 -0
  31. schemathesis/core/compat.py +25 -0
  32. schemathesis/core/control.py +2 -0
  33. schemathesis/core/curl.py +58 -0
  34. schemathesis/core/deserialization.py +65 -0
  35. schemathesis/core/errors.py +370 -0
  36. schemathesis/core/failures.py +285 -0
  37. schemathesis/core/fs.py +19 -0
  38. schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
  39. schemathesis/core/loaders.py +104 -0
  40. schemathesis/core/marks.py +66 -0
  41. schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
  42. schemathesis/core/output/__init__.py +69 -0
  43. schemathesis/core/output/sanitization.py +197 -0
  44. schemathesis/core/rate_limit.py +60 -0
  45. schemathesis/core/registries.py +31 -0
  46. schemathesis/{internal → core}/result.py +1 -1
  47. schemathesis/core/transforms.py +113 -0
  48. schemathesis/core/transport.py +108 -0
  49. schemathesis/core/validation.py +38 -0
  50. schemathesis/core/version.py +7 -0
  51. schemathesis/engine/__init__.py +30 -0
  52. schemathesis/engine/config.py +59 -0
  53. schemathesis/engine/context.py +119 -0
  54. schemathesis/engine/control.py +36 -0
  55. schemathesis/engine/core.py +157 -0
  56. schemathesis/engine/errors.py +394 -0
  57. schemathesis/engine/events.py +337 -0
  58. schemathesis/engine/phases/__init__.py +66 -0
  59. schemathesis/{runner → engine/phases}/probes.py +50 -67
  60. schemathesis/engine/phases/stateful/__init__.py +65 -0
  61. schemathesis/engine/phases/stateful/_executor.py +326 -0
  62. schemathesis/engine/phases/stateful/context.py +85 -0
  63. schemathesis/engine/phases/unit/__init__.py +174 -0
  64. schemathesis/engine/phases/unit/_executor.py +321 -0
  65. schemathesis/engine/phases/unit/_pool.py +74 -0
  66. schemathesis/engine/recorder.py +241 -0
  67. schemathesis/errors.py +31 -0
  68. schemathesis/experimental/__init__.py +18 -14
  69. schemathesis/filters.py +103 -14
  70. schemathesis/generation/__init__.py +21 -37
  71. schemathesis/generation/case.py +190 -0
  72. schemathesis/generation/coverage.py +931 -0
  73. schemathesis/generation/hypothesis/__init__.py +30 -0
  74. schemathesis/generation/hypothesis/builder.py +585 -0
  75. schemathesis/generation/hypothesis/examples.py +50 -0
  76. schemathesis/generation/hypothesis/given.py +66 -0
  77. schemathesis/generation/hypothesis/reporting.py +14 -0
  78. schemathesis/generation/hypothesis/strategies.py +16 -0
  79. schemathesis/generation/meta.py +115 -0
  80. schemathesis/generation/modes.py +28 -0
  81. schemathesis/generation/overrides.py +96 -0
  82. schemathesis/generation/stateful/__init__.py +20 -0
  83. schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
  84. schemathesis/generation/targets.py +69 -0
  85. schemathesis/graphql/__init__.py +15 -0
  86. schemathesis/graphql/checks.py +115 -0
  87. schemathesis/graphql/loaders.py +131 -0
  88. schemathesis/hooks.py +99 -67
  89. schemathesis/openapi/__init__.py +13 -0
  90. schemathesis/openapi/checks.py +412 -0
  91. schemathesis/openapi/generation/__init__.py +0 -0
  92. schemathesis/openapi/generation/filters.py +63 -0
  93. schemathesis/openapi/loaders.py +178 -0
  94. schemathesis/pytest/__init__.py +5 -0
  95. schemathesis/pytest/control_flow.py +7 -0
  96. schemathesis/pytest/lazy.py +273 -0
  97. schemathesis/pytest/loaders.py +12 -0
  98. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
  99. schemathesis/python/__init__.py +0 -0
  100. schemathesis/python/asgi.py +12 -0
  101. schemathesis/python/wsgi.py +12 -0
  102. schemathesis/schemas.py +537 -261
  103. schemathesis/specs/graphql/__init__.py +0 -1
  104. schemathesis/specs/graphql/_cache.py +25 -0
  105. schemathesis/specs/graphql/nodes.py +1 -0
  106. schemathesis/specs/graphql/scalars.py +7 -5
  107. schemathesis/specs/graphql/schemas.py +215 -187
  108. schemathesis/specs/graphql/validation.py +11 -18
  109. schemathesis/specs/openapi/__init__.py +7 -1
  110. schemathesis/specs/openapi/_cache.py +122 -0
  111. schemathesis/specs/openapi/_hypothesis.py +146 -165
  112. schemathesis/specs/openapi/checks.py +565 -67
  113. schemathesis/specs/openapi/converter.py +33 -6
  114. schemathesis/specs/openapi/definitions.py +11 -18
  115. schemathesis/specs/openapi/examples.py +139 -23
  116. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  117. schemathesis/specs/openapi/expressions/context.py +4 -6
  118. schemathesis/specs/openapi/expressions/extractors.py +23 -0
  119. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  120. schemathesis/specs/openapi/expressions/nodes.py +38 -14
  121. schemathesis/specs/openapi/expressions/parser.py +26 -5
  122. schemathesis/specs/openapi/formats.py +45 -0
  123. schemathesis/specs/openapi/links.py +65 -165
  124. schemathesis/specs/openapi/media_types.py +32 -0
  125. schemathesis/specs/openapi/negative/__init__.py +7 -3
  126. schemathesis/specs/openapi/negative/mutations.py +24 -8
  127. schemathesis/specs/openapi/parameters.py +46 -30
  128. schemathesis/specs/openapi/patterns.py +137 -0
  129. schemathesis/specs/openapi/references.py +47 -57
  130. schemathesis/specs/openapi/schemas.py +478 -369
  131. schemathesis/specs/openapi/security.py +25 -7
  132. schemathesis/specs/openapi/serialization.py +11 -6
  133. schemathesis/specs/openapi/stateful/__init__.py +185 -73
  134. schemathesis/specs/openapi/utils.py +6 -1
  135. schemathesis/transport/__init__.py +104 -0
  136. schemathesis/transport/asgi.py +26 -0
  137. schemathesis/transport/prepare.py +99 -0
  138. schemathesis/transport/requests.py +221 -0
  139. schemathesis/{_xml.py → transport/serialization.py} +143 -28
  140. schemathesis/transport/wsgi.py +165 -0
  141. schemathesis-4.0.0a1.dist-info/METADATA +297 -0
  142. schemathesis-4.0.0a1.dist-info/RECORD +152 -0
  143. {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
  144. {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
  145. schemathesis/_compat.py +0 -74
  146. schemathesis/_dependency_versions.py +0 -17
  147. schemathesis/_hypothesis.py +0 -246
  148. schemathesis/_override.py +0 -49
  149. schemathesis/cli/cassettes.py +0 -375
  150. schemathesis/cli/context.py +0 -58
  151. schemathesis/cli/debug.py +0 -26
  152. schemathesis/cli/handlers.py +0 -16
  153. schemathesis/cli/junitxml.py +0 -43
  154. schemathesis/cli/output/__init__.py +0 -1
  155. schemathesis/cli/output/default.py +0 -790
  156. schemathesis/cli/output/short.py +0 -44
  157. schemathesis/cli/sanitization.py +0 -20
  158. schemathesis/code_samples.py +0 -149
  159. schemathesis/constants.py +0 -55
  160. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  161. schemathesis/contrib/openapi/formats/uuid.py +0 -15
  162. schemathesis/contrib/unique_data.py +0 -41
  163. schemathesis/exceptions.py +0 -560
  164. schemathesis/extra/_aiohttp.py +0 -27
  165. schemathesis/extra/_flask.py +0 -10
  166. schemathesis/extra/_server.py +0 -17
  167. schemathesis/failures.py +0 -209
  168. schemathesis/fixups/__init__.py +0 -36
  169. schemathesis/fixups/fast_api.py +0 -41
  170. schemathesis/fixups/utf8_bom.py +0 -29
  171. schemathesis/graphql.py +0 -4
  172. schemathesis/internal/__init__.py +0 -7
  173. schemathesis/internal/copy.py +0 -13
  174. schemathesis/internal/datetime.py +0 -5
  175. schemathesis/internal/deprecation.py +0 -34
  176. schemathesis/internal/jsonschema.py +0 -35
  177. schemathesis/internal/transformation.py +0 -15
  178. schemathesis/internal/validation.py +0 -34
  179. schemathesis/lazy.py +0 -361
  180. schemathesis/loaders.py +0 -120
  181. schemathesis/models.py +0 -1234
  182. schemathesis/parameters.py +0 -86
  183. schemathesis/runner/__init__.py +0 -570
  184. schemathesis/runner/events.py +0 -329
  185. schemathesis/runner/impl/__init__.py +0 -3
  186. schemathesis/runner/impl/core.py +0 -1035
  187. schemathesis/runner/impl/solo.py +0 -90
  188. schemathesis/runner/impl/threadpool.py +0 -400
  189. schemathesis/runner/serialization.py +0 -411
  190. schemathesis/sanitization.py +0 -248
  191. schemathesis/serializers.py +0 -323
  192. schemathesis/service/__init__.py +0 -18
  193. schemathesis/service/auth.py +0 -11
  194. schemathesis/service/ci.py +0 -201
  195. schemathesis/service/client.py +0 -100
  196. schemathesis/service/constants.py +0 -38
  197. schemathesis/service/events.py +0 -57
  198. schemathesis/service/hosts.py +0 -107
  199. schemathesis/service/metadata.py +0 -46
  200. schemathesis/service/models.py +0 -49
  201. schemathesis/service/report.py +0 -255
  202. schemathesis/service/serialization.py +0 -199
  203. schemathesis/service/usage.py +0 -65
  204. schemathesis/specs/graphql/loaders.py +0 -344
  205. schemathesis/specs/openapi/filters.py +0 -49
  206. schemathesis/specs/openapi/loaders.py +0 -667
  207. schemathesis/specs/openapi/stateful/links.py +0 -92
  208. schemathesis/specs/openapi/validation.py +0 -25
  209. schemathesis/stateful/__init__.py +0 -133
  210. schemathesis/targets.py +0 -45
  211. schemathesis/throttling.py +0 -41
  212. schemathesis/transports/__init__.py +0 -5
  213. schemathesis/transports/auth.py +0 -15
  214. schemathesis/transports/headers.py +0 -35
  215. schemathesis/transports/responses.py +0 -52
  216. schemathesis/types.py +0 -35
  217. schemathesis/utils.py +0 -169
  218. schemathesis-3.25.6.dist-info/METADATA +0 -356
  219. schemathesis-3.25.6.dist-info/RECORD +0 -134
  220. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  221. {schemathesis-3.25.6.dist-info → schemathesis-4.0.0a1.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,41 @@ from typing import (
15
16
  ClassVar,
16
17
  Generator,
17
18
  Iterable,
19
+ Iterator,
20
+ Mapping,
18
21
  NoReturn,
19
- Sequence,
20
- TypeVar,
22
+ cast,
21
23
  )
22
24
  from urllib.parse import urlsplit
23
25
 
24
26
  import jsonschema
25
- from hypothesis.strategies import SearchStrategy
26
27
  from packaging import version
28
+ from requests.exceptions import InvalidHeader
27
29
  from requests.structures import CaseInsensitiveDict
28
-
29
- from ... import experimental, failures
30
- from ..._compat import MultipleFailures
31
- from ..._override import CaseOverride, set_override_mark, check_no_override_mark
32
- from ...auths import AuthStorage
33
- from ...generation import DataGenerationMethod, GenerationConfig
34
- from ...constants import HTTP_METHODS, NOT_SET
35
- from ...exceptions import (
36
- InternalError,
37
- OperationSchemaError,
38
- UsageError,
39
- get_missing_content_type_error,
40
- get_response_parsing_error,
41
- get_schema_validation_error,
42
- SchemaError,
43
- SchemaErrorType,
44
- OperationNotFound,
45
- )
46
- from ...hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, should_skip_operation
47
- from ...internal.copy import fast_deepcopy
48
- from ...internal.jsonschema import traverse_schema
49
- from ...internal.result import Err, Ok, Result
50
- from ...models import APIOperation, Case, OperationDefinition
51
- from ...schemas import BaseSchema, APIOperationMap
52
- from ...stateful import Stateful, StatefulTest
53
- from ...stateful.state_machine import APIStateMachine
54
- from ...transports.content_types import is_json_media_type, parse_content_type
55
- from ...transports.responses import get_json
56
- from ...types import Body, Cookies, FormData, Headers, NotSet, PathParameters, Query, GenericTest
30
+ from requests.utils import check_header_validity
31
+
32
+ from schemathesis.core import NOT_SET, NotSet, Specification, media_types
33
+ from schemathesis.core.compat import RefResolutionError
34
+ from schemathesis.core.errors import InternalError, InvalidSchema, LoaderError, LoaderErrorKind, OperationNotFound
35
+ from schemathesis.core.failures import Failure, FailureGroup, MalformedJson
36
+ from schemathesis.core.result import Err, Ok, Result
37
+ from schemathesis.core.transforms import UNRESOLVABLE, deepclone, resolve_pointer, transform
38
+ from schemathesis.core.transport import Response
39
+ from schemathesis.core.validation import INVALID_HEADER_RE
40
+ from schemathesis.generation.case import Case
41
+ from schemathesis.generation.meta import CaseMetadata
42
+ from schemathesis.generation.overrides import Override, OverrideMark, check_no_override_mark
43
+ from schemathesis.openapi.checks import JsonSchemaError, MissingContentType
44
+
45
+ from ...generation import GenerationConfig, GenerationMode
46
+ from ...hooks import HookContext, HookDispatcher
47
+ from ...schemas import APIOperation, APIOperationMap, ApiOperationsCount, BaseSchema, OperationDefinition
57
48
  from . import links, serialization
58
- from ._hypothesis import get_case_strategy
49
+ from ._cache import OperationCache
50
+ from ._hypothesis import openapi_cases
59
51
  from .converter import to_json_schema, to_json_schema_recursive
60
52
  from .definitions import OPENAPI_30_VALIDATOR, OPENAPI_31_VALIDATOR, SWAGGER_20_VALIDATOR
61
53
  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
54
  from .parameters import (
70
55
  OpenAPI20Body,
71
56
  OpenAPI20CompositeBody,
@@ -74,15 +59,34 @@ from .parameters import (
74
59
  OpenAPI30Parameter,
75
60
  OpenAPIParameter,
76
61
  )
77
- from .references import RECURSION_DEPTH_LIMIT, ConvertingResolver, InliningResolver, resolve_pointer, UNRESOLVABLE
62
+ from .references import RECURSION_DEPTH_LIMIT, ConvertingResolver, InliningResolver
78
63
  from .security import BaseSecurityProcessor, OpenAPISecurityProcessor, SwaggerSecurityProcessor
79
64
  from .stateful import create_state_machine
80
65
 
81
66
  if TYPE_CHECKING:
82
- from ...transports.responses import GenericResponse
67
+ from hypothesis.strategies import SearchStrategy
83
68
 
69
+ from ...auths import AuthStorage
70
+ from ...stateful.state_machine import APIStateMachine
71
+
72
+ HTTP_METHODS = frozenset({"get", "put", "post", "delete", "options", "head", "patch", "trace"})
84
73
  SCHEMA_ERROR_MESSAGE = "Ensure that the definition complies with the OpenAPI specification"
85
- SCHEMA_PARSING_ERRORS = (KeyError, AttributeError, jsonschema.exceptions.RefResolutionError)
74
+ SCHEMA_PARSING_ERRORS = (KeyError, AttributeError, RefResolutionError, InvalidSchema)
75
+
76
+
77
+ def check_header(parameter: dict[str, Any]) -> None:
78
+ name = parameter["name"]
79
+ if not name:
80
+ raise InvalidSchema("Header name should not be empty")
81
+ if not name.isascii():
82
+ # `urllib3` encodes header names to ASCII
83
+ raise InvalidSchema(f"Header name should be ASCII: {name}")
84
+ try:
85
+ check_header_validity((name, ""))
86
+ except InvalidHeader as exc:
87
+ raise InvalidSchema(str(exc)) from None
88
+ if bool(INVALID_HEADER_RE.search(name)):
89
+ raise InvalidSchema(f"Invalid header name: {name}")
86
90
 
87
91
 
88
92
  @dataclass(eq=False, repr=False)
@@ -91,36 +95,39 @@ 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
- _operations_by_id: dict[str, APIOperation] = field(init=False)
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
103
- def spec_version(self) -> str:
106
+ def specification(self) -> Specification:
104
107
  raise NotImplementedError
105
108
 
106
- def get_stateful_tests(
107
- self, response: GenericResponse, operation: APIOperation, stateful: Stateful | None
108
- ) -> Sequence[StatefulTest]:
109
- if stateful == Stateful.links:
110
- return links.get_links(response, operation, field=self.links_field)
111
- return []
112
-
113
109
  def __repr__(self) -> str:
114
110
  info = self.raw_schema["info"]
115
111
  return f"<{self.__class__.__name__} for {info['title']} {info['version']}>"
116
112
 
117
- def _store_operations(
118
- self, operations: Generator[Result[APIOperation, OperationSchemaError], None, None]
119
- ) -> dict[str, APIOperationMap]:
120
- return operations_to_dict(operations)
113
+ def __iter__(self) -> Iterator[str]:
114
+ return iter(self.raw_schema.get("paths", {}))
115
+
116
+ def _get_operation_map(self, path: str) -> APIOperationMap:
117
+ cache = self._operation_cache
118
+ map = cache.get_map(path)
119
+ if map is not None:
120
+ return map
121
+ path_item = self.raw_schema.get("paths", {})[path]
122
+ scope, path_item = self._resolve_path_item(path_item)
123
+ self.dispatch_hook("before_process_path", HookContext(), path, path_item)
124
+ map = APIOperationMap(self, {})
125
+ map._data = MethodMap(map, scope, path, CaseInsensitiveDict(path_item))
126
+ cache.insert_map(path, map)
127
+ return map
121
128
 
122
129
  def on_missing_operation(self, item: str, exc: KeyError) -> NoReturn:
123
- matches = get_close_matches(item, list(self.operations))
130
+ matches = get_close_matches(item, list(self))
124
131
  self._on_missing_operation(item, exc, matches)
125
132
 
126
133
  def _on_missing_operation(self, item: str, exc: KeyError, matches: list[str]) -> NoReturn:
@@ -129,14 +136,59 @@ class BaseOpenAPISchema(BaseSchema):
129
136
  message += f". Did you mean `{matches[0]}`?"
130
137
  raise OperationNotFound(message=message, item=item) from exc
131
138
 
132
- def _should_skip(self, method: str, definition: dict[str, Any]) -> bool:
133
- return (
134
- method not in HTTP_METHODS
135
- or should_skip_method(method, self.method)
136
- or should_skip_deprecated(definition.get("deprecated", False), self.skip_deprecated_operations)
137
- or should_skip_by_tag(definition.get("tags"), self.tag)
138
- or should_skip_by_operation_id(definition.get("operationId"), self.operation_id)
139
- )
139
+ def _should_skip(
140
+ self,
141
+ path: str,
142
+ method: str,
143
+ definition: dict[str, Any],
144
+ _ctx_cache: SimpleNamespace = SimpleNamespace(
145
+ operation=APIOperation(
146
+ method="",
147
+ path="",
148
+ label="",
149
+ definition=OperationDefinition(raw=None, resolved=None, scope=""),
150
+ schema=None, # type: ignore
151
+ )
152
+ ),
153
+ ) -> bool:
154
+ if method not in HTTP_METHODS:
155
+ return True
156
+ if self.filter_set.is_empty():
157
+ return False
158
+ path = self.get_full_path(path)
159
+ # Attribute assignment is way faster than creating a new namespace every time
160
+ operation = _ctx_cache.operation
161
+ operation.method = method
162
+ operation.path = path
163
+ operation.label = f"{method.upper()} {path}"
164
+ operation.definition.raw = definition
165
+ operation.definition.resolved = definition
166
+ operation.schema = self
167
+ return not self.filter_set.match(_ctx_cache)
168
+
169
+ def _do_count_operations(self) -> ApiOperationsCount:
170
+ counter = ApiOperationsCount()
171
+ try:
172
+ paths = self.raw_schema["paths"]
173
+ except KeyError:
174
+ return counter
175
+
176
+ resolve = self.resolver.resolve
177
+ should_skip = self._should_skip
178
+
179
+ for path, path_item in paths.items():
180
+ try:
181
+ if "$ref" in path_item:
182
+ _, path_item = resolve(path_item["$ref"])
183
+ for method, definition in path_item.items():
184
+ if method not in HTTP_METHODS:
185
+ continue
186
+ counter.total += 1
187
+ if not should_skip(path, method, definition):
188
+ counter.selected += 1
189
+ except SCHEMA_PARSING_ERRORS:
190
+ continue
191
+ return counter
140
192
 
141
193
  def _operation_iter(self) -> Generator[dict[str, Any], None, None]:
142
194
  try:
@@ -144,40 +196,30 @@ class BaseOpenAPISchema(BaseSchema):
144
196
  except KeyError:
145
197
  return
146
198
  resolve = self.resolver.resolve
147
- for path, methods in paths.items():
148
- full_path = self.get_full_path(path)
149
- if should_skip_endpoint(full_path, self.endpoint):
150
- continue
199
+ should_skip = self._should_skip
200
+ for path, path_item in paths.items():
151
201
  try:
152
- if "$ref" in methods:
153
- _, resolved_methods = resolve(methods["$ref"])
154
- else:
155
- resolved_methods = methods
202
+ if "$ref" in path_item:
203
+ _, path_item = resolve(path_item["$ref"])
156
204
  # Straightforward iteration is faster than converting to a set & calculating length.
157
- for method, definition in resolved_methods.items():
158
- if self._should_skip(method, definition):
205
+ for method, definition in path_item.items():
206
+ if should_skip(path, method, definition):
159
207
  continue
160
208
  yield definition
161
209
  except SCHEMA_PARSING_ERRORS:
162
210
  # Ignore errors
163
211
  continue
164
212
 
165
- @property
166
- def operations_count(self) -> int:
167
- total = 0
168
- # Do not build a list from it
169
- for _ in self._operation_iter():
170
- total += 1
171
- return total
172
-
173
213
  @property
174
214
  def links_count(self) -> int:
175
215
  total = 0
216
+ resolve = self.resolver.resolve
217
+ links_field = self.links_field
176
218
  for definition in self._operation_iter():
177
219
  for response in definition.get("responses", {}).values():
178
220
  if "$ref" in response:
179
- _, response = self.resolver.resolve(response["$ref"])
180
- defined_links = response.get(self.links_field)
221
+ _, response = resolve(response["$ref"])
222
+ defined_links = response.get(links_field)
181
223
  if defined_links is not None:
182
224
  total += len(defined_links)
183
225
  return total
@@ -189,22 +231,40 @@ class BaseOpenAPISchema(BaseSchema):
189
231
  headers: dict[str, str] | None = None,
190
232
  cookies: dict[str, str] | None = None,
191
233
  path_parameters: dict[str, str] | None = None,
192
- ) -> Callable[[GenericTest], GenericTest]:
234
+ ) -> Callable[[Callable], Callable]:
193
235
  """Override Open API parameters with fixed values."""
194
236
 
195
- def _add_override(test: GenericTest) -> GenericTest:
237
+ def _add_override(test: Callable) -> Callable:
196
238
  check_no_override_mark(test)
197
- override = CaseOverride(
239
+ override = Override(
198
240
  query=query or {}, headers=headers or {}, cookies=cookies or {}, path_parameters=path_parameters or {}
199
241
  )
200
- set_override_mark(test, override)
242
+ OverrideMark.set(test, override)
201
243
  return test
202
244
 
203
245
  return _add_override
204
246
 
247
+ def _resolve_until_no_references(self, value: dict[str, Any]) -> dict[str, Any]:
248
+ while "$ref" in value:
249
+ _, value = self.resolver.resolve(value["$ref"])
250
+ return value
251
+
252
+ def _resolve_shared_parameters(self, path_item: Mapping[str, Any]) -> list[dict[str, Any]]:
253
+ return self.resolver.resolve_all(path_item.get("parameters", []), RECURSION_DEPTH_LIMIT - 8)
254
+
255
+ def _resolve_operation(self, operation: dict[str, Any]) -> dict[str, Any]:
256
+ return self.resolver.resolve_all(operation, RECURSION_DEPTH_LIMIT - 8)
257
+
258
+ def _collect_operation_parameters(
259
+ self, path_item: Mapping[str, Any], operation: dict[str, Any]
260
+ ) -> list[OpenAPIParameter]:
261
+ shared_parameters = self._resolve_shared_parameters(path_item)
262
+ parameters = operation.get("parameters", ())
263
+ return self.collect_parameters(itertools.chain(parameters, shared_parameters), operation)
264
+
205
265
  def get_all_operations(
206
- self, hooks: HookDispatcher | None = None
207
- ) -> Generator[Result[APIOperation, OperationSchemaError], None, None]:
266
+ self, generation_config: GenerationConfig | None = None
267
+ ) -> Generator[Result[APIOperation, InvalidSchema], None, None]:
208
268
  """Iterate over all operations defined in the API.
209
269
 
210
270
  Each yielded item is either `Ok` or `Err`, depending on the presence of errors during schema processing.
@@ -225,62 +285,59 @@ class BaseOpenAPISchema(BaseSchema):
225
285
  paths = self.raw_schema["paths"]
226
286
  except KeyError as exc:
227
287
  # This field is optional in Open API 3.1
228
- if version.parse(self.spec_version) >= version.parse("3.1"):
288
+ if version.parse(self.specification.version) >= version.parse("3.1"):
229
289
  return
230
290
  # Missing `paths` is not recoverable
231
291
  self._raise_invalid_schema(exc)
232
292
 
233
293
  context = HookContext()
234
- for path, methods in paths.items():
294
+ # Optimization: local variables are faster than attribute access
295
+ dispatch_hook = self.dispatch_hook
296
+ resolve_path_item = self._resolve_path_item
297
+ resolve_shared_parameters = self._resolve_shared_parameters
298
+ resolve_operation = self._resolve_operation
299
+ should_skip = self._should_skip
300
+ collect_parameters = self.collect_parameters
301
+ make_operation = self.make_operation
302
+ for path, path_item in paths.items():
235
303
  method = None
236
304
  try:
237
- full_path = self.get_full_path(path) # Should be available for later use
238
- if should_skip_endpoint(full_path, self.endpoint):
239
- continue
240
- self.dispatch_hook("before_process_path", context, path, methods)
241
- scope, raw_methods = self._resolve_methods(methods)
242
- common_parameters = self.resolver.resolve_all(methods.get("parameters", []), RECURSION_DEPTH_LIMIT - 8)
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):
305
+ dispatch_hook("before_process_path", context, path, path_item)
306
+ scope, path_item = resolve_path_item(path_item)
307
+ with in_scope(self.resolver, scope):
308
+ shared_parameters = resolve_shared_parameters(path_item)
309
+ for method, entry in path_item.items():
310
+ if method not in HTTP_METHODS:
254
311
  continue
255
- parameters = self.collect_parameters(
256
- itertools.chain(resolved_definition.get("parameters", ()), common_parameters),
257
- resolved_definition,
258
- )
259
- # To prevent recursion errors we need to pass not resolved schema as well
260
- # It could be used for response validation
261
- raw_definition = OperationDefinition(
262
- raw_methods[method], resolved_definition, scope, parameters
263
- )
264
- operation = self.make_operation(path, method, parameters, raw_definition)
265
- context = HookContext(operation=operation)
266
- if (
267
- should_skip_operation(GLOBAL_HOOK_DISPATCHER, context)
268
- or should_skip_operation(self.hooks, context)
269
- or (hooks and should_skip_operation(hooks, context))
270
- ):
271
- continue
272
- yield Ok(operation)
273
- except SCHEMA_PARSING_ERRORS as exc:
274
- yield self._into_err(exc, path, method)
312
+ try:
313
+ resolved = resolve_operation(entry)
314
+ if should_skip(path, method, resolved):
315
+ continue
316
+ parameters = resolved.get("parameters", ())
317
+ parameters = collect_parameters(itertools.chain(parameters, shared_parameters), resolved)
318
+ operation = make_operation(
319
+ path,
320
+ method,
321
+ parameters,
322
+ entry,
323
+ resolved,
324
+ scope,
325
+ with_security_parameters=generation_config.with_security_parameters
326
+ if generation_config
327
+ else None,
328
+ )
329
+ yield Ok(operation)
330
+ except SCHEMA_PARSING_ERRORS as exc:
331
+ yield self._into_err(exc, path, method)
275
332
  except SCHEMA_PARSING_ERRORS as exc:
276
333
  yield self._into_err(exc, path, method)
277
334
 
278
- def _into_err(self, error: Exception, path: str | None, method: str | None) -> Err[OperationSchemaError]:
335
+ def _into_err(self, error: Exception, path: str | None, method: str | None) -> Err[InvalidSchema]:
279
336
  __tracebackhide__ = True
280
337
  try:
281
338
  full_path = self.get_full_path(path) if isinstance(path, str) else None
282
339
  self._raise_invalid_schema(error, full_path, path, method)
283
- except OperationSchemaError as exc:
340
+ except InvalidSchema as exc:
284
341
  return Err(exc)
285
342
 
286
343
  def _raise_invalid_schema(
@@ -291,17 +348,15 @@ class BaseOpenAPISchema(BaseSchema):
291
348
  method: str | None = None,
292
349
  ) -> NoReturn:
293
350
  __tracebackhide__ = True
294
- if isinstance(error, jsonschema.exceptions.RefResolutionError):
295
- raise OperationSchemaError.from_reference_resolution_error(
351
+ if isinstance(error, RefResolutionError):
352
+ raise InvalidSchema.from_reference_resolution_error(
296
353
  error, path=path, method=method, full_path=full_path
297
354
  ) from None
298
355
  try:
299
356
  self.validate()
300
357
  except jsonschema.ValidationError as exc:
301
- raise OperationSchemaError.from_jsonschema_error(
302
- exc, path=path, method=method, full_path=full_path
303
- ) from None
304
- raise OperationSchemaError(SCHEMA_ERROR_MESSAGE, path=path, method=method, full_path=full_path) from error
358
+ raise InvalidSchema.from_jsonschema_error(exc, path=path, method=method, full_path=full_path) from None
359
+ raise InvalidSchema(SCHEMA_ERROR_MESSAGE, path=path, method=method, full_path=full_path) from error
305
360
 
306
361
  def validate(self) -> None:
307
362
  with suppress(TypeError):
@@ -320,35 +375,44 @@ class BaseOpenAPISchema(BaseSchema):
320
375
  """
321
376
  raise NotImplementedError
322
377
 
323
- def _resolve_methods(self, methods: dict[str, Any]) -> tuple[str, dict[str, Any]]:
324
- # We need to know a proper scope in what methods are.
325
- # It will allow us to provide a proper reference resolving in `response_schema_conformance` and avoid
326
- # recursion errors
378
+ def _resolve_path_item(self, methods: dict[str, Any]) -> tuple[str, dict[str, Any]]:
379
+ # The path item could be behind a reference
380
+ # In this case, we need to resolve it to get the proper scope for reference inside the item.
381
+ # It is mostly for validating responses.
327
382
  if "$ref" in methods:
328
- return fast_deepcopy(self.resolver.resolve(methods["$ref"]))
329
- return self.resolver.resolution_scope, fast_deepcopy(methods)
383
+ return self.resolver.resolve(methods["$ref"])
384
+ return self.resolver.resolution_scope, methods
330
385
 
331
386
  def make_operation(
332
387
  self,
333
388
  path: str,
334
389
  method: str,
335
390
  parameters: list[OpenAPIParameter],
336
- raw_definition: OperationDefinition,
391
+ raw: dict[str, Any],
392
+ resolved: dict[str, Any],
393
+ scope: str,
394
+ with_security_parameters: bool | None = None,
337
395
  ) -> APIOperation:
338
396
  """Create JSON schemas for the query, body, etc from Swagger parameters definitions."""
339
397
  __tracebackhide__ = True
340
398
  base_url = self.get_base_url()
341
- operation: APIOperation[OpenAPIParameter, Case] = APIOperation(
399
+ operation: APIOperation[OpenAPIParameter] = APIOperation(
342
400
  path=path,
343
401
  method=method,
344
- definition=raw_definition,
402
+ definition=OperationDefinition(raw, resolved, scope),
345
403
  base_url=base_url,
346
404
  app=self.app,
347
405
  schema=self,
348
406
  )
349
407
  for parameter in parameters:
350
408
  operation.add_parameter(parameter)
351
- self.security.process_definitions(self.raw_schema, operation, self.resolver)
409
+ with_security_parameters = (
410
+ with_security_parameters
411
+ if with_security_parameters is not None
412
+ else self.generation_config.with_security_parameters
413
+ )
414
+ if with_security_parameters:
415
+ self.security.process_definitions(self.raw_schema, operation, self.resolver)
352
416
  self.dispatch_hook("before_init_operation", HookContext(operation=operation), operation)
353
417
  return operation
354
418
 
@@ -358,11 +422,11 @@ class BaseOpenAPISchema(BaseSchema):
358
422
  self._resolver = InliningResolver(self.location or "", self.raw_schema)
359
423
  return self._resolver
360
424
 
361
- def get_content_types(self, operation: APIOperation, response: GenericResponse) -> list[str]:
425
+ def get_content_types(self, operation: APIOperation, response: Response) -> list[str]:
362
426
  """Content types available for this API operation."""
363
427
  raise NotImplementedError
364
428
 
365
- def get_strategies_from_examples(self, operation: APIOperation) -> list[SearchStrategy[Case]]:
429
+ def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
366
430
  """Get examples from the API operation."""
367
431
  raise NotImplementedError
368
432
 
@@ -376,76 +440,106 @@ class BaseOpenAPISchema(BaseSchema):
376
440
 
377
441
  def get_operation_by_id(self, operation_id: str) -> APIOperation:
378
442
  """Get an `APIOperation` instance by its `operationId`."""
379
- if not hasattr(self, "_operations_by_id"):
380
- self._operations_by_id = dict(self._group_operations_by_id())
443
+ cache = self._operation_cache
444
+ cached = cache.get_operation_by_id(operation_id)
445
+ if cached is not None:
446
+ return cached
447
+ # Operation has not been accessed yet, need to populate the cache
448
+ if not cache.has_ids_to_definitions:
449
+ self._populate_operation_id_cache(cache)
381
450
  try:
382
- return self._operations_by_id[operation_id]
451
+ entry = cache.get_definition_by_id(operation_id)
383
452
  except KeyError as exc:
384
- matches = get_close_matches(operation_id, list(self._operations_by_id))
453
+ matches = get_close_matches(operation_id, cache.known_operation_ids)
385
454
  self._on_missing_operation(operation_id, exc, matches)
386
-
387
- def _group_operations_by_id(self) -> Generator[tuple[str, APIOperation], None, None]:
388
- for path, methods in self.raw_schema["paths"].items():
389
- scope, raw_methods = self._resolve_methods(methods)
390
- common_parameters = self.resolver.resolve_all(methods.get("parameters", []), RECURSION_DEPTH_LIMIT - 8)
391
- for method, definition in methods.items():
392
- if method not in HTTP_METHODS or "operationId" not in definition:
455
+ # It could've been already accessed in a different place
456
+ traversal_key = (entry.scope, entry.path, entry.method)
457
+ instance = cache.get_operation_by_traversal_key(traversal_key)
458
+ if instance is not None:
459
+ return instance
460
+ resolved = self._resolve_operation(entry.operation)
461
+ parameters = self._collect_operation_parameters(entry.path_item, resolved)
462
+ initialized = self.make_operation(entry.path, entry.method, parameters, entry.operation, resolved, entry.scope)
463
+ cache.insert_operation(initialized, traversal_key=traversal_key, operation_id=operation_id)
464
+ return initialized
465
+
466
+ def _populate_operation_id_cache(self, cache: OperationCache) -> None:
467
+ """Collect all operation IDs from the schema."""
468
+ resolve = self.resolver.resolve
469
+ default_scope = self.resolver.resolution_scope
470
+ for path, path_item in self.raw_schema.get("paths", {}).items():
471
+ # If the path is behind a reference we have to keep the scope
472
+ # The scope is used to resolve nested components later on
473
+ if "$ref" in path_item:
474
+ scope, path_item = resolve(path_item["$ref"])
475
+ else:
476
+ scope = default_scope
477
+ for key, entry in path_item.items():
478
+ if key not in HTTP_METHODS:
393
479
  continue
394
- self.resolver.push_scope(scope)
395
- try:
396
- resolved_definition = self.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 8)
397
- finally:
398
- self.resolver.pop_scope()
399
- parameters = self.collect_parameters(
400
- itertools.chain(resolved_definition.get("parameters", ()), common_parameters), resolved_definition
401
- )
402
- raw_definition = OperationDefinition(raw_methods[method], resolved_definition, scope, parameters)
403
- yield resolved_definition["operationId"], self.make_operation(path, method, parameters, raw_definition)
480
+ if "operationId" in entry:
481
+ cache.insert_definition_by_id(
482
+ entry["operationId"],
483
+ path=path,
484
+ method=key,
485
+ scope=scope,
486
+ path_item=path_item,
487
+ operation=entry,
488
+ )
404
489
 
405
490
  def get_operation_by_reference(self, reference: str) -> APIOperation:
406
491
  """Get local or external `APIOperation` instance by reference.
407
492
 
408
493
  Reference example: #/paths/~1users~1{user_id}/patch
409
494
  """
410
- scope, data = self.resolver.resolve(reference)
495
+ cache = self._operation_cache
496
+ cached = cache.get_operation_by_reference(reference)
497
+ if cached is not None:
498
+ return cached
499
+ scope, operation = self.resolver.resolve(reference)
411
500
  path, method = scope.rsplit("/", maxsplit=2)[-2:]
412
501
  path = path.replace("~1", "/").replace("~0", "~")
413
- resolved_definition = self.resolver.resolve_all(data)
502
+ # Check the traversal cache as it could've been populated in other places
503
+ traversal_key = (self.resolver.resolution_scope, path, method)
504
+ cached = cache.get_operation_by_traversal_key(traversal_key)
505
+ if cached is not None:
506
+ return cached
507
+ with in_scope(self.resolver, scope):
508
+ resolved = self._resolve_operation(operation)
414
509
  parent_ref, _ = reference.rsplit("/", maxsplit=1)
415
- _, methods = self.resolver.resolve(parent_ref)
416
- common_parameters = self.resolver.resolve_all(methods.get("parameters", []), RECURSION_DEPTH_LIMIT - 8)
417
- parameters = self.collect_parameters(
418
- itertools.chain(resolved_definition.get("parameters", ()), common_parameters), resolved_definition
419
- )
420
- raw_definition = OperationDefinition(data, resolved_definition, scope, parameters)
421
- return self.make_operation(path, method, parameters, raw_definition)
510
+ _, path_item = self.resolver.resolve(parent_ref)
511
+ parameters = self._collect_operation_parameters(path_item, resolved)
512
+ initialized = self.make_operation(path, method, parameters, operation, resolved, scope)
513
+ cache.insert_operation(initialized, traversal_key=traversal_key, reference=reference)
514
+ return initialized
422
515
 
423
516
  def get_case_strategy(
424
517
  self,
425
518
  operation: APIOperation,
426
519
  hooks: HookDispatcher | None = None,
427
520
  auth_storage: AuthStorage | None = None,
428
- data_generation_method: DataGenerationMethod = DataGenerationMethod.default(),
521
+ generation_mode: GenerationMode = GenerationMode.default(),
429
522
  generation_config: GenerationConfig | None = None,
430
523
  **kwargs: Any,
431
524
  ) -> SearchStrategy:
432
- return get_case_strategy(
525
+ return openapi_cases(
433
526
  operation=operation,
434
527
  auth_storage=auth_storage,
435
528
  hooks=hooks,
436
- generator=data_generation_method,
529
+ generation_mode=generation_mode,
437
530
  generation_config=generation_config or self.generation_config,
438
531
  **kwargs,
439
532
  )
440
533
 
441
534
  def get_parameter_serializer(self, operation: APIOperation, location: str) -> Callable | None:
442
- definitions = [item for item in operation.definition.resolved.get("parameters", []) if item["in"] == location]
443
- security_parameters = self.security.get_security_definitions_as_parameters(
444
- self.raw_schema, operation, self.resolver, location
445
- )
446
- security_parameters = [item for item in security_parameters if item["in"] == location]
447
- if security_parameters:
448
- definitions.extend(security_parameters)
535
+ definitions = [item.definition for item in operation.iter_parameters() if item.location == location]
536
+ if self.generation_config.with_security_parameters:
537
+ security_parameters = self.security.get_security_definitions_as_parameters(
538
+ self.raw_schema, operation, self.resolver, location
539
+ )
540
+ security_parameters = [item for item in security_parameters if item["in"] == location]
541
+ if security_parameters:
542
+ definitions.extend(security_parameters)
449
543
  if definitions:
450
544
  return self._get_parameter_serializer(definitions)
451
545
  return None
@@ -453,33 +547,37 @@ class BaseOpenAPISchema(BaseSchema):
453
547
  def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
454
548
  raise NotImplementedError
455
549
 
456
- def _get_response_definitions(self, operation: APIOperation, response: GenericResponse) -> dict[str, Any] | None:
550
+ def _get_response_definitions(
551
+ self, operation: APIOperation, response: Response
552
+ ) -> tuple[list[str], dict[str, Any]] | None:
457
553
  try:
458
- responses = operation.definition.resolved["responses"]
554
+ responses = operation.definition.raw["responses"]
459
555
  except KeyError as exc:
460
- # Possible to get if `validate_schema=False` is passed during schema creation
461
556
  path = operation.path
462
557
  full_path = self.get_full_path(path) if isinstance(path, str) else None
463
558
  self._raise_invalid_schema(exc, full_path, path, operation.method)
464
559
  status_code = str(response.status_code)
465
560
  if status_code in responses:
466
- return responses[status_code]
561
+ return self.resolver.resolve_in_scope(responses[status_code], operation.definition.scope)
467
562
  if "default" in responses:
468
- return responses["default"]
563
+ return self.resolver.resolve_in_scope(responses["default"], operation.definition.scope)
469
564
  return None
470
565
 
471
- def get_headers(self, operation: APIOperation, response: GenericResponse) -> dict[str, dict[str, Any]] | None:
472
- definitions = self._get_response_definitions(operation, response)
473
- if not definitions:
566
+ def get_headers(
567
+ self, operation: APIOperation, response: Response
568
+ ) -> tuple[list[str], dict[str, dict[str, Any]] | None] | None:
569
+ resolved = self._get_response_definitions(operation, response)
570
+ if not resolved:
474
571
  return None
475
- return definitions.get("headers")
572
+ scopes, definitions = resolved
573
+ return scopes, definitions.get("headers")
476
574
 
477
575
  def as_state_machine(self) -> type[APIStateMachine]:
478
576
  try:
479
577
  return create_state_machine(self)
480
578
  except OperationNotFound as exc:
481
- raise SchemaError(
482
- type=SchemaErrorType.OPEN_API_INVALID_SCHEMA,
579
+ raise LoaderError(
580
+ kind=LoaderErrorKind.OPEN_API_INVALID_SCHEMA,
483
581
  message=f"Invalid Open API link definition: Operation `{exc.item}` not found",
484
582
  ) from exc
485
583
 
@@ -507,7 +605,7 @@ class BaseOpenAPISchema(BaseSchema):
507
605
 
508
606
  .. code-block:: python
509
607
 
510
- schema = schemathesis.from_uri("http://0.0.0.0/schema.yaml")
608
+ schema = schemathesis.openapi.from_url("http://0.0.0.0/schema.yaml")
511
609
 
512
610
  schema.add_link(
513
611
  source=schema["/users/"]["POST"],
@@ -518,46 +616,16 @@ class BaseOpenAPISchema(BaseSchema):
518
616
  """
519
617
  if parameters is None and request_body is None:
520
618
  raise ValueError("You need to provide `parameters` or `request_body`.")
521
- if hasattr(self, "_operations"):
522
- delattr(self, "_operations")
523
- for operation, methods in self.raw_schema["paths"].items():
524
- if operation == source.path:
525
- # Methods should be completely resolved now, otherwise they might miss a resolving scope when
526
- # they will be fully resolved later
527
- methods = self.resolver.resolve_all(methods)
528
- found = False
529
- for method, definition in methods.items():
530
- if method.upper() == source.method.upper():
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)
619
+ links.add_link(
620
+ resolver=self.resolver,
621
+ responses=self[source.path][source.method].definition.raw["responses"],
622
+ links_field=self.links_field,
623
+ parameters=parameters,
624
+ request_body=request_body,
625
+ status_code=status_code,
626
+ target=target,
627
+ name=name,
628
+ )
561
629
 
562
630
  def get_links(self, operation: APIOperation) -> dict[str, dict[str, Any]]:
563
631
  result: dict[str, dict[str, Any]] = defaultdict(dict)
@@ -566,9 +634,15 @@ class BaseOpenAPISchema(BaseSchema):
566
634
  return result
567
635
 
568
636
  def get_tags(self, operation: APIOperation) -> list[str] | None:
569
- return operation.definition.resolved.get("tags")
637
+ return operation.definition.raw.get("tags")
638
+
639
+ @property
640
+ def validator_cls(self) -> type[jsonschema.Validator]:
641
+ if self.specification.version.startswith("3.1"):
642
+ return jsonschema.Draft202012Validator
643
+ return jsonschema.Draft4Validator
570
644
 
571
- def validate_response(self, operation: APIOperation, response: GenericResponse) -> bool | None:
645
+ def validate_response(self, operation: APIOperation, response: Response) -> bool | None:
572
646
  responses = {str(key): value for key, value in operation.definition.raw.get("responses", {}).items()}
573
647
  status_code = str(response.status_code)
574
648
  if status_code in responses:
@@ -582,51 +656,52 @@ class BaseOpenAPISchema(BaseSchema):
582
656
  if not schema:
583
657
  # No schema to check against
584
658
  return None
585
- content_type = response.headers.get("Content-Type")
586
- errors = []
587
- if content_type is None:
588
- media_types = self.get_content_types(operation, response)
589
- formatted_content_types = [f"\n- `{content_type}`" for content_type in media_types]
659
+ content_types = response.headers.get("content-type")
660
+ failures: list[Failure] = []
661
+ if content_types is None:
662
+ all_media_types = self.get_content_types(operation, response)
663
+ formatted_content_types = [f"\n- `{content_type}`" for content_type in all_media_types]
590
664
  message = f"The following media types are documented in the schema:{''.join(formatted_content_types)}"
591
- try:
592
- raise get_missing_content_type_error()(
593
- failures.MissingContentType.title,
594
- context=failures.MissingContentType(message=message, media_types=media_types),
595
- )
596
- except Exception as exc:
597
- errors.append(exc)
598
- if content_type and not is_json_media_type(content_type):
599
- _maybe_raise_one_or_more(errors)
665
+ failures.append(MissingContentType(operation=operation.label, message=message, media_types=all_media_types))
666
+ content_type = None
667
+ else:
668
+ content_type = content_types[0]
669
+ if content_type and not media_types.is_json(content_type):
670
+ _maybe_raise_one_or_more(failures)
600
671
  return None
601
672
  try:
602
- data = get_json(response)
673
+ data = response.json()
603
674
  except JSONDecodeError as exc:
604
- exc_class = get_response_parsing_error(exc)
605
- context = failures.JSONDecodeErrorContext.from_exception(exc)
675
+ failures.append(MalformedJson.from_exception(operation=operation.label, exc=exc))
676
+ _maybe_raise_one_or_more(failures)
677
+ with self._validating_response(scopes) as resolver:
606
678
  try:
607
- raise exc_class(context.title, context=context) from exc
608
- except Exception as exc:
609
- errors.append(exc)
610
- _maybe_raise_one_or_more(errors)
679
+ jsonschema.validate(
680
+ data,
681
+ schema,
682
+ cls=self.validator_cls,
683
+ resolver=resolver,
684
+ # Use a recent JSON Schema format checker to get most of formats checked for older drafts as well
685
+ format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER,
686
+ )
687
+ except jsonschema.ValidationError as exc:
688
+ failures.append(
689
+ JsonSchemaError.from_exception(
690
+ operation=operation.label,
691
+ exc=exc,
692
+ output_config=operation.schema.output_config,
693
+ )
694
+ )
695
+ _maybe_raise_one_or_more(failures)
696
+ return None # explicitly return None for mypy
697
+
698
+ @contextmanager
699
+ def _validating_response(self, scopes: list[str]) -> Generator[ConvertingResolver, None, None]:
611
700
  resolver = ConvertingResolver(
612
701
  self.location or "", self.raw_schema, nullable_name=self.nullable_name, is_response_schema=True
613
702
  )
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
703
  with in_scopes(resolver, scopes):
619
- try:
620
- jsonschema.validate(data, schema, cls=cls, resolver=resolver)
621
- except jsonschema.ValidationError as exc:
622
- exc_class = get_schema_validation_error(exc)
623
- ctx = failures.ValidationErrorContext.from_exception(exc)
624
- try:
625
- raise exc_class(ctx.title, context=ctx) from exc
626
- except Exception as exc:
627
- errors.append(exc)
628
- _maybe_raise_one_or_more(errors)
629
- return None # explicitly return None for mypy
704
+ yield resolver
630
705
 
631
706
  @property
632
707
  def rewritten_components(self) -> dict[str, Any]:
@@ -648,7 +723,7 @@ class BaseOpenAPISchema(BaseSchema):
648
723
  else:
649
724
  break
650
725
  else:
651
- target.update(traverse_schema(fast_deepcopy(schema), callback, self.nullable_name))
726
+ target.update(transform(deepclone(schema), callback, self.nullable_name))
652
727
  if self._inline_reference_cache:
653
728
  components[INLINED_REFERENCES_KEY] = self._inline_reference_cache
654
729
  self._rewritten_components = components
@@ -659,8 +734,8 @@ class BaseOpenAPISchema(BaseSchema):
659
734
 
660
735
  Inlining components helps `hypothesis-jsonschema` generate data that involves non-resolved references.
661
736
  """
662
- schema = fast_deepcopy(schema)
663
- schema = traverse_schema(schema, self._rewrite_references, self.resolver)
737
+ schema = deepclone(schema)
738
+ schema = transform(schema, self._rewrite_references, self.resolver)
664
739
  # Only add definitions that are reachable from the schema via references
665
740
  stack = [schema]
666
741
  seen = set()
@@ -675,8 +750,8 @@ class BaseOpenAPISchema(BaseSchema):
675
750
  pointer = reference[1:]
676
751
  resolved = resolve_pointer(self.rewritten_components, pointer)
677
752
  if resolved is UNRESOLVABLE:
678
- raise SchemaError(
679
- SchemaErrorType.OPEN_API_INVALID_SCHEMA,
753
+ raise LoaderError(
754
+ LoaderErrorKind.OPEN_API_INVALID_SCHEMA,
680
755
  message=f"Unresolvable JSON pointer in the schema: {pointer}",
681
756
  )
682
757
  if isinstance(resolved, dict):
@@ -710,7 +785,7 @@ class BaseOpenAPISchema(BaseSchema):
710
785
  if key not in self._inline_reference_cache:
711
786
  with resolver.resolving(reference) as resolved:
712
787
  # Resolved object also may have references
713
- self._inline_reference_cache[key] = traverse_schema(
788
+ self._inline_reference_cache[key] = transform(
714
789
  resolved, lambda s: self._rewrite_references(s, resolver)
715
790
  )
716
791
  # Rewrite the reference with the new location
@@ -718,13 +793,12 @@ class BaseOpenAPISchema(BaseSchema):
718
793
  return schema
719
794
 
720
795
 
721
- def _maybe_raise_one_or_more(errors: list[Exception]) -> None:
722
- if not errors:
796
+ def _maybe_raise_one_or_more(failures: list[Failure]) -> None:
797
+ if not failures:
723
798
  return
724
- elif len(errors) == 1:
725
- raise errors[0]
726
- else:
727
- raise MultipleFailures("\n\n".join(str(error) for error in errors), errors)
799
+ if len(failures) == 1:
800
+ raise failures[0] from None
801
+ raise FailureGroup(failures) from None
728
802
 
729
803
 
730
804
  def _make_reference_key(scopes: list[str], reference: str) -> str:
@@ -766,36 +840,63 @@ def in_scopes(resolver: jsonschema.RefResolver, scopes: list[str]) -> Generator[
766
840
  yield
767
841
 
768
842
 
769
- def operations_to_dict(
770
- operations: Generator[Result[APIOperation, OperationSchemaError], None, None],
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):
843
+ @dataclass
844
+ class MethodMap(Mapping):
782
845
  """Container for accessing API operations.
783
846
 
784
847
  Provides a more specific error message if API operation is not found.
785
848
  """
786
849
 
850
+ _parent: APIOperationMap
851
+ # Reference resolution scope
852
+ _scope: str
853
+ # Methods are stored for this path
854
+ _path: str
855
+ # Storage for definitions
856
+ _path_item: CaseInsensitiveDict
857
+
858
+ __slots__ = ("_parent", "_scope", "_path", "_path_item")
859
+
860
+ def __len__(self) -> int:
861
+ return len(self._path_item)
862
+
863
+ def __iter__(self) -> Iterator[str]:
864
+ return iter(self._path_item)
865
+
866
+ def _init_operation(self, method: str) -> APIOperation:
867
+ method = method.lower()
868
+ operation = self._path_item[method]
869
+ schema = cast(BaseOpenAPISchema, self._parent._schema)
870
+ cache = schema._operation_cache
871
+ path = self._path
872
+ scope = self._scope
873
+ traversal_key = (scope, path, method)
874
+ cached = cache.get_operation_by_traversal_key(traversal_key)
875
+ if cached is not None:
876
+ return cached
877
+ schema.resolver.push_scope(scope)
878
+ try:
879
+ resolved = schema._resolve_operation(operation)
880
+ finally:
881
+ schema.resolver.pop_scope()
882
+ parameters = schema._collect_operation_parameters(self._path_item, resolved)
883
+ initialized = schema.make_operation(path, method, parameters, operation, resolved, scope)
884
+ cache.insert_operation(initialized, traversal_key=traversal_key, operation_id=resolved.get("operationId"))
885
+ return initialized
886
+
787
887
  def __getitem__(self, item: str) -> APIOperation:
788
888
  try:
789
- return super().__getitem__(item)
790
- except KeyError as exc:
889
+ return self._init_operation(item)
890
+ except LookupError as exc:
791
891
  available_methods = ", ".join(map(str.upper, self))
792
- message = f"Method `{item}` not found. Available methods: {available_methods}"
793
- raise KeyError(message) from exc
892
+ message = f"Method `{item.upper()}` not found."
893
+ if available_methods:
894
+ message += f" Available methods: {available_methods}"
895
+ raise LookupError(message) from exc
794
896
 
795
897
 
796
898
  OPENAPI_20_DEFAULT_BODY_MEDIA_TYPE = "application/json"
797
899
  OPENAPI_20_DEFAULT_FORM_MEDIA_TYPE = "multipart/form-data"
798
- C = TypeVar("C", bound=Case)
799
900
 
800
901
 
801
902
  class SwaggerV20(BaseOpenAPISchema):
@@ -808,12 +909,9 @@ class SwaggerV20(BaseOpenAPISchema):
808
909
  links_field = "x-links"
809
910
 
810
911
  @property
811
- def spec_version(self) -> str:
812
- return self.raw_schema.get("swagger", "2.0")
813
-
814
- @property
815
- def verbose_name(self) -> str:
816
- return f"Swagger {self.spec_version}"
912
+ def specification(self) -> Specification:
913
+ version = self.raw_schema.get("swagger", "2.0")
914
+ return Specification.openapi(version=version)
817
915
 
818
916
  def _validate(self) -> None:
819
917
  SWAGGER_20_VALIDATOR.validate(self.raw_schema)
@@ -849,6 +947,8 @@ class SwaggerV20(BaseOpenAPISchema):
849
947
  for media_type in body_media_types:
850
948
  collected.append(OpenAPI20Body(definition=parameter, media_type=media_type))
851
949
  else:
950
+ if parameter["in"] in ("header", "cookie"):
951
+ check_header(parameter)
852
952
  collected.append(OpenAPI20Parameter(definition=parameter))
853
953
 
854
954
  if form_parameters:
@@ -859,20 +959,22 @@ class SwaggerV20(BaseOpenAPISchema):
859
959
  )
860
960
  return collected
861
961
 
862
- def get_strategies_from_examples(self, operation: APIOperation) -> list[SearchStrategy[Case]]:
962
+ def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
863
963
  """Get examples from the API operation."""
864
- return get_strategies_from_examples(operation, self.examples_field)
964
+ return get_strategies_from_examples(operation, **kwargs)
865
965
 
866
966
  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(fast_deepcopy(definition), scope)
967
+ scopes, definition = self.resolver.resolve_in_scope(definition, scope)
868
968
  schema = definition.get("schema")
869
969
  if not schema:
870
970
  return scopes, None
871
971
  # Extra conversion to JSON Schema is needed here if there was one $ref in the input
872
972
  # because it is not converted
873
- return scopes, to_json_schema_recursive(schema, self.nullable_name, is_response_schema=True)
973
+ return scopes, to_json_schema_recursive(
974
+ schema, self.nullable_name, is_response_schema=True, update_quantifiers=False
975
+ )
874
976
 
875
- def get_content_types(self, operation: APIOperation, response: GenericResponse) -> list[str]:
977
+ def get_content_types(self, operation: APIOperation, response: Response) -> list[str]:
876
978
  produces = operation.definition.raw.get("produces", None)
877
979
  if produces:
878
980
  return produces
@@ -882,7 +984,7 @@ class SwaggerV20(BaseOpenAPISchema):
882
984
  return serialization.serialize_swagger2_parameters(definitions)
883
985
 
884
986
  def prepare_multipart(
885
- self, form_data: FormData, operation: APIOperation
987
+ self, form_data: dict[str, Any], operation: APIOperation
886
988
  ) -> tuple[list | None, dict[str, Any] | None]:
887
989
  """Prepare form data for sending with `requests`.
888
990
 
@@ -903,7 +1005,7 @@ class SwaggerV20(BaseOpenAPISchema):
903
1005
  else:
904
1006
  files.append((name, file_value))
905
1007
 
906
- for parameter in operation.definition.parameters:
1008
+ for parameter in operation.body:
907
1009
  if isinstance(parameter, OpenAPI20CompositeBody):
908
1010
  for form_parameter in parameter.definition:
909
1011
  name = form_parameter.name
@@ -918,41 +1020,35 @@ class SwaggerV20(BaseOpenAPISchema):
918
1020
  return files or None, data or None
919
1021
 
920
1022
  def get_request_payload_content_types(self, operation: APIOperation) -> list[str]:
921
- return self._get_consumes_for_operation(operation.definition.resolved)
1023
+ return self._get_consumes_for_operation(operation.definition.raw)
922
1024
 
923
1025
  def make_case(
924
1026
  self,
925
1027
  *,
926
- case_cls: type[C],
927
1028
  operation: APIOperation,
928
- path_parameters: PathParameters | None = None,
929
- headers: Headers | None = None,
930
- cookies: Cookies | None = None,
931
- query: Query | None = None,
932
- body: Body | NotSet = NOT_SET,
1029
+ method: str | None = None,
1030
+ path: str | None = None,
1031
+ path_parameters: dict[str, Any] | None = None,
1032
+ headers: dict[str, Any] | None = None,
1033
+ cookies: dict[str, Any] | None = None,
1034
+ query: dict[str, Any] | None = None,
1035
+ body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet = NOT_SET,
933
1036
  media_type: str | None = None,
934
- ) -> C:
1037
+ meta: CaseMetadata | None = None,
1038
+ ) -> Case:
935
1039
  if body is not NOT_SET and media_type is None:
936
- # If the user wants to send payload, then there should be a media type, otherwise the payload is ignored
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
- )
948
- return case_cls(
1040
+ media_type = operation._get_default_media_type()
1041
+ return Case(
949
1042
  operation=operation,
1043
+ method=method or operation.method.upper(),
1044
+ path=path or operation.path,
950
1045
  path_parameters=path_parameters,
951
1046
  headers=CaseInsensitiveDict(headers) if headers is not None else headers,
952
1047
  cookies=cookies,
953
1048
  query=query,
954
1049
  body=body,
955
1050
  media_type=media_type,
1051
+ meta=meta,
956
1052
  )
957
1053
 
958
1054
  def _get_consumes_for_operation(self, definition: dict[str, Any]) -> list[str]:
@@ -970,6 +1066,8 @@ class SwaggerV20(BaseOpenAPISchema):
970
1066
 
971
1067
  def _get_payload_schema(self, definition: dict[str, Any], media_type: str) -> dict[str, Any] | None:
972
1068
  for parameter in definition.get("parameters", []):
1069
+ if "$ref" in parameter:
1070
+ _, parameter = self.resolver.resolve(parameter["$ref"])
973
1071
  if parameter["in"] == "body":
974
1072
  return parameter["schema"]
975
1073
  return None
@@ -985,15 +1083,12 @@ class OpenApi30(SwaggerV20):
985
1083
  links_field = "links"
986
1084
 
987
1085
  @property
988
- def spec_version(self) -> str:
989
- return self.raw_schema["openapi"]
990
-
991
- @property
992
- def verbose_name(self) -> str:
993
- return f"Open API {self.spec_version}"
1086
+ def specification(self) -> Specification:
1087
+ version = self.raw_schema["openapi"]
1088
+ return Specification.openapi(version=version)
994
1089
 
995
1090
  def _validate(self) -> None:
996
- if self.spec_version.startswith("3.1"):
1091
+ if self.specification.version.startswith("3.1"):
997
1092
  # Currently we treat Open API 3.1 as 3.0 in some regard
998
1093
  OPENAPI_31_VALIDATOR.validate(self.raw_schema)
999
1094
  else:
@@ -1012,7 +1107,12 @@ class OpenApi30(SwaggerV20):
1012
1107
  self, parameters: Iterable[dict[str, Any]], definition: dict[str, Any]
1013
1108
  ) -> list[OpenAPIParameter]:
1014
1109
  # Open API 3.0 has the `requestBody` keyword, which may contain multiple different payload variants.
1015
- collected: list[OpenAPIParameter] = [OpenAPI30Parameter(definition=parameter) for parameter in parameters]
1110
+ collected: list[OpenAPIParameter] = []
1111
+
1112
+ for parameter in parameters:
1113
+ if parameter["in"] in ("header", "cookie"):
1114
+ check_header(parameter)
1115
+ collected.append(OpenAPI30Parameter(definition=parameter))
1016
1116
  if "requestBody" in definition:
1017
1117
  required = definition["requestBody"].get("required", False)
1018
1118
  description = definition["requestBody"].get("description")
@@ -1023,34 +1123,38 @@ class OpenApi30(SwaggerV20):
1023
1123
  return collected
1024
1124
 
1025
1125
  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(fast_deepcopy(definition), scope)
1126
+ scopes, definition = self.resolver.resolve_in_scope(definition, scope)
1027
1127
  options = iter(definition.get("content", {}).values())
1028
1128
  option = next(options, None)
1029
1129
  # "schema" is an optional key in the `MediaType` object
1030
1130
  if option and "schema" in option:
1031
1131
  # Extra conversion to JSON Schema is needed here if there was one $ref in the input
1032
1132
  # because it is not converted
1033
- return scopes, to_json_schema_recursive(option["schema"], self.nullable_name, is_response_schema=True)
1133
+ return scopes, to_json_schema_recursive(
1134
+ option["schema"], self.nullable_name, is_response_schema=True, update_quantifiers=False
1135
+ )
1034
1136
  return scopes, None
1035
1137
 
1036
- def get_strategies_from_examples(self, operation: APIOperation) -> list[SearchStrategy[Case]]:
1138
+ def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
1037
1139
  """Get examples from the API operation."""
1038
- return get_strategies_from_examples(operation, self.examples_field)
1140
+ return get_strategies_from_examples(operation, **kwargs)
1039
1141
 
1040
- def get_content_types(self, operation: APIOperation, response: GenericResponse) -> list[str]:
1041
- definitions = self._get_response_definitions(operation, response)
1042
- if not definitions:
1142
+ def get_content_types(self, operation: APIOperation, response: Response) -> list[str]:
1143
+ resolved = self._get_response_definitions(operation, response)
1144
+ if not resolved:
1043
1145
  return []
1146
+ _, definitions = resolved
1044
1147
  return list(definitions.get("content", {}).keys())
1045
1148
 
1046
1149
  def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
1047
1150
  return serialization.serialize_openapi3_parameters(definitions)
1048
1151
 
1049
1152
  def get_request_payload_content_types(self, operation: APIOperation) -> list[str]:
1050
- return list(operation.definition.resolved["requestBody"]["content"].keys())
1153
+ request_body = self._resolve_until_no_references(operation.definition.raw["requestBody"])
1154
+ return list(request_body["content"])
1051
1155
 
1052
1156
  def prepare_multipart(
1053
- self, form_data: FormData, operation: APIOperation
1157
+ self, form_data: dict[str, Any], operation: APIOperation
1054
1158
  ) -> tuple[list | None, dict[str, Any] | None]:
1055
1159
  """Prepare form data for sending with `requests`.
1056
1160
 
@@ -1059,17 +1163,22 @@ class OpenApi30(SwaggerV20):
1059
1163
  :return: `files` and `data` values for `requests.request`.
1060
1164
  """
1061
1165
  files = []
1062
- content = operation.definition.resolved["requestBody"]["content"]
1166
+ definition = operation.definition.raw
1167
+ if "$ref" in definition["requestBody"]:
1168
+ body = self.resolver.resolve_all(definition["requestBody"], RECURSION_DEPTH_LIMIT)
1169
+ else:
1170
+ body = definition["requestBody"]
1171
+ content = body["content"]
1063
1172
  # Open API 3.0 requires media types to be present. We can get here only if the schema defines
1064
1173
  # the "multipart/form-data" media type, or any other more general media type that matches it (like `*/*`)
1065
1174
  for media_type, entry in content.items():
1066
- main, sub = parse_content_type(media_type)
1067
- if main in ("*", "multipart") and sub in ("*", "form-data"):
1068
- schema = entry["schema"]
1175
+ main, sub = media_types.parse(media_type)
1176
+ if main in ("*", "multipart") and sub in ("*", "form-data", "mixed"):
1177
+ schema = entry.get("schema")
1069
1178
  break
1070
1179
  else:
1071
1180
  raise InternalError("No 'multipart/form-data' media type found in the schema")
1072
- for name, property_schema in schema.get("properties", {}).items():
1181
+ for name, property_schema in (schema or {}).get("properties", {}).items():
1073
1182
  if name in form_data:
1074
1183
  if isinstance(form_data[name], list):
1075
1184
  files.extend([(name, item) for item in form_data[name]])
@@ -1087,8 +1196,8 @@ class OpenApi30(SwaggerV20):
1087
1196
  else:
1088
1197
  body = definition["requestBody"]
1089
1198
  if "content" in body:
1090
- main, sub = parse_content_type(media_type)
1199
+ main, sub = media_types.parse(media_type)
1091
1200
  for defined_media_type, item in body["content"].items():
1092
- if parse_content_type(defined_media_type) == (main, sub):
1201
+ if media_types.parse(defined_media_type) == (main, sub):
1093
1202
  return item["schema"]
1094
1203
  return None