schemathesis 4.1.4__py3-none-any.whl → 4.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemathesis/cli/commands/run/executor.py +1 -1
- schemathesis/cli/commands/run/handlers/base.py +28 -1
- schemathesis/cli/commands/run/handlers/cassettes.py +109 -137
- schemathesis/cli/commands/run/handlers/junitxml.py +5 -6
- schemathesis/cli/commands/run/handlers/output.py +7 -1
- schemathesis/cli/ext/fs.py +1 -1
- schemathesis/config/_diff_base.py +3 -1
- schemathesis/config/_operations.py +2 -0
- schemathesis/config/_phases.py +21 -4
- schemathesis/config/_projects.py +10 -2
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/errors.py +29 -5
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +163 -0
- schemathesis/{specs/openapi/constants.py → core/jsonschema/keywords.py} +0 -8
- schemathesis/core/jsonschema/references.py +122 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/media_types.py +6 -4
- schemathesis/core/parameters.py +37 -0
- schemathesis/core/transforms.py +25 -2
- schemathesis/core/validation.py +19 -0
- schemathesis/engine/context.py +1 -1
- schemathesis/engine/errors.py +11 -18
- schemathesis/engine/phases/stateful/_executor.py +1 -1
- schemathesis/engine/phases/unit/_executor.py +30 -13
- schemathesis/errors.py +4 -0
- schemathesis/filters.py +2 -2
- schemathesis/generation/coverage.py +87 -11
- schemathesis/generation/hypothesis/__init__.py +79 -2
- schemathesis/generation/hypothesis/builder.py +108 -70
- schemathesis/generation/meta.py +5 -14
- schemathesis/generation/overrides.py +17 -17
- schemathesis/pytest/lazy.py +1 -1
- schemathesis/pytest/plugin.py +1 -6
- schemathesis/schemas.py +22 -72
- schemathesis/specs/graphql/schemas.py +27 -16
- schemathesis/specs/openapi/_hypothesis.py +83 -68
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +504 -0
- schemathesis/specs/openapi/adapter/protocol.py +57 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +329 -0
- schemathesis/specs/openapi/adapter/security.py +141 -0
- schemathesis/specs/openapi/adapter/v2.py +28 -0
- schemathesis/specs/openapi/adapter/v3_0.py +28 -0
- schemathesis/specs/openapi/adapter/v3_1.py +28 -0
- schemathesis/specs/openapi/checks.py +99 -90
- schemathesis/specs/openapi/converter.py +114 -27
- schemathesis/specs/openapi/examples.py +210 -168
- schemathesis/specs/openapi/negative/__init__.py +12 -7
- schemathesis/specs/openapi/negative/mutations.py +68 -40
- schemathesis/specs/openapi/references.py +2 -175
- schemathesis/specs/openapi/schemas.py +142 -490
- schemathesis/specs/openapi/serialization.py +15 -7
- schemathesis/specs/openapi/stateful/__init__.py +17 -12
- schemathesis/specs/openapi/stateful/inference.py +13 -11
- schemathesis/specs/openapi/stateful/links.py +5 -20
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/v3.py +68 -0
- schemathesis/specs/openapi/utils.py +1 -13
- schemathesis/transport/requests.py +3 -11
- schemathesis/transport/serialization.py +63 -27
- schemathesis/transport/wsgi.py +1 -8
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/METADATA +2 -2
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/RECORD +68 -53
- schemathesis/specs/openapi/parameters.py +0 -405
- schemathesis/specs/openapi/security.py +0 -162
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/WHEEL +0 -0
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,504 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from abc import ABC, abstractmethod
|
4
|
+
from dataclasses import dataclass
|
5
|
+
from itertools import chain
|
6
|
+
from typing import TYPE_CHECKING, Any, Iterable, Iterator, Mapping, Sequence, cast
|
7
|
+
|
8
|
+
from schemathesis.core import NOT_SET, NotSet
|
9
|
+
from schemathesis.core.adapter import OperationParameter
|
10
|
+
from schemathesis.core.errors import InvalidSchema
|
11
|
+
from schemathesis.core.jsonschema import BundleError, Bundler
|
12
|
+
from schemathesis.core.jsonschema.bundler import BUNDLE_STORAGE_KEY
|
13
|
+
from schemathesis.core.jsonschema.types import JsonSchema, JsonSchemaObject
|
14
|
+
from schemathesis.core.parameters import HEADER_LOCATIONS, ParameterLocation
|
15
|
+
from schemathesis.core.validation import check_header_name
|
16
|
+
from schemathesis.schemas import ParameterSet
|
17
|
+
from schemathesis.specs.openapi.adapter.protocol import SpecificationAdapter
|
18
|
+
from schemathesis.specs.openapi.adapter.references import maybe_resolve
|
19
|
+
from schemathesis.specs.openapi.converter import to_json_schema
|
20
|
+
|
21
|
+
if TYPE_CHECKING:
|
22
|
+
from schemathesis.core.compat import RefResolver
|
23
|
+
|
24
|
+
|
25
|
+
MISSING_SCHEMA_OR_CONTENT_MESSAGE = (
|
26
|
+
"Can not generate data for {location} parameter `{name}`! "
|
27
|
+
"It should have either `schema` or `content` keywords defined"
|
28
|
+
)
|
29
|
+
|
30
|
+
INVALID_SCHEMA_MESSAGE = (
|
31
|
+
"Can not generate data for {location} parameter `{name}`! Its schema should be an object or boolean, got {schema}"
|
32
|
+
)
|
33
|
+
|
34
|
+
FORM_MEDIA_TYPES = frozenset(["multipart/form-data", "application/x-www-form-urlencoded"])
|
35
|
+
|
36
|
+
|
37
|
+
@dataclass
|
38
|
+
class OpenApiComponent(ABC):
|
39
|
+
definition: Mapping[str, Any]
|
40
|
+
is_required: bool
|
41
|
+
adapter: SpecificationAdapter
|
42
|
+
|
43
|
+
__slots__ = (
|
44
|
+
"definition",
|
45
|
+
"is_required",
|
46
|
+
"adapter",
|
47
|
+
"_optimized_schema",
|
48
|
+
"_unoptimized_schema",
|
49
|
+
"_raw_schema",
|
50
|
+
"_examples",
|
51
|
+
)
|
52
|
+
|
53
|
+
def __post_init__(self) -> None:
|
54
|
+
self._optimized_schema: JsonSchema | NotSet = NOT_SET
|
55
|
+
self._unoptimized_schema: JsonSchema | NotSet = NOT_SET
|
56
|
+
self._raw_schema: JsonSchema | NotSet = NOT_SET
|
57
|
+
self._examples: list | NotSet = NOT_SET
|
58
|
+
|
59
|
+
@property
|
60
|
+
def optimized_schema(self) -> JsonSchema:
|
61
|
+
"""JSON schema optimized for data generation."""
|
62
|
+
if self._optimized_schema is NOT_SET:
|
63
|
+
self._optimized_schema = self._build_schema(optimize=True)
|
64
|
+
assert not isinstance(self._optimized_schema, NotSet)
|
65
|
+
return self._optimized_schema
|
66
|
+
|
67
|
+
@property
|
68
|
+
def unoptimized_schema(self) -> JsonSchema:
|
69
|
+
"""JSON schema preserving original constraint structure."""
|
70
|
+
if self._unoptimized_schema is NOT_SET:
|
71
|
+
self._unoptimized_schema = self._build_schema(optimize=False)
|
72
|
+
assert not isinstance(self._unoptimized_schema, NotSet)
|
73
|
+
return self._unoptimized_schema
|
74
|
+
|
75
|
+
@property
|
76
|
+
def raw_schema(self) -> JsonSchema:
|
77
|
+
"""Raw schema extracted from definition before JSON Schema conversion."""
|
78
|
+
if self._raw_schema is NOT_SET:
|
79
|
+
self._raw_schema = self._get_raw_schema()
|
80
|
+
assert not isinstance(self._raw_schema, NotSet)
|
81
|
+
return self._raw_schema
|
82
|
+
|
83
|
+
@abstractmethod
|
84
|
+
def _get_raw_schema(self) -> JsonSchema:
|
85
|
+
"""Get the raw schema for this component."""
|
86
|
+
raise NotImplementedError
|
87
|
+
|
88
|
+
@abstractmethod
|
89
|
+
def _get_default_type(self) -> str | None:
|
90
|
+
"""Get default type for this parameter."""
|
91
|
+
raise NotImplementedError
|
92
|
+
|
93
|
+
def _build_schema(self, *, optimize: bool) -> JsonSchema:
|
94
|
+
"""Build JSON schema with optional optimizations for data generation."""
|
95
|
+
schema = to_json_schema(
|
96
|
+
self.raw_schema,
|
97
|
+
nullable_keyword=self.adapter.nullable_keyword,
|
98
|
+
update_quantifiers=optimize,
|
99
|
+
)
|
100
|
+
|
101
|
+
# Missing the `type` keyword may significantly slowdown data generation, ensure it is set
|
102
|
+
default_type = self._get_default_type()
|
103
|
+
if isinstance(schema, dict):
|
104
|
+
if default_type is not None:
|
105
|
+
schema.setdefault("type", default_type)
|
106
|
+
elif schema is True and default_type is not None:
|
107
|
+
# Restrict such cases too
|
108
|
+
schema = {"type": default_type}
|
109
|
+
|
110
|
+
return schema
|
111
|
+
|
112
|
+
@property
|
113
|
+
def examples(self) -> list:
|
114
|
+
"""All examples extracted from definition.
|
115
|
+
|
116
|
+
Combines both single 'example' and 'examples' container values.
|
117
|
+
"""
|
118
|
+
if self._examples is NOT_SET:
|
119
|
+
self._examples = self._extract_examples()
|
120
|
+
assert not isinstance(self._examples, NotSet)
|
121
|
+
return self._examples
|
122
|
+
|
123
|
+
def _extract_examples(self) -> list[object]:
|
124
|
+
"""Extract examples from both single example and examples container."""
|
125
|
+
examples: list[object] = []
|
126
|
+
|
127
|
+
container = self.definition.get(self.adapter.examples_container_keyword)
|
128
|
+
if isinstance(container, dict):
|
129
|
+
examples.extend(ex["value"] for ex in container.values() if isinstance(ex, dict) and "value" in ex)
|
130
|
+
elif isinstance(container, list):
|
131
|
+
examples.extend(container)
|
132
|
+
|
133
|
+
example = self.definition.get(self.adapter.example_keyword, NOT_SET)
|
134
|
+
if example is not NOT_SET:
|
135
|
+
examples.append(example)
|
136
|
+
|
137
|
+
return examples
|
138
|
+
|
139
|
+
|
140
|
+
@dataclass
|
141
|
+
class OpenApiParameter(OpenApiComponent):
|
142
|
+
"""OpenAPI operation parameter."""
|
143
|
+
|
144
|
+
@classmethod
|
145
|
+
def from_definition(cls, *, definition: Mapping[str, Any], adapter: SpecificationAdapter) -> OpenApiParameter:
|
146
|
+
is_required = definition.get("required", False)
|
147
|
+
return cls(definition=definition, is_required=is_required, adapter=adapter)
|
148
|
+
|
149
|
+
@property
|
150
|
+
def name(self) -> str:
|
151
|
+
"""Parameter name."""
|
152
|
+
return self.definition["name"]
|
153
|
+
|
154
|
+
@property
|
155
|
+
def location(self) -> ParameterLocation:
|
156
|
+
"""Where this parameter is located."""
|
157
|
+
try:
|
158
|
+
return ParameterLocation(self.definition["in"])
|
159
|
+
except ValueError:
|
160
|
+
return ParameterLocation.UNKNOWN
|
161
|
+
|
162
|
+
def _get_raw_schema(self) -> JsonSchema:
|
163
|
+
"""Get raw parameter schema."""
|
164
|
+
return self.adapter.extract_parameter_schema(self.definition)
|
165
|
+
|
166
|
+
def _get_default_type(self) -> str | None:
|
167
|
+
"""Return default type if parameter is in string-type location."""
|
168
|
+
return "string" if self.location.is_in_header else None
|
169
|
+
|
170
|
+
|
171
|
+
@dataclass
|
172
|
+
class OpenApiBody(OpenApiComponent):
|
173
|
+
"""OpenAPI request body."""
|
174
|
+
|
175
|
+
media_type: str
|
176
|
+
resource_name: str | None
|
177
|
+
|
178
|
+
__slots__ = (
|
179
|
+
"definition",
|
180
|
+
"is_required",
|
181
|
+
"media_type",
|
182
|
+
"resource_name",
|
183
|
+
"adapter",
|
184
|
+
"_optimized_schema",
|
185
|
+
"_unoptimized_schema",
|
186
|
+
"_raw_schema",
|
187
|
+
"_examples",
|
188
|
+
)
|
189
|
+
|
190
|
+
@classmethod
|
191
|
+
def from_definition(
|
192
|
+
cls,
|
193
|
+
*,
|
194
|
+
definition: Mapping[str, Any],
|
195
|
+
is_required: bool,
|
196
|
+
media_type: str,
|
197
|
+
resource_name: str | None,
|
198
|
+
adapter: SpecificationAdapter,
|
199
|
+
) -> OpenApiBody:
|
200
|
+
return cls(
|
201
|
+
definition=definition,
|
202
|
+
is_required=is_required,
|
203
|
+
media_type=media_type,
|
204
|
+
resource_name=resource_name,
|
205
|
+
adapter=adapter,
|
206
|
+
)
|
207
|
+
|
208
|
+
@classmethod
|
209
|
+
def from_form_parameters(
|
210
|
+
cls,
|
211
|
+
*,
|
212
|
+
definition: Mapping[str, Any],
|
213
|
+
media_type: str,
|
214
|
+
adapter: SpecificationAdapter,
|
215
|
+
) -> OpenApiBody:
|
216
|
+
return cls(
|
217
|
+
definition=definition,
|
218
|
+
is_required=True,
|
219
|
+
media_type=media_type,
|
220
|
+
resource_name=None,
|
221
|
+
adapter=adapter,
|
222
|
+
)
|
223
|
+
|
224
|
+
@property
|
225
|
+
def location(self) -> ParameterLocation:
|
226
|
+
return ParameterLocation.BODY
|
227
|
+
|
228
|
+
@property
|
229
|
+
def name(self) -> str:
|
230
|
+
# The name doesn't matter but is here for the interface completeness.
|
231
|
+
return "body"
|
232
|
+
|
233
|
+
def _get_raw_schema(self) -> JsonSchema:
|
234
|
+
"""Get raw body schema."""
|
235
|
+
return self.definition.get("schema", {})
|
236
|
+
|
237
|
+
def _get_default_type(self) -> str | None:
|
238
|
+
"""Return default type if body is a form type."""
|
239
|
+
return "object" if self.media_type in FORM_MEDIA_TYPES else None
|
240
|
+
|
241
|
+
|
242
|
+
OPENAPI_20_EXCLUDE_KEYS = frozenset(["required", "name", "in", "title", "description"])
|
243
|
+
|
244
|
+
|
245
|
+
def extract_parameter_schema_v2(parameter: Mapping[str, Any]) -> JsonSchemaObject:
|
246
|
+
# In Open API 2.0, schema for non-body parameters lives directly in the parameter definition
|
247
|
+
return {key: value for key, value in parameter.items() if key not in OPENAPI_20_EXCLUDE_KEYS}
|
248
|
+
|
249
|
+
|
250
|
+
def extract_parameter_schema_v3(parameter: Mapping[str, Any]) -> JsonSchema:
|
251
|
+
if "schema" in parameter:
|
252
|
+
if not isinstance(parameter["schema"], (dict, bool)):
|
253
|
+
raise InvalidSchema(
|
254
|
+
INVALID_SCHEMA_MESSAGE.format(
|
255
|
+
location=parameter.get("in", ""),
|
256
|
+
name=parameter.get("name", "<UNKNOWN>"),
|
257
|
+
schema=parameter["schema"],
|
258
|
+
),
|
259
|
+
)
|
260
|
+
return parameter["schema"]
|
261
|
+
# https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#fixed-fields-10
|
262
|
+
# > The map MUST only contain one entry.
|
263
|
+
try:
|
264
|
+
content = parameter["content"]
|
265
|
+
except KeyError as exc:
|
266
|
+
raise InvalidSchema(
|
267
|
+
MISSING_SCHEMA_OR_CONTENT_MESSAGE.format(
|
268
|
+
location=parameter.get("in", ""), name=parameter.get("name", "<UNKNOWN>")
|
269
|
+
),
|
270
|
+
) from exc
|
271
|
+
options = iter(content.values())
|
272
|
+
media_type_object = next(options)
|
273
|
+
return media_type_object.get("schema", {})
|
274
|
+
|
275
|
+
|
276
|
+
def _bundle_parameter(parameter: Mapping, resolver: RefResolver, bundler: Bundler) -> dict:
|
277
|
+
"""Bundle a parameter definition to make it self-contained."""
|
278
|
+
_, definition = maybe_resolve(parameter, resolver, "")
|
279
|
+
schema = definition.get("schema")
|
280
|
+
if schema is not None:
|
281
|
+
definition = {k: v for k, v in definition.items() if k != "schema"}
|
282
|
+
try:
|
283
|
+
definition["schema"] = bundler.bundle(schema, resolver, inline_recursive=True)
|
284
|
+
except BundleError as exc:
|
285
|
+
location = parameter.get("in", "")
|
286
|
+
name = parameter.get("name", "<UNKNOWN>")
|
287
|
+
raise InvalidSchema.from_bundle_error(exc, location, name) from exc
|
288
|
+
return cast(dict, definition)
|
289
|
+
|
290
|
+
|
291
|
+
OPENAPI_20_DEFAULT_BODY_MEDIA_TYPE = "application/json"
|
292
|
+
OPENAPI_20_DEFAULT_FORM_MEDIA_TYPE = "multipart/form-data"
|
293
|
+
|
294
|
+
|
295
|
+
def iter_parameters_v2(
|
296
|
+
definition: Mapping[str, Any],
|
297
|
+
shared_parameters: Sequence[Mapping[str, Any]],
|
298
|
+
default_media_types: list[str],
|
299
|
+
resolver: RefResolver,
|
300
|
+
adapter: SpecificationAdapter,
|
301
|
+
) -> Iterator[OperationParameter]:
|
302
|
+
media_types = definition.get("consumes", default_media_types)
|
303
|
+
# For `in=body` parameters, we imply `application/json` as the default media type because it is the most common.
|
304
|
+
body_media_types = media_types or (OPENAPI_20_DEFAULT_BODY_MEDIA_TYPE,)
|
305
|
+
# If an API operation has parameters with `in=formData`, Schemathesis should know how to serialize it.
|
306
|
+
# We can't be 100% sure what media type is expected by the server and chose `multipart/form-data` as
|
307
|
+
# the default because it is broader since it allows us to upload files.
|
308
|
+
form_data_media_types = media_types or (OPENAPI_20_DEFAULT_FORM_MEDIA_TYPE,)
|
309
|
+
|
310
|
+
form_parameters = []
|
311
|
+
bundler = Bundler()
|
312
|
+
for parameter in chain(definition.get("parameters", []), shared_parameters):
|
313
|
+
parameter = _bundle_parameter(parameter, resolver, bundler)
|
314
|
+
if parameter["in"] in HEADER_LOCATIONS:
|
315
|
+
check_header_name(parameter["name"])
|
316
|
+
|
317
|
+
if parameter["in"] == "formData":
|
318
|
+
# We need to gather form parameters first before creating a composite parameter for them
|
319
|
+
form_parameters.append(parameter)
|
320
|
+
elif parameter["in"] == ParameterLocation.BODY:
|
321
|
+
# Take the original definition & extract the resource_name from there
|
322
|
+
resource_name = None
|
323
|
+
for param in chain(definition.get("parameters", []), shared_parameters):
|
324
|
+
_, param = maybe_resolve(param, resolver, "")
|
325
|
+
if param.get("in") == ParameterLocation.BODY:
|
326
|
+
if "$ref" in param["schema"]:
|
327
|
+
resource_name = _get_resource_name(param["schema"]["$ref"])
|
328
|
+
for media_type in body_media_types:
|
329
|
+
yield OpenApiBody.from_definition(
|
330
|
+
definition=parameter,
|
331
|
+
is_required=parameter.get("required", False),
|
332
|
+
media_type=media_type,
|
333
|
+
resource_name=resource_name,
|
334
|
+
adapter=adapter,
|
335
|
+
)
|
336
|
+
else:
|
337
|
+
yield OpenApiParameter.from_definition(definition=parameter, adapter=adapter)
|
338
|
+
|
339
|
+
if form_parameters:
|
340
|
+
form_data = form_data_to_json_schema(form_parameters)
|
341
|
+
for media_type in form_data_media_types:
|
342
|
+
# Individual `formData` parameters are joined into a single "composite" one.
|
343
|
+
yield OpenApiBody.from_form_parameters(definition=form_data, media_type=media_type, adapter=adapter)
|
344
|
+
|
345
|
+
|
346
|
+
def iter_parameters_v3(
|
347
|
+
definition: Mapping[str, Any],
|
348
|
+
shared_parameters: Sequence[Mapping[str, Any]],
|
349
|
+
default_media_types: list[str],
|
350
|
+
resolver: RefResolver,
|
351
|
+
adapter: SpecificationAdapter,
|
352
|
+
) -> Iterator[OperationParameter]:
|
353
|
+
# Open API 3.0 has the `requestBody` keyword, which may contain multiple different payload variants.
|
354
|
+
# TODO: Typing
|
355
|
+
operation = definition
|
356
|
+
|
357
|
+
bundler = Bundler()
|
358
|
+
for parameter in chain(definition.get("parameters", []), shared_parameters):
|
359
|
+
parameter = _bundle_parameter(parameter, resolver, bundler)
|
360
|
+
if parameter["in"] in HEADER_LOCATIONS:
|
361
|
+
check_header_name(parameter["name"])
|
362
|
+
|
363
|
+
yield OpenApiParameter.from_definition(definition=parameter, adapter=adapter)
|
364
|
+
|
365
|
+
request_body_or_ref = operation.get("requestBody")
|
366
|
+
if request_body_or_ref is not None:
|
367
|
+
scope, request_body_or_ref = maybe_resolve(request_body_or_ref, resolver, "")
|
368
|
+
# It could be an object inside `requestBodies`, which could be a reference itself
|
369
|
+
_, request_body = maybe_resolve(request_body_or_ref, resolver, scope)
|
370
|
+
|
371
|
+
required = request_body.get("required", False)
|
372
|
+
for media_type, content in request_body["content"].items():
|
373
|
+
resource_name = None
|
374
|
+
schema = content.get("schema")
|
375
|
+
if isinstance(schema, dict):
|
376
|
+
content = dict(content)
|
377
|
+
if "$ref" in schema:
|
378
|
+
resource_name = _get_resource_name(schema["$ref"])
|
379
|
+
try:
|
380
|
+
to_bundle = cast(dict[str, Any], schema)
|
381
|
+
bundled = bundler.bundle(to_bundle, resolver, inline_recursive=True)
|
382
|
+
content["schema"] = bundled
|
383
|
+
except BundleError as exc:
|
384
|
+
raise InvalidSchema.from_bundle_error(exc, "body") from exc
|
385
|
+
yield OpenApiBody.from_definition(
|
386
|
+
definition=content,
|
387
|
+
is_required=required,
|
388
|
+
media_type=media_type,
|
389
|
+
resource_name=resource_name,
|
390
|
+
adapter=adapter,
|
391
|
+
)
|
392
|
+
|
393
|
+
|
394
|
+
def _get_resource_name(reference: str) -> str:
|
395
|
+
return reference.rsplit("/", maxsplit=1)[1]
|
396
|
+
|
397
|
+
|
398
|
+
def build_path_parameter_v2(kwargs: Mapping[str, Any]) -> OpenApiParameter:
|
399
|
+
from schemathesis.specs.openapi.adapter import v2
|
400
|
+
|
401
|
+
return OpenApiParameter.from_definition(
|
402
|
+
definition={"in": ParameterLocation.PATH.value, "required": True, "type": "string", "minLength": 1, **kwargs},
|
403
|
+
adapter=v2,
|
404
|
+
)
|
405
|
+
|
406
|
+
|
407
|
+
def build_path_parameter_v3_0(kwargs: Mapping[str, Any]) -> OpenApiParameter:
|
408
|
+
from schemathesis.specs.openapi.adapter import v3_0
|
409
|
+
|
410
|
+
return OpenApiParameter.from_definition(
|
411
|
+
definition={
|
412
|
+
"in": ParameterLocation.PATH.value,
|
413
|
+
"required": True,
|
414
|
+
"schema": {"type": "string", "minLength": 1},
|
415
|
+
**kwargs,
|
416
|
+
},
|
417
|
+
adapter=v3_0,
|
418
|
+
)
|
419
|
+
|
420
|
+
|
421
|
+
def build_path_parameter_v3_1(kwargs: Mapping[str, Any]) -> OpenApiParameter:
|
422
|
+
from schemathesis.specs.openapi.adapter import v3_1
|
423
|
+
|
424
|
+
return OpenApiParameter.from_definition(
|
425
|
+
definition={
|
426
|
+
"in": ParameterLocation.PATH.value,
|
427
|
+
"required": True,
|
428
|
+
"schema": {"type": "string", "minLength": 1},
|
429
|
+
**kwargs,
|
430
|
+
},
|
431
|
+
adapter=v3_1,
|
432
|
+
)
|
433
|
+
|
434
|
+
|
435
|
+
@dataclass
|
436
|
+
class OpenApiParameterSet(ParameterSet):
|
437
|
+
items: list[OpenApiParameter]
|
438
|
+
|
439
|
+
__slots__ = ("items", "_schema")
|
440
|
+
|
441
|
+
def __init__(self, items: list[OpenApiParameter] | None = None) -> None:
|
442
|
+
self.items = items or []
|
443
|
+
self._schema: dict | NotSet = NOT_SET
|
444
|
+
|
445
|
+
@property
|
446
|
+
def schema(self) -> dict[str, Any]:
|
447
|
+
if self._schema is NOT_SET:
|
448
|
+
self._schema = parameters_to_json_schema(self.items)
|
449
|
+
assert not isinstance(self._schema, NotSet)
|
450
|
+
return self._schema
|
451
|
+
|
452
|
+
|
453
|
+
COMBINED_FORM_DATA_MARKER = "x-schemathesis-form-parameter"
|
454
|
+
|
455
|
+
|
456
|
+
def form_data_to_json_schema(parameters: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
457
|
+
"""Convert raw form parameter definitions to a JSON Schema."""
|
458
|
+
parameter_data = (
|
459
|
+
(param["name"], extract_parameter_schema_v2(param), param.get("required", False)) for param in parameters
|
460
|
+
)
|
461
|
+
|
462
|
+
merged = _merge_parameters_to_object_schema(parameter_data)
|
463
|
+
|
464
|
+
return {"schema": merged, COMBINED_FORM_DATA_MARKER: True}
|
465
|
+
|
466
|
+
|
467
|
+
def parameters_to_json_schema(parameters: Iterable[OpenApiParameter]) -> dict[str, Any]:
|
468
|
+
"""Convert multiple Open API parameters to a JSON Schema."""
|
469
|
+
parameter_data = ((param.name, param.optimized_schema, param.is_required) for param in parameters)
|
470
|
+
|
471
|
+
return _merge_parameters_to_object_schema(parameter_data)
|
472
|
+
|
473
|
+
|
474
|
+
def _merge_parameters_to_object_schema(parameters: Iterable[tuple[str, Any, bool]]) -> dict[str, Any]:
|
475
|
+
"""Merge parameter data into a JSON Schema object."""
|
476
|
+
properties = {}
|
477
|
+
required = []
|
478
|
+
bundled = {}
|
479
|
+
|
480
|
+
for name, subschema, is_required in parameters:
|
481
|
+
# Extract bundled data if present
|
482
|
+
if isinstance(subschema, dict) and BUNDLE_STORAGE_KEY in subschema:
|
483
|
+
subschema = dict(subschema)
|
484
|
+
subschema_bundle = subschema.pop(BUNDLE_STORAGE_KEY)
|
485
|
+
# NOTE: Bundled schema names are not overlapping as they were bundled via the same `Bundler` that
|
486
|
+
# ensures unique names
|
487
|
+
bundled.update(subschema_bundle)
|
488
|
+
|
489
|
+
properties[name] = subschema
|
490
|
+
|
491
|
+
# Avoid duplicate entries in required
|
492
|
+
if is_required and name not in required:
|
493
|
+
required.append(name)
|
494
|
+
|
495
|
+
merged = {
|
496
|
+
"properties": properties,
|
497
|
+
"additionalProperties": False,
|
498
|
+
"type": "object",
|
499
|
+
"required": required,
|
500
|
+
}
|
501
|
+
if bundled:
|
502
|
+
merged[BUNDLE_STORAGE_KEY] = bundled
|
503
|
+
|
504
|
+
return merged
|
@@ -0,0 +1,57 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Mapping, Protocol, Sequence, Union
|
4
|
+
|
5
|
+
if TYPE_CHECKING:
|
6
|
+
from jsonschema.protocols import Validator
|
7
|
+
|
8
|
+
from schemathesis.core.adapter import OperationParameter
|
9
|
+
from schemathesis.core.compat import RefResolver
|
10
|
+
from schemathesis.core.jsonschema.types import JsonSchema
|
11
|
+
|
12
|
+
IterResponseExamples = Callable[[Mapping[str, Any], str], Iterator[tuple[str, object]]]
|
13
|
+
ExtractResponseSchema = Callable[[Mapping[str, Any], "RefResolver", str, str], Union["JsonSchema", None]]
|
14
|
+
ExtractHeaderSchema = Callable[[Mapping[str, Any], "RefResolver", str, str], "JsonSchema"]
|
15
|
+
ExtractParameterSchema = Callable[[Mapping[str, Any]], "JsonSchema"]
|
16
|
+
ExtractSecurityParameters = Callable[
|
17
|
+
[Mapping[str, Any], Mapping[str, Any], "RefResolver"],
|
18
|
+
Iterator[Mapping[str, Any]],
|
19
|
+
]
|
20
|
+
IterParameters = Callable[
|
21
|
+
[Mapping[str, Any], Sequence[Mapping[str, Any]], list[str], "RefResolver", "SpecificationAdapter"],
|
22
|
+
Iterable["OperationParameter"],
|
23
|
+
]
|
24
|
+
BuildPathParameter = Callable[[Mapping[str, Any]], "OperationParameter"]
|
25
|
+
|
26
|
+
|
27
|
+
class SpecificationAdapter(Protocol):
|
28
|
+
"""Protocol for abstracting over different API specification formats (OpenAPI 2/3, etc.)."""
|
29
|
+
|
30
|
+
# Keyword used to mark nullable fields (e.g., "x-nullable" in OpenAPI 2.0, "nullable" in 3.x)
|
31
|
+
nullable_keyword: str
|
32
|
+
# Keyword used for required / optional headers. Open API 2.0 does not expect `required` there
|
33
|
+
header_required_keyword: str
|
34
|
+
# Keyword for Open API links
|
35
|
+
links_keyword: str
|
36
|
+
# Keyword for a single example
|
37
|
+
example_keyword: str
|
38
|
+
# Keyword for examples container
|
39
|
+
examples_container_keyword: str
|
40
|
+
|
41
|
+
# Function to extract schema from parameter definition
|
42
|
+
extract_parameter_schema: ExtractParameterSchema
|
43
|
+
# Function to extract response schema from specification
|
44
|
+
extract_response_schema: ExtractResponseSchema
|
45
|
+
# Function to extract header schema from specification
|
46
|
+
extract_header_schema: ExtractHeaderSchema
|
47
|
+
# Function to iterate over API operation parameters
|
48
|
+
iter_parameters: IterParameters
|
49
|
+
# Function to create a new path parameter
|
50
|
+
build_path_parameter: BuildPathParameter
|
51
|
+
# Function to extract examples from response definition
|
52
|
+
iter_response_examples: IterResponseExamples
|
53
|
+
# Function to extract security parameters for an API operation
|
54
|
+
extract_security_parameters: ExtractSecurityParameters
|
55
|
+
|
56
|
+
# JSON Schema validator class appropriate for this specification version
|
57
|
+
jsonschema_validator_cls: type[Validator]
|
@@ -0,0 +1,19 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Any, Mapping
|
4
|
+
|
5
|
+
if TYPE_CHECKING:
|
6
|
+
from schemathesis.core.compat import RefResolver
|
7
|
+
|
8
|
+
|
9
|
+
def maybe_resolve(item: Mapping[str, Any], resolver: RefResolver, scope: str) -> tuple[str, Mapping[str, Any]]:
|
10
|
+
reference = item.get("$ref")
|
11
|
+
if reference is not None:
|
12
|
+
# TODO: this one should be synchronized
|
13
|
+
resolver.push_scope(scope)
|
14
|
+
try:
|
15
|
+
return resolver.resolve(reference)
|
16
|
+
finally:
|
17
|
+
resolver.pop_scope()
|
18
|
+
|
19
|
+
return scope, item
|