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.
Files changed (70) hide show
  1. schemathesis/cli/commands/run/executor.py +1 -1
  2. schemathesis/cli/commands/run/handlers/base.py +28 -1
  3. schemathesis/cli/commands/run/handlers/cassettes.py +109 -137
  4. schemathesis/cli/commands/run/handlers/junitxml.py +5 -6
  5. schemathesis/cli/commands/run/handlers/output.py +7 -1
  6. schemathesis/cli/ext/fs.py +1 -1
  7. schemathesis/config/_diff_base.py +3 -1
  8. schemathesis/config/_operations.py +2 -0
  9. schemathesis/config/_phases.py +21 -4
  10. schemathesis/config/_projects.py +10 -2
  11. schemathesis/core/adapter.py +34 -0
  12. schemathesis/core/errors.py +29 -5
  13. schemathesis/core/jsonschema/__init__.py +13 -0
  14. schemathesis/core/jsonschema/bundler.py +163 -0
  15. schemathesis/{specs/openapi/constants.py → core/jsonschema/keywords.py} +0 -8
  16. schemathesis/core/jsonschema/references.py +122 -0
  17. schemathesis/core/jsonschema/types.py +41 -0
  18. schemathesis/core/media_types.py +6 -4
  19. schemathesis/core/parameters.py +37 -0
  20. schemathesis/core/transforms.py +25 -2
  21. schemathesis/core/validation.py +19 -0
  22. schemathesis/engine/context.py +1 -1
  23. schemathesis/engine/errors.py +11 -18
  24. schemathesis/engine/phases/stateful/_executor.py +1 -1
  25. schemathesis/engine/phases/unit/_executor.py +30 -13
  26. schemathesis/errors.py +4 -0
  27. schemathesis/filters.py +2 -2
  28. schemathesis/generation/coverage.py +87 -11
  29. schemathesis/generation/hypothesis/__init__.py +79 -2
  30. schemathesis/generation/hypothesis/builder.py +108 -70
  31. schemathesis/generation/meta.py +5 -14
  32. schemathesis/generation/overrides.py +17 -17
  33. schemathesis/pytest/lazy.py +1 -1
  34. schemathesis/pytest/plugin.py +1 -6
  35. schemathesis/schemas.py +22 -72
  36. schemathesis/specs/graphql/schemas.py +27 -16
  37. schemathesis/specs/openapi/_hypothesis.py +83 -68
  38. schemathesis/specs/openapi/adapter/__init__.py +10 -0
  39. schemathesis/specs/openapi/adapter/parameters.py +504 -0
  40. schemathesis/specs/openapi/adapter/protocol.py +57 -0
  41. schemathesis/specs/openapi/adapter/references.py +19 -0
  42. schemathesis/specs/openapi/adapter/responses.py +329 -0
  43. schemathesis/specs/openapi/adapter/security.py +141 -0
  44. schemathesis/specs/openapi/adapter/v2.py +28 -0
  45. schemathesis/specs/openapi/adapter/v3_0.py +28 -0
  46. schemathesis/specs/openapi/adapter/v3_1.py +28 -0
  47. schemathesis/specs/openapi/checks.py +99 -90
  48. schemathesis/specs/openapi/converter.py +114 -27
  49. schemathesis/specs/openapi/examples.py +210 -168
  50. schemathesis/specs/openapi/negative/__init__.py +12 -7
  51. schemathesis/specs/openapi/negative/mutations.py +68 -40
  52. schemathesis/specs/openapi/references.py +2 -175
  53. schemathesis/specs/openapi/schemas.py +142 -490
  54. schemathesis/specs/openapi/serialization.py +15 -7
  55. schemathesis/specs/openapi/stateful/__init__.py +17 -12
  56. schemathesis/specs/openapi/stateful/inference.py +13 -11
  57. schemathesis/specs/openapi/stateful/links.py +5 -20
  58. schemathesis/specs/openapi/types/__init__.py +3 -0
  59. schemathesis/specs/openapi/types/v3.py +68 -0
  60. schemathesis/specs/openapi/utils.py +1 -13
  61. schemathesis/transport/requests.py +3 -11
  62. schemathesis/transport/serialization.py +63 -27
  63. schemathesis/transport/wsgi.py +1 -8
  64. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/METADATA +2 -2
  65. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/RECORD +68 -53
  66. schemathesis/specs/openapi/parameters.py +0 -405
  67. schemathesis/specs/openapi/security.py +0 -162
  68. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/WHEEL +0 -0
  69. {schemathesis-4.1.4.dist-info → schemathesis-4.2.1.dist-info}/entry_points.txt +0 -0
  70. {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