schemathesis 3.15.4__py3-none-any.whl → 4.4.2__py3-none-any.whl

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