schemathesis 4.1.4__py3-none-any.whl → 4.2.0__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 +10 -12
- 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 +4 -1
- 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.0.dist-info}/METADATA +2 -2
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.0.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.0.dist-info}/WHEEL +0 -0
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -10,10 +10,13 @@ from typing import Any, Callable, Sequence, TypeVar
|
|
10
10
|
from hypothesis import reject
|
11
11
|
from hypothesis import strategies as st
|
12
12
|
from hypothesis.strategies._internal.featureflags import FeatureStrategy
|
13
|
+
from hypothesis_jsonschema._canonicalise import canonicalish
|
13
14
|
|
15
|
+
from schemathesis.core.jsonschema import get_type
|
16
|
+
from schemathesis.core.jsonschema.types import JsonSchemaObject
|
17
|
+
from schemathesis.core.parameters import ParameterLocation
|
14
18
|
from schemathesis.core.transforms import deepclone
|
15
19
|
|
16
|
-
from ..utils import get_type, is_header_location
|
17
20
|
from .types import Draw, Schema
|
18
21
|
from .utils import can_negate
|
19
22
|
|
@@ -70,29 +73,38 @@ class MutationContext:
|
|
70
73
|
keywords: Schema # only keywords
|
71
74
|
non_keywords: Schema # everything else
|
72
75
|
# Schema location within API operation (header, query, etc)
|
73
|
-
location:
|
76
|
+
location: ParameterLocation
|
74
77
|
# Payload media type, if available
|
75
78
|
media_type: str | None
|
76
79
|
|
77
80
|
__slots__ = ("keywords", "non_keywords", "location", "media_type")
|
78
81
|
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
+
def __init__(
|
83
|
+
self,
|
84
|
+
*,
|
85
|
+
keywords: Schema,
|
86
|
+
non_keywords: Schema,
|
87
|
+
location: ParameterLocation,
|
88
|
+
media_type: str | None,
|
89
|
+
) -> None:
|
90
|
+
self.keywords = keywords
|
91
|
+
self.non_keywords = non_keywords
|
92
|
+
self.location = location
|
93
|
+
self.media_type = media_type
|
82
94
|
|
83
95
|
@property
|
84
96
|
def is_path_location(self) -> bool:
|
85
|
-
return self.location ==
|
97
|
+
return self.location == ParameterLocation.PATH
|
86
98
|
|
87
99
|
@property
|
88
100
|
def is_query_location(self) -> bool:
|
89
|
-
return self.location ==
|
101
|
+
return self.location == ParameterLocation.QUERY
|
90
102
|
|
91
103
|
def mutate(self, draw: Draw) -> Schema:
|
92
104
|
# On the top level, Schemathesis creates "object" schemas for all parameter "in" values except "body", which is
|
93
105
|
# taken as-is. Therefore, we can only apply mutations that won't change the Open API semantics of the schema.
|
94
106
|
mutations: list[Mutation]
|
95
|
-
if self.location in (
|
107
|
+
if self.location in (ParameterLocation.HEADER, ParameterLocation.COOKIE, ParameterLocation.QUERY):
|
96
108
|
# These objects follow this pattern:
|
97
109
|
# {
|
98
110
|
# "properties": properties,
|
@@ -127,7 +139,7 @@ class MutationContext:
|
|
127
139
|
# If we failed to apply anything, then reject the whole case
|
128
140
|
reject() # type: ignore
|
129
141
|
new_schema.update(self.non_keywords)
|
130
|
-
if self.
|
142
|
+
if self.location.is_in_header:
|
131
143
|
# All headers should have names that can be sent over network
|
132
144
|
new_schema["propertyNames"] = {"type": "string", "format": "_header_name"}
|
133
145
|
for sub_schema in new_schema.get("properties", {}).values():
|
@@ -156,10 +168,10 @@ def for_types(*allowed_types: str) -> Callable[[Mutation], Mutation]:
|
|
156
168
|
|
157
169
|
def wrapper(mutation: Mutation) -> Mutation:
|
158
170
|
@wraps(mutation)
|
159
|
-
def inner(
|
171
|
+
def inner(ctx: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
|
160
172
|
types = get_type(schema)
|
161
173
|
if _allowed_types & set(types):
|
162
|
-
return mutation(
|
174
|
+
return mutation(ctx, draw, schema)
|
163
175
|
return MutationResult.FAILURE
|
164
176
|
|
165
177
|
return inner
|
@@ -168,7 +180,7 @@ def for_types(*allowed_types: str) -> Callable[[Mutation], Mutation]:
|
|
168
180
|
|
169
181
|
|
170
182
|
@for_types("object")
|
171
|
-
def remove_required_property(
|
183
|
+
def remove_required_property(ctx: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
|
172
184
|
"""Remove a required property.
|
173
185
|
|
174
186
|
Effect: Some property won't be generated.
|
@@ -201,29 +213,29 @@ def remove_required_property(context: MutationContext, draw: Draw, schema: Schem
|
|
201
213
|
return MutationResult.SUCCESS
|
202
214
|
|
203
215
|
|
204
|
-
def change_type(
|
216
|
+
def change_type(ctx: MutationContext, draw: Draw, schema: JsonSchemaObject) -> MutationResult:
|
205
217
|
"""Change type of values accepted by a schema."""
|
206
218
|
if "type" not in schema:
|
207
219
|
# The absence of this keyword means that the schema values can be of any type;
|
208
220
|
# Therefore, we can't choose a different type
|
209
221
|
return MutationResult.FAILURE
|
210
|
-
if
|
222
|
+
if ctx.media_type == "application/x-www-form-urlencoded":
|
211
223
|
# Form data should be an object, do not change it
|
212
224
|
return MutationResult.FAILURE
|
213
225
|
# For headers, query and path parameters, if the current type is string, then it already
|
214
226
|
# includes all possible values as those parameters will be stringified before sending,
|
215
227
|
# therefore it can't be negated.
|
216
228
|
types = get_type(schema)
|
217
|
-
if "string" in types and (
|
229
|
+
if "string" in types and (ctx.location.is_in_header or ctx.is_path_location or ctx.is_query_location):
|
218
230
|
return MutationResult.FAILURE
|
219
|
-
candidates = _get_type_candidates(
|
231
|
+
candidates = _get_type_candidates(ctx, schema)
|
220
232
|
if not candidates:
|
221
233
|
# Schema covers all possible types, not possible to choose something else
|
222
234
|
return MutationResult.FAILURE
|
223
235
|
if len(candidates) == 1:
|
224
236
|
new_type = candidates.pop()
|
225
237
|
schema["type"] = new_type
|
226
|
-
_ensure_query_serializes_to_non_empty(
|
238
|
+
_ensure_query_serializes_to_non_empty(ctx, schema)
|
227
239
|
prevent_unsatisfiable_schema(schema, new_type)
|
228
240
|
return MutationResult.SUCCESS
|
229
241
|
# Choose one type that will be present in the final candidates list
|
@@ -236,21 +248,21 @@ def change_type(context: MutationContext, draw: Draw, schema: Schema) -> Mutatio
|
|
236
248
|
]
|
237
249
|
new_type = draw(st.sampled_from(remaining_candidates))
|
238
250
|
schema["type"] = new_type
|
239
|
-
_ensure_query_serializes_to_non_empty(
|
251
|
+
_ensure_query_serializes_to_non_empty(ctx, schema)
|
240
252
|
prevent_unsatisfiable_schema(schema, new_type)
|
241
253
|
return MutationResult.SUCCESS
|
242
254
|
|
243
255
|
|
244
|
-
def _ensure_query_serializes_to_non_empty(
|
245
|
-
if
|
256
|
+
def _ensure_query_serializes_to_non_empty(ctx: MutationContext, schema: Schema) -> None:
|
257
|
+
if ctx.is_query_location and schema.get("type") == "array":
|
246
258
|
# Query parameters with empty arrays or arrays of `None` or empty arrays / objects will not appear in the final URL
|
247
259
|
schema["minItems"] = schema.get("minItems") or 1
|
248
260
|
schema.setdefault("items", {}).update({"not": {"enum": [None, [], {}]}})
|
249
261
|
|
250
262
|
|
251
|
-
def _get_type_candidates(
|
263
|
+
def _get_type_candidates(ctx: MutationContext, schema: Schema) -> set[str]:
|
252
264
|
types = set(get_type(schema))
|
253
|
-
if
|
265
|
+
if ctx.is_path_location:
|
254
266
|
candidates = {"string", "integer", "number", "boolean", "null"} - types
|
255
267
|
else:
|
256
268
|
candidates = {"string", "integer", "number", "object", "array", "boolean", "null"} - types
|
@@ -279,7 +291,7 @@ def drop_not_type_specific_keywords(schema: Schema, new_type: str) -> None:
|
|
279
291
|
|
280
292
|
|
281
293
|
@for_types("object")
|
282
|
-
def change_properties(
|
294
|
+
def change_properties(ctx: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
|
283
295
|
"""Mutate individual object schema properties.
|
284
296
|
|
285
297
|
Effect: Some properties will not validate the original schema
|
@@ -290,9 +302,12 @@ def change_properties(context: MutationContext, draw: Draw, schema: Schema) -> M
|
|
290
302
|
return MutationResult.FAILURE
|
291
303
|
# Order properties randomly and iterate over them until at least one mutation is successfully applied to at least
|
292
304
|
# one property
|
293
|
-
ordered_properties =
|
305
|
+
ordered_properties = [
|
306
|
+
(name, canonicalish(subschema) if isinstance(subschema, bool) else subschema)
|
307
|
+
for name, subschema in draw(ordered(properties, unique_by=lambda x: x[0]))
|
308
|
+
]
|
294
309
|
for property_name, property_schema in ordered_properties:
|
295
|
-
if apply_until_success(
|
310
|
+
if apply_until_success(ctx, draw, property_schema) == MutationResult.SUCCESS:
|
296
311
|
# It is still possible to generate "positive" cases, for example, when this property is optional.
|
297
312
|
# They are filtered out on the upper level anyway, but to avoid performance penalty we adjust the schema
|
298
313
|
# so the generated samples are less likely to be "positive"
|
@@ -318,19 +333,19 @@ def change_properties(context: MutationContext, draw: Draw, schema: Schema) -> M
|
|
318
333
|
if enabled_properties.is_enabled(name):
|
319
334
|
for mutation in get_mutations(draw, property_schema):
|
320
335
|
if enabled_mutations.is_enabled(mutation.__name__):
|
321
|
-
mutation(
|
336
|
+
mutation(ctx, draw, property_schema)
|
322
337
|
return MutationResult.SUCCESS
|
323
338
|
|
324
339
|
|
325
|
-
def apply_until_success(
|
340
|
+
def apply_until_success(ctx: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
|
326
341
|
for mutation in get_mutations(draw, schema):
|
327
|
-
if mutation(
|
342
|
+
if mutation(ctx, draw, schema) == MutationResult.SUCCESS:
|
328
343
|
return MutationResult.SUCCESS
|
329
344
|
return MutationResult.FAILURE
|
330
345
|
|
331
346
|
|
332
347
|
@for_types("array")
|
333
|
-
def change_items(
|
348
|
+
def change_items(ctx: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
|
334
349
|
"""Mutate individual array items.
|
335
350
|
|
336
351
|
Effect: Some items will not validate the original schema
|
@@ -339,17 +354,25 @@ def change_items(context: MutationContext, draw: Draw, schema: Schema) -> Mutati
|
|
339
354
|
if not items:
|
340
355
|
# No items to mutate
|
341
356
|
return MutationResult.FAILURE
|
357
|
+
# For query/path/header/cookie, string items cannot be meaningfully mutated
|
358
|
+
# because all types serialize to strings anyway
|
359
|
+
if ctx.location.is_in_header or ctx.is_path_location or ctx.is_query_location:
|
360
|
+
items = schema.get("items", {})
|
361
|
+
if isinstance(items, dict):
|
362
|
+
items_types = get_type(items)
|
363
|
+
if "string" in items_types:
|
364
|
+
return MutationResult.FAILURE
|
342
365
|
if isinstance(items, dict):
|
343
|
-
return _change_items_object(
|
366
|
+
return _change_items_object(ctx, draw, schema, items)
|
344
367
|
if isinstance(items, list):
|
345
|
-
return _change_items_array(
|
368
|
+
return _change_items_array(ctx, draw, schema, items)
|
346
369
|
return MutationResult.FAILURE
|
347
370
|
|
348
371
|
|
349
|
-
def _change_items_object(
|
372
|
+
def _change_items_object(ctx: MutationContext, draw: Draw, schema: Schema, items: Schema) -> MutationResult:
|
350
373
|
result = MutationResult.FAILURE
|
351
374
|
for mutation in get_mutations(draw, items):
|
352
|
-
result |= mutation(
|
375
|
+
result |= mutation(ctx, draw, items)
|
353
376
|
if result == MutationResult.FAILURE:
|
354
377
|
return MutationResult.FAILURE
|
355
378
|
min_items = schema.get("minItems", 0)
|
@@ -357,12 +380,12 @@ def _change_items_object(context: MutationContext, draw: Draw, schema: Schema, i
|
|
357
380
|
return MutationResult.SUCCESS
|
358
381
|
|
359
382
|
|
360
|
-
def _change_items_array(
|
383
|
+
def _change_items_array(ctx: MutationContext, draw: Draw, schema: Schema, items: list) -> MutationResult:
|
361
384
|
latest_success_index = None
|
362
385
|
for idx, item in enumerate(items):
|
363
386
|
result = MutationResult.FAILURE
|
364
387
|
for mutation in get_mutations(draw, item):
|
365
|
-
result |= mutation(
|
388
|
+
result |= mutation(ctx, draw, item)
|
366
389
|
if result == MutationResult.SUCCESS:
|
367
390
|
latest_success_index = idx
|
368
391
|
if latest_success_index is None:
|
@@ -372,7 +395,7 @@ def _change_items_array(context: MutationContext, draw: Draw, schema: Schema, it
|
|
372
395
|
return MutationResult.SUCCESS
|
373
396
|
|
374
397
|
|
375
|
-
def negate_constraints(
|
398
|
+
def negate_constraints(ctx: MutationContext, draw: Draw, schema: Schema) -> MutationResult:
|
376
399
|
"""Negate schema constrains while keeping the original type."""
|
377
400
|
if not can_negate(schema):
|
378
401
|
return MutationResult.FAILURE
|
@@ -386,12 +409,12 @@ def negate_constraints(context: MutationContext, draw: Draw, schema: Schema) ->
|
|
386
409
|
return v != []
|
387
410
|
if k in ("example", "examples"):
|
388
411
|
return False
|
389
|
-
if
|
412
|
+
if ctx.is_path_location and k == "minLength" and v == 1:
|
390
413
|
# Empty path parameter will be filtered out
|
391
414
|
return False
|
392
415
|
return not (
|
393
416
|
k in ("type", "properties", "items", "minItems")
|
394
|
-
or (k == "additionalProperties" and
|
417
|
+
or (k == "additionalProperties" and ctx.location.is_in_header)
|
395
418
|
)
|
396
419
|
|
397
420
|
enabled_keywords = draw(st.shared(FeatureStrategy(), key="keywords")) # type: ignore
|
@@ -425,12 +448,17 @@ def negate_constraints(context: MutationContext, draw: Draw, schema: Schema) ->
|
|
425
448
|
DEPENDENCIES = {"exclusiveMaximum": "maximum", "exclusiveMinimum": "minimum"}
|
426
449
|
|
427
450
|
|
428
|
-
def get_mutations(draw: Draw, schema:
|
451
|
+
def get_mutations(draw: Draw, schema: JsonSchemaObject) -> tuple[Mutation, ...]:
|
429
452
|
"""Get mutations possible for a schema."""
|
430
453
|
types = get_type(schema)
|
431
454
|
# On the top-level of Open API schemas, types are always strings, but inside "schema" objects, they are the same as
|
432
455
|
# in JSON Schema, where it could be either a string or an array of strings.
|
433
|
-
options: list[Mutation]
|
456
|
+
options: list[Mutation]
|
457
|
+
if list(schema) == ["type"]:
|
458
|
+
# When there is only `type` in schema then `negate_constraints` is not applicable
|
459
|
+
options = [change_type]
|
460
|
+
else:
|
461
|
+
options = [negate_constraints, change_type]
|
434
462
|
if "object" in types:
|
435
463
|
options.extend([change_properties, remove_required_property])
|
436
464
|
elif "array" in types:
|
@@ -2,23 +2,15 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import sys
|
4
4
|
from functools import lru_cache
|
5
|
-
from typing import Any, Callable, Dict, Union
|
5
|
+
from typing import Any, Callable, Dict, Union
|
6
6
|
from urllib.request import urlopen
|
7
7
|
|
8
8
|
import requests
|
9
9
|
|
10
10
|
from schemathesis.core.compat import RefResolutionError, RefResolver
|
11
11
|
from schemathesis.core.deserialization import deserialize_yaml
|
12
|
-
from schemathesis.core.transforms import deepclone
|
13
12
|
from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT
|
14
13
|
|
15
|
-
from .constants import ALL_KEYWORDS
|
16
|
-
from .converter import to_json_schema_recursive
|
17
|
-
from .utils import get_type
|
18
|
-
|
19
|
-
# Reference resolving will stop after this depth
|
20
|
-
RECURSION_DEPTH_LIMIT = 100
|
21
|
-
|
22
14
|
|
23
15
|
def load_file_impl(location: str, opener: Callable) -> dict[str, Any]:
|
24
16
|
"""Load a schema from the given file."""
|
@@ -47,9 +39,7 @@ def load_remote_uri(uri: str) -> Any:
|
|
47
39
|
JSONType = Union[None, bool, float, str, list, Dict[str, Any]]
|
48
40
|
|
49
41
|
|
50
|
-
class
|
51
|
-
"""Inlines resolved schemas."""
|
52
|
-
|
42
|
+
class ReferenceResolver(RefResolver):
|
53
43
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
54
44
|
kwargs.setdefault(
|
55
45
|
"handlers", {"file": load_file_uri, "": load_file, "http": load_remote_uri, "https": load_remote_uri}
|
@@ -72,166 +62,3 @@ class InliningResolver(RefResolver):
|
|
72
62
|
except RefResolutionError as exc:
|
73
63
|
exc.__notes__ = [ref]
|
74
64
|
raise
|
75
|
-
|
76
|
-
@overload
|
77
|
-
def resolve_all(self, item: dict[str, Any], recursion_level: int = 0) -> dict[str, Any]: ...
|
78
|
-
|
79
|
-
@overload
|
80
|
-
def resolve_all(self, item: list, recursion_level: int = 0) -> list: ...
|
81
|
-
|
82
|
-
def resolve_all(self, item: JSONType, recursion_level: int = 0) -> JSONType:
|
83
|
-
"""Recursively resolve all references in the given object."""
|
84
|
-
resolve = self.resolve_all
|
85
|
-
if isinstance(item, dict):
|
86
|
-
ref = item.get("$ref")
|
87
|
-
if isinstance(ref, str):
|
88
|
-
url, resolved = self.resolve(ref)
|
89
|
-
self.push_scope(url)
|
90
|
-
try:
|
91
|
-
# If the next level of recursion exceeds the limit, then we need to copy it explicitly
|
92
|
-
# In other cases, this method create new objects for mutable types (dict & list)
|
93
|
-
next_recursion_level = recursion_level + 1
|
94
|
-
if next_recursion_level > RECURSION_DEPTH_LIMIT:
|
95
|
-
copied = deepclone(resolved)
|
96
|
-
remove_optional_references(copied)
|
97
|
-
return copied
|
98
|
-
return resolve(resolved, next_recursion_level)
|
99
|
-
finally:
|
100
|
-
self.pop_scope()
|
101
|
-
return {
|
102
|
-
key: resolve(sub_item, recursion_level) if isinstance(sub_item, (dict, list)) else sub_item
|
103
|
-
for key, sub_item in item.items()
|
104
|
-
}
|
105
|
-
if isinstance(item, list):
|
106
|
-
return [
|
107
|
-
self.resolve_all(sub_item, recursion_level) if isinstance(sub_item, (dict, list)) else sub_item
|
108
|
-
for sub_item in item
|
109
|
-
]
|
110
|
-
return item
|
111
|
-
|
112
|
-
def resolve_in_scope(self, definition: dict[str, Any], scope: str) -> tuple[list[str], dict[str, Any]]:
|
113
|
-
scopes = [scope]
|
114
|
-
# if there is `$ref` then we have a scope change that should be used during validation later to
|
115
|
-
# resolve nested references correctly
|
116
|
-
if "$ref" in definition:
|
117
|
-
self.push_scope(scope)
|
118
|
-
try:
|
119
|
-
new_scope, definition = self.resolve(definition["$ref"])
|
120
|
-
finally:
|
121
|
-
self.pop_scope()
|
122
|
-
scopes.append(new_scope)
|
123
|
-
return scopes, definition
|
124
|
-
|
125
|
-
|
126
|
-
class ConvertingResolver(InliningResolver):
|
127
|
-
"""Convert resolved OpenAPI schemas to JSON Schema.
|
128
|
-
|
129
|
-
When recursive schemas are validated we need to have resolved documents properly converted.
|
130
|
-
This approach is the simplest one, since this logic isolated in a single place.
|
131
|
-
"""
|
132
|
-
|
133
|
-
def __init__(self, *args: Any, nullable_name: Any, is_response_schema: bool = False, **kwargs: Any) -> None:
|
134
|
-
super().__init__(*args, **kwargs)
|
135
|
-
self.nullable_name = nullable_name
|
136
|
-
self.is_response_schema = is_response_schema
|
137
|
-
|
138
|
-
def resolve(self, ref: str) -> tuple[str, Any]:
|
139
|
-
url, document = super().resolve(ref)
|
140
|
-
document = to_json_schema_recursive(
|
141
|
-
document,
|
142
|
-
nullable_name=self.nullable_name,
|
143
|
-
is_response_schema=self.is_response_schema,
|
144
|
-
update_quantifiers=False,
|
145
|
-
)
|
146
|
-
return url, document
|
147
|
-
|
148
|
-
|
149
|
-
def remove_optional_references(schema: dict[str, Any]) -> None:
|
150
|
-
"""Remove optional parts of the schema that contain references.
|
151
|
-
|
152
|
-
It covers only the most popular cases, as removing all optional parts is complicated.
|
153
|
-
We might fall back to filtering out invalid cases in the future.
|
154
|
-
"""
|
155
|
-
|
156
|
-
def clean_properties(s: dict[str, Any]) -> None:
|
157
|
-
properties = s["properties"]
|
158
|
-
required = s.get("required", [])
|
159
|
-
for name, value in list(properties.items()):
|
160
|
-
if name not in required and contains_ref(value):
|
161
|
-
# Drop the property - it will not be generated
|
162
|
-
del properties[name]
|
163
|
-
elif on_single_item_combinators(value):
|
164
|
-
properties.pop(name, None)
|
165
|
-
else:
|
166
|
-
stack.append(value)
|
167
|
-
|
168
|
-
def clean_items(s: dict[str, Any]) -> None:
|
169
|
-
items = s["items"]
|
170
|
-
min_items = s.get("minItems", 0)
|
171
|
-
if not min_items:
|
172
|
-
if isinstance(items, dict) and ("$ref" in items or on_single_item_combinators(items)):
|
173
|
-
force_empty_list(s)
|
174
|
-
if isinstance(items, list) and any_ref(items):
|
175
|
-
force_empty_list(s)
|
176
|
-
|
177
|
-
def clean_additional_properties(s: dict[str, Any]) -> None:
|
178
|
-
additional_properties = s["additionalProperties"]
|
179
|
-
if isinstance(additional_properties, dict) and "$ref" in additional_properties:
|
180
|
-
s["additionalProperties"] = False
|
181
|
-
|
182
|
-
def force_empty_list(s: dict[str, Any]) -> None:
|
183
|
-
del s["items"]
|
184
|
-
s["maxItems"] = 0
|
185
|
-
|
186
|
-
def any_ref(i: list[dict[str, Any]]) -> bool:
|
187
|
-
return any("$ref" in item for item in i)
|
188
|
-
|
189
|
-
def contains_ref(s: dict[str, Any]) -> bool:
|
190
|
-
if "$ref" in s:
|
191
|
-
return True
|
192
|
-
i = s.get("items")
|
193
|
-
return (isinstance(i, dict) and "$ref" in i) or isinstance(i, list) and any_ref(i)
|
194
|
-
|
195
|
-
def can_elide(s: dict[str, Any]) -> bool:
|
196
|
-
# Whether this schema could be dropped from a list of schemas
|
197
|
-
type_ = get_type(s)
|
198
|
-
if type_ == ["object"]:
|
199
|
-
# Empty object is valid for this schema -> could be dropped
|
200
|
-
return s.get("required", []) == [] and s.get("minProperties", 0) == 0
|
201
|
-
# Has at least one keyword -> should not be removed
|
202
|
-
return not any(k in ALL_KEYWORDS for k in s)
|
203
|
-
|
204
|
-
def on_single_item_combinators(s: dict[str, Any]) -> list[str]:
|
205
|
-
# Schema example:
|
206
|
-
# {
|
207
|
-
# "type": "object",
|
208
|
-
# "properties": {
|
209
|
-
# "parent": {
|
210
|
-
# "allOf": [{"$ref": "#/components/schemas/User"}]
|
211
|
-
# }
|
212
|
-
# }
|
213
|
-
# }
|
214
|
-
found = []
|
215
|
-
for keyword in ("allOf", "oneOf", "anyOf"):
|
216
|
-
v = s.get(keyword)
|
217
|
-
if v is not None:
|
218
|
-
elided = [sub for sub in v if not can_elide(sub)]
|
219
|
-
if len(elided) == 1 and contains_ref(elided[0]):
|
220
|
-
found.append(keyword)
|
221
|
-
return found
|
222
|
-
|
223
|
-
stack = [schema]
|
224
|
-
while stack:
|
225
|
-
definition = stack.pop()
|
226
|
-
if isinstance(definition, dict):
|
227
|
-
# Optional properties
|
228
|
-
if "properties" in definition:
|
229
|
-
clean_properties(definition)
|
230
|
-
# Optional items
|
231
|
-
if "items" in definition:
|
232
|
-
clean_items(definition)
|
233
|
-
# Not required additional properties
|
234
|
-
if "additionalProperties" in definition:
|
235
|
-
clean_additional_properties(definition)
|
236
|
-
for k in on_single_item_combinators(definition):
|
237
|
-
del definition[k]
|