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,34 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import Any, Protocol, TypeVar
|
4
|
+
|
5
|
+
from schemathesis.core.parameters import ParameterLocation
|
6
|
+
|
7
|
+
T = TypeVar("T", covariant=True)
|
8
|
+
|
9
|
+
|
10
|
+
class ResponsesContainer(Protocol[T]):
|
11
|
+
def find_by_status_code(self, status_code: int) -> T | None: ... # pragma: no cover
|
12
|
+
def add(self, status_code: str, definition: dict[str, Any]) -> T: ... # pragma: no cover
|
13
|
+
|
14
|
+
|
15
|
+
class OperationParameter(Protocol):
|
16
|
+
"""API parameter at a specific location (query, header, body, etc.)."""
|
17
|
+
|
18
|
+
definition: Any
|
19
|
+
"""Raw parameter definition from the API spec."""
|
20
|
+
|
21
|
+
@property
|
22
|
+
def location(self) -> ParameterLocation:
|
23
|
+
"""Location: "query", "header", "body", etc."""
|
24
|
+
... # pragma: no cover
|
25
|
+
|
26
|
+
@property
|
27
|
+
def name(self) -> str:
|
28
|
+
"""Parameter name."""
|
29
|
+
... # pragma: no cover
|
30
|
+
|
31
|
+
@property
|
32
|
+
def is_required(self) -> bool:
|
33
|
+
"""True if required."""
|
34
|
+
... # pragma: no cover
|
schemathesis/core/errors.py
CHANGED
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
|
|
17
17
|
|
18
18
|
from schemathesis.config import OutputConfig
|
19
19
|
from schemathesis.core.compat import RefResolutionError
|
20
|
+
from schemathesis.core.jsonschema import BundleError
|
20
21
|
|
21
22
|
|
22
23
|
SCHEMA_ERROR_SUGGESTION = "Ensure that the definition complies with the OpenAPI specification"
|
@@ -26,11 +27,6 @@ SERIALIZATION_NOT_POSSIBLE_MESSAGE = f"No supported serializers for media types:
|
|
26
27
|
SERIALIZATION_FOR_TYPE_IS_NOT_POSSIBLE_MESSAGE = (
|
27
28
|
f"Cannot serialize to '{{}}' (unsupported media type)\n{SERIALIZERS_SUGGESTION_MESSAGE}"
|
28
29
|
)
|
29
|
-
RECURSIVE_REFERENCE_ERROR_MESSAGE = (
|
30
|
-
"Currently, Schemathesis can't generate data for this operation due to "
|
31
|
-
"recursive references in the operation definition. See more information in "
|
32
|
-
"this issue - https://github.com/schemathesis/schemathesis/issues/947"
|
33
|
-
)
|
34
30
|
|
35
31
|
|
36
32
|
class SchemathesisError(Exception):
|
@@ -50,6 +46,14 @@ class InvalidSchema(SchemathesisError):
|
|
50
46
|
self.path = path
|
51
47
|
self.method = method
|
52
48
|
|
49
|
+
@classmethod
|
50
|
+
def from_bundle_error(cls, error: BundleError, location: str, name: str | None = None) -> InvalidSchema:
|
51
|
+
if location == "body":
|
52
|
+
message = f"Can not generate data for {location}! {error}"
|
53
|
+
else:
|
54
|
+
message = f"Can not generate data for {location} parameter `{name}`! {error}"
|
55
|
+
return InvalidSchema(message)
|
56
|
+
|
53
57
|
@classmethod
|
54
58
|
def from_jsonschema_error(
|
55
59
|
cls, error: ValidationError | JsonSchemaError, path: str | None, method: str | None, config: OutputConfig
|
@@ -283,6 +287,26 @@ class UnboundPrefix(SerializationError):
|
|
283
287
|
super().__init__(UNBOUND_PREFIX_MESSAGE_TEMPLATE.format(prefix=prefix))
|
284
288
|
|
285
289
|
|
290
|
+
class UnresolvableReference(SchemathesisError):
|
291
|
+
"""A reference cannot be resolved."""
|
292
|
+
|
293
|
+
def __init__(self, reference: str) -> None:
|
294
|
+
self.reference = reference
|
295
|
+
|
296
|
+
def __str__(self) -> str:
|
297
|
+
return f"Reference `{self.reference}` cannot be resolved"
|
298
|
+
|
299
|
+
|
300
|
+
class InfiniteRecursiveReference(SchemathesisError):
|
301
|
+
"""Required recursive reference creates a cycle."""
|
302
|
+
|
303
|
+
def __init__(self, reference: str) -> None:
|
304
|
+
self.reference = reference
|
305
|
+
|
306
|
+
def __str__(self) -> str:
|
307
|
+
return f"Required reference `{self.reference}` creates a cycle"
|
308
|
+
|
309
|
+
|
286
310
|
class SerializationNotPossible(SerializationError):
|
287
311
|
"""Not possible to serialize data to specified media type(s).
|
288
312
|
|
@@ -0,0 +1,13 @@
|
|
1
|
+
from .bundler import BUNDLE_STORAGE_KEY, REFERENCE_TO_BUNDLE_PREFIX, BundleError, Bundler, bundle
|
2
|
+
from .keywords import ALL_KEYWORDS
|
3
|
+
from .types import get_type
|
4
|
+
|
5
|
+
__all__ = [
|
6
|
+
"ALL_KEYWORDS",
|
7
|
+
"bundle",
|
8
|
+
"Bundler",
|
9
|
+
"BundleError",
|
10
|
+
"REFERENCE_TO_BUNDLE_PREFIX",
|
11
|
+
"BUNDLE_STORAGE_KEY",
|
12
|
+
"get_type",
|
13
|
+
]
|
@@ -0,0 +1,163 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Any
|
4
|
+
|
5
|
+
from schemathesis.core.errors import InfiniteRecursiveReference
|
6
|
+
from schemathesis.core.jsonschema.references import sanitize
|
7
|
+
from schemathesis.core.jsonschema.types import JsonSchema, to_json_type_name
|
8
|
+
from schemathesis.core.transforms import deepclone
|
9
|
+
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
from schemathesis.core.compat import RefResolver
|
12
|
+
|
13
|
+
|
14
|
+
BUNDLE_STORAGE_KEY = "x-bundled"
|
15
|
+
REFERENCE_TO_BUNDLE_PREFIX = f"#/{BUNDLE_STORAGE_KEY}"
|
16
|
+
|
17
|
+
|
18
|
+
class BundleError(Exception):
|
19
|
+
def __init__(self, reference: str, value: Any) -> None:
|
20
|
+
self.reference = reference
|
21
|
+
self.value = value
|
22
|
+
|
23
|
+
def __str__(self) -> str:
|
24
|
+
return f"Cannot bundle `{self.reference}`: expected JSON Schema (object or boolean), got {to_json_type_name(self.value)}"
|
25
|
+
|
26
|
+
|
27
|
+
class Bundler:
|
28
|
+
"""Bundler tracks schema ids stored in a bundle."""
|
29
|
+
|
30
|
+
counter: int
|
31
|
+
|
32
|
+
__slots__ = ("counter",)
|
33
|
+
|
34
|
+
def __init__(self) -> None:
|
35
|
+
self.counter = 0
|
36
|
+
|
37
|
+
def bundle(self, schema: JsonSchema, resolver: RefResolver, *, inline_recursive: bool) -> JsonSchema:
|
38
|
+
"""Bundle a JSON Schema by embedding all references."""
|
39
|
+
# Inlining recursive reference is required (for now) for data generation, but is unsound for data validation
|
40
|
+
if not isinstance(schema, dict):
|
41
|
+
return schema
|
42
|
+
|
43
|
+
# Track visited URIs and their local definition names
|
44
|
+
visited: set[str] = set()
|
45
|
+
uri_to_def_name: dict[str, str] = {}
|
46
|
+
defs = {}
|
47
|
+
|
48
|
+
has_recursive_references = False
|
49
|
+
resolve = resolver.resolve
|
50
|
+
visit = visited.add
|
51
|
+
|
52
|
+
def get_def_name(uri: str) -> str:
|
53
|
+
"""Generate or retrieve the local definition name for a URI."""
|
54
|
+
name = uri_to_def_name.get(uri)
|
55
|
+
if name is None:
|
56
|
+
self.counter += 1
|
57
|
+
name = f"schema{self.counter}"
|
58
|
+
uri_to_def_name[uri] = name
|
59
|
+
return name
|
60
|
+
|
61
|
+
def bundle_recursive(current: JsonSchema | list[JsonSchema]) -> JsonSchema | list[JsonSchema]:
|
62
|
+
"""Recursively process and bundle references in the current schema."""
|
63
|
+
# Local lookup is cheaper and it matters for large schemas.
|
64
|
+
# It works because this recursive call goes to every nested value
|
65
|
+
nonlocal has_recursive_references
|
66
|
+
_bundle_recursive = bundle_recursive
|
67
|
+
if isinstance(current, dict):
|
68
|
+
reference = current.get("$ref")
|
69
|
+
if isinstance(reference, str) and not reference.startswith(REFERENCE_TO_BUNDLE_PREFIX):
|
70
|
+
resolved_uri, resolved_schema = resolve(reference)
|
71
|
+
|
72
|
+
if not isinstance(resolved_schema, (dict, bool)):
|
73
|
+
raise BundleError(reference, resolved_schema)
|
74
|
+
def_name = get_def_name(resolved_uri)
|
75
|
+
|
76
|
+
is_recursive_reference = resolved_uri in resolver._scopes_stack
|
77
|
+
has_recursive_references |= is_recursive_reference
|
78
|
+
if inline_recursive and is_recursive_reference:
|
79
|
+
# This is a recursive reference! As of Sep 2025, `hypothesis-jsonschema` does not support
|
80
|
+
# recursive references and Schemathesis has to remove them if possible.
|
81
|
+
#
|
82
|
+
# Cutting them of immediately would limit the quality of generated data, since it would have
|
83
|
+
# just a single level of recursion. Currently, the only way to generate recursive data is to
|
84
|
+
# inline definitions directly, which can lead to schema size explosion.
|
85
|
+
#
|
86
|
+
# To balance it, Schemathesis inlines one level, that avoids exponential blowup of O(B ^ L)
|
87
|
+
# in worst case, where B is branching factor (number of recursive references per schema), and
|
88
|
+
# L is the number of levels. Even quadratic growth can be unacceptable for large schemas.
|
89
|
+
#
|
90
|
+
# In the future, it **should** be handled by `hypothesis-jsonschema` instead.
|
91
|
+
cloned = deepclone(resolved_schema)
|
92
|
+
remaining_references = sanitize(cloned)
|
93
|
+
if remaining_references:
|
94
|
+
# This schema is either infinitely recursive or the sanitization logic misses it, in any
|
95
|
+
# event, we git up here
|
96
|
+
raise InfiniteRecursiveReference(reference)
|
97
|
+
|
98
|
+
result = {key: _bundle_recursive(value) for key, value in current.items() if key != "$ref"}
|
99
|
+
# Recursive references need `$ref` to be in them, which is only possible with `dict`
|
100
|
+
assert isinstance(cloned, dict)
|
101
|
+
result.update(cloned)
|
102
|
+
return result
|
103
|
+
elif resolved_uri not in visited:
|
104
|
+
# Bundle only new schemas
|
105
|
+
visit(resolved_uri)
|
106
|
+
|
107
|
+
# Recursively bundle the embedded schema too!
|
108
|
+
resolver.push_scope(resolved_uri)
|
109
|
+
try:
|
110
|
+
bundled_resolved = _bundle_recursive(resolved_schema)
|
111
|
+
finally:
|
112
|
+
resolver.pop_scope()
|
113
|
+
|
114
|
+
defs[def_name] = bundled_resolved
|
115
|
+
|
116
|
+
return {
|
117
|
+
key: f"{REFERENCE_TO_BUNDLE_PREFIX}/{def_name}"
|
118
|
+
if key == "$ref"
|
119
|
+
else _bundle_recursive(value)
|
120
|
+
if isinstance(value, (dict, list))
|
121
|
+
else value
|
122
|
+
for key, value in current.items()
|
123
|
+
}
|
124
|
+
else:
|
125
|
+
# Already visited - just update $ref
|
126
|
+
return {
|
127
|
+
key: f"{REFERENCE_TO_BUNDLE_PREFIX}/{def_name}"
|
128
|
+
if key == "$ref"
|
129
|
+
else _bundle_recursive(value)
|
130
|
+
if isinstance(value, (dict, list))
|
131
|
+
else value
|
132
|
+
for key, value in current.items()
|
133
|
+
}
|
134
|
+
return {
|
135
|
+
key: _bundle_recursive(value) if isinstance(value, (dict, list)) else value
|
136
|
+
for key, value in current.items()
|
137
|
+
}
|
138
|
+
elif isinstance(current, list):
|
139
|
+
return [_bundle_recursive(item) if isinstance(item, (dict, list)) else item for item in current] # type: ignore[misc]
|
140
|
+
# `isinstance` guards won't let it happen
|
141
|
+
# Otherwise is present to make type checker happy
|
142
|
+
return current # pragma: no cover
|
143
|
+
|
144
|
+
bundled = bundle_recursive(schema)
|
145
|
+
|
146
|
+
assert isinstance(bundled, dict)
|
147
|
+
|
148
|
+
# Inlining such a schema is only possible if recursive references were inlined
|
149
|
+
if (inline_recursive or not has_recursive_references) and "$ref" in bundled and len(defs) == 1:
|
150
|
+
result = {key: value for key, value in bundled.items() if key != "$ref"}
|
151
|
+
for value in defs.values():
|
152
|
+
if isinstance(value, dict):
|
153
|
+
result.update(value)
|
154
|
+
return result
|
155
|
+
|
156
|
+
if defs:
|
157
|
+
bundled[BUNDLE_STORAGE_KEY] = defs
|
158
|
+
return bundled
|
159
|
+
|
160
|
+
|
161
|
+
def bundle(schema: JsonSchema, resolver: RefResolver, *, inline_recursive: bool) -> JsonSchema:
|
162
|
+
"""Bundle a JSON Schema by embedding all references."""
|
163
|
+
return Bundler().bundle(schema, resolver, inline_recursive=inline_recursive)
|
@@ -0,0 +1,122 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from schemathesis.core.jsonschema.keywords import ALL_KEYWORDS
|
4
|
+
from schemathesis.core.jsonschema.types import JsonSchema, JsonSchemaObject, get_type
|
5
|
+
|
6
|
+
|
7
|
+
def sanitize(schema: JsonSchema) -> set[str]:
|
8
|
+
"""Remove optional parts of the schema that contain references.
|
9
|
+
|
10
|
+
It covers only the most popular cases, as removing all optional parts is complicated.
|
11
|
+
We might fall back to filtering out invalid cases in the future.
|
12
|
+
"""
|
13
|
+
if isinstance(schema, bool):
|
14
|
+
return set()
|
15
|
+
|
16
|
+
stack = [schema]
|
17
|
+
while stack:
|
18
|
+
current = stack.pop()
|
19
|
+
if isinstance(current, dict):
|
20
|
+
# Optional properties
|
21
|
+
if "properties" in current:
|
22
|
+
properties = current["properties"]
|
23
|
+
required = current.get("required", [])
|
24
|
+
for name, value in list(properties.items()):
|
25
|
+
if isinstance(value, dict):
|
26
|
+
if name not in required and _has_references(value):
|
27
|
+
del properties[name]
|
28
|
+
elif _find_single_reference_combinators(value):
|
29
|
+
properties.pop(name, None)
|
30
|
+
else:
|
31
|
+
stack.append(value)
|
32
|
+
|
33
|
+
# Optional items
|
34
|
+
if "items" in current:
|
35
|
+
_sanitize_items(current)
|
36
|
+
# Not required additional properties
|
37
|
+
if "additionalProperties" in current:
|
38
|
+
_sanitize_additional_properties(current)
|
39
|
+
for k in _find_single_reference_combinators(current):
|
40
|
+
del current[k]
|
41
|
+
|
42
|
+
remaining: set[str] = set()
|
43
|
+
_collect_all_references(schema, remaining)
|
44
|
+
return remaining
|
45
|
+
|
46
|
+
|
47
|
+
def _collect_all_references(schema: JsonSchema | list[JsonSchema], remaining: set[str]) -> None:
|
48
|
+
"""Recursively collect all $ref present in the schema."""
|
49
|
+
if isinstance(schema, dict):
|
50
|
+
reference = schema.get("$ref")
|
51
|
+
if isinstance(reference, str):
|
52
|
+
remaining.add(reference)
|
53
|
+
for value in schema.values():
|
54
|
+
_collect_all_references(value, remaining)
|
55
|
+
elif isinstance(schema, list):
|
56
|
+
for item in schema:
|
57
|
+
_collect_all_references(item, remaining)
|
58
|
+
|
59
|
+
|
60
|
+
def _has_references_in_items(items: list[JsonSchema]) -> bool:
|
61
|
+
return any("$ref" in item for item in items if isinstance(item, dict))
|
62
|
+
|
63
|
+
|
64
|
+
def _has_references(schema: JsonSchemaObject) -> bool:
|
65
|
+
if "$ref" in schema:
|
66
|
+
return True
|
67
|
+
items = schema.get("items")
|
68
|
+
return (isinstance(items, dict) and "$ref" in items) or isinstance(items, list) and _has_references_in_items(items)
|
69
|
+
|
70
|
+
|
71
|
+
def _is_optional_schema(schema: JsonSchema) -> bool:
|
72
|
+
# Whether this schema could be dropped from a list of schemas
|
73
|
+
if isinstance(schema, bool):
|
74
|
+
return True
|
75
|
+
type_ = get_type(schema)
|
76
|
+
if type_ == ["object"]:
|
77
|
+
# Empty object is valid for this schema -> could be dropped
|
78
|
+
return schema.get("required", []) == [] and schema.get("minProperties", 0) == 0
|
79
|
+
# Has at least one keyword -> should not be removed
|
80
|
+
return not any(k in ALL_KEYWORDS for k in schema)
|
81
|
+
|
82
|
+
|
83
|
+
def _find_single_reference_combinators(schema: JsonSchemaObject) -> list[str]:
|
84
|
+
# Schema example:
|
85
|
+
# {
|
86
|
+
# "type": "object",
|
87
|
+
# "properties": {
|
88
|
+
# "parent": {
|
89
|
+
# "allOf": [{"$ref": "#/components/schemas/User"}]
|
90
|
+
# }
|
91
|
+
# }
|
92
|
+
# }
|
93
|
+
found = []
|
94
|
+
for keyword in ("allOf", "oneOf", "anyOf"):
|
95
|
+
combinator = schema.get(keyword)
|
96
|
+
if combinator is not None:
|
97
|
+
optionals = [subschema for subschema in combinator if not _is_optional_schema(subschema)]
|
98
|
+
# NOTE: The first schema is not bool, hence it is safe to pass it to `_has_references`
|
99
|
+
if len(optionals) == 1 and _has_references(optionals[0]):
|
100
|
+
found.append(keyword)
|
101
|
+
return found
|
102
|
+
|
103
|
+
|
104
|
+
def _sanitize_items(schema: JsonSchemaObject) -> None:
|
105
|
+
items = schema["items"]
|
106
|
+
min_items = schema.get("minItems", 0)
|
107
|
+
if not min_items:
|
108
|
+
if isinstance(items, dict) and ("$ref" in items or _find_single_reference_combinators(items)):
|
109
|
+
_convert_to_empty_array(schema)
|
110
|
+
if isinstance(items, list) and _has_references_in_items(items):
|
111
|
+
_convert_to_empty_array(schema)
|
112
|
+
|
113
|
+
|
114
|
+
def _convert_to_empty_array(schema: JsonSchemaObject) -> None:
|
115
|
+
del schema["items"]
|
116
|
+
schema["maxItems"] = 0
|
117
|
+
|
118
|
+
|
119
|
+
def _sanitize_additional_properties(schema: JsonSchemaObject) -> None:
|
120
|
+
additional_properties = schema["additionalProperties"]
|
121
|
+
if isinstance(additional_properties, dict) and "$ref" in additional_properties:
|
122
|
+
schema["additionalProperties"] = False
|
@@ -0,0 +1,41 @@
|
|
1
|
+
from typing import Any, Union
|
2
|
+
|
3
|
+
JsonSchemaObject = dict[str, Any]
|
4
|
+
JsonSchema = Union[JsonSchemaObject, bool]
|
5
|
+
|
6
|
+
ANY_TYPE = ["null", "boolean", "number", "string", "array", "object"]
|
7
|
+
ALL_TYPES = ["null", "boolean", "integer", "number", "string", "array", "object"]
|
8
|
+
|
9
|
+
|
10
|
+
def get_type(schema: JsonSchema, *, _check_type: bool = False) -> list[str]:
|
11
|
+
if isinstance(schema, bool):
|
12
|
+
return ANY_TYPE
|
13
|
+
ty = schema.get("type", ANY_TYPE)
|
14
|
+
if isinstance(ty, str):
|
15
|
+
if _check_type and ty not in ALL_TYPES:
|
16
|
+
raise AssertionError(f"Unknown type: `{ty}`. Should be one of {', '.join(ALL_TYPES)}")
|
17
|
+
return [ty]
|
18
|
+
if ty is ANY_TYPE:
|
19
|
+
return list(ty)
|
20
|
+
return [t for t in ALL_TYPES if t in ty]
|
21
|
+
|
22
|
+
|
23
|
+
def _get_type(schema: JsonSchema) -> list[str]:
|
24
|
+
# Special version to patch `hypothesis-jsonschema`
|
25
|
+
return get_type(schema, _check_type=True)
|
26
|
+
|
27
|
+
|
28
|
+
def to_json_type_name(v: Any) -> str:
|
29
|
+
if v is None:
|
30
|
+
return "null"
|
31
|
+
if isinstance(v, bool):
|
32
|
+
return "boolean"
|
33
|
+
if isinstance(v, dict):
|
34
|
+
return "object"
|
35
|
+
if isinstance(v, list):
|
36
|
+
return "array"
|
37
|
+
if isinstance(v, (int, float)):
|
38
|
+
return "number"
|
39
|
+
if isinstance(v, str):
|
40
|
+
return "string"
|
41
|
+
return type(v).__name__
|
schemathesis/core/media_types.py
CHANGED
@@ -1,10 +1,12 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
from functools import lru_cache
|
2
|
-
from typing import Generator
|
4
|
+
from typing import Generator
|
3
5
|
|
4
6
|
from schemathesis.core.errors import MalformedMediaType
|
5
7
|
|
6
8
|
|
7
|
-
def _parseparam(s: str) -> Generator[str
|
9
|
+
def _parseparam(s: str) -> Generator[str]:
|
8
10
|
while s[:1] == ";":
|
9
11
|
s = s[1:]
|
10
12
|
end = s.find(";")
|
@@ -17,7 +19,7 @@ def _parseparam(s: str) -> Generator[str, None, None]:
|
|
17
19
|
s = s[end:]
|
18
20
|
|
19
21
|
|
20
|
-
def _parse_header(line: str) ->
|
22
|
+
def _parse_header(line: str) -> tuple[str, dict]:
|
21
23
|
parts = _parseparam(";" + line)
|
22
24
|
key = parts.__next__()
|
23
25
|
pdict = {}
|
@@ -34,7 +36,7 @@ def _parse_header(line: str) -> Tuple[str, dict]:
|
|
34
36
|
|
35
37
|
|
36
38
|
@lru_cache
|
37
|
-
def parse(media_type: str) ->
|
39
|
+
def parse(media_type: str) -> tuple[str, str]:
|
38
40
|
"""Parse Content Type and return main type and subtype."""
|
39
41
|
try:
|
40
42
|
media_type, _ = _parse_header(media_type)
|
@@ -0,0 +1,37 @@
|
|
1
|
+
from enum import Enum
|
2
|
+
|
3
|
+
LOCATION_TO_CONTAINER = {
|
4
|
+
"path": "path_parameters",
|
5
|
+
"query": "query",
|
6
|
+
"header": "headers",
|
7
|
+
"cookie": "cookies",
|
8
|
+
"body": "body",
|
9
|
+
}
|
10
|
+
|
11
|
+
|
12
|
+
class ParameterLocation(str, Enum):
|
13
|
+
"""API parameter location."""
|
14
|
+
|
15
|
+
QUERY = "query"
|
16
|
+
HEADER = "header"
|
17
|
+
PATH = "path"
|
18
|
+
COOKIE = "cookie"
|
19
|
+
BODY = "body"
|
20
|
+
UNKNOWN = None
|
21
|
+
|
22
|
+
@property
|
23
|
+
def container_name(self) -> str:
|
24
|
+
return {
|
25
|
+
"path": "path_parameters",
|
26
|
+
"query": "query",
|
27
|
+
"header": "headers",
|
28
|
+
"cookie": "cookies",
|
29
|
+
"body": "body",
|
30
|
+
}[self]
|
31
|
+
|
32
|
+
@property
|
33
|
+
def is_in_header(self) -> bool:
|
34
|
+
return self in HEADER_LOCATIONS
|
35
|
+
|
36
|
+
|
37
|
+
HEADER_LOCATIONS = frozenset([ParameterLocation.HEADER, ParameterLocation.COOKIE])
|
schemathesis/core/transforms.py
CHANGED
@@ -1,6 +1,20 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
from typing import Any, Callable, Dict, List, Mapping, Union, overload
|
3
|
+
from typing import Any, Callable, Dict, List, Mapping, TypeVar, Union, overload
|
4
|
+
|
5
|
+
T = TypeVar("T")
|
6
|
+
|
7
|
+
|
8
|
+
@overload
|
9
|
+
def deepclone(value: dict) -> dict: ... # pragma: no cover
|
10
|
+
|
11
|
+
|
12
|
+
@overload
|
13
|
+
def deepclone(value: list) -> list: ... # pragma: no cover
|
14
|
+
|
15
|
+
|
16
|
+
@overload
|
17
|
+
def deepclone(value: T) -> T: ... # pragma: no cover
|
4
18
|
|
5
19
|
|
6
20
|
def deepclone(value: Any) -> Any:
|
@@ -11,7 +25,16 @@ def deepclone(value: Any) -> Any:
|
|
11
25
|
if isinstance(value, dict):
|
12
26
|
return {
|
13
27
|
k1: (
|
14
|
-
{
|
28
|
+
{
|
29
|
+
k2: (
|
30
|
+
{k3: deepclone(v3) for k3, v3 in v2.items()}
|
31
|
+
if isinstance(v2, dict)
|
32
|
+
else [deepclone(v3) for v3 in v2]
|
33
|
+
if isinstance(v2, list)
|
34
|
+
else v2
|
35
|
+
)
|
36
|
+
for k2, v2 in v1.items()
|
37
|
+
}
|
15
38
|
if isinstance(v1, dict)
|
16
39
|
else [deepclone(v2) for v2 in v1]
|
17
40
|
if isinstance(v1, list)
|
schemathesis/core/validation.py
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
import re
|
2
2
|
from urllib.parse import urlparse
|
3
3
|
|
4
|
+
from schemathesis.core.errors import InvalidSchema
|
5
|
+
|
4
6
|
# Adapted from http.client._is_illegal_header_value
|
5
7
|
INVALID_HEADER_RE = re.compile(r"\n(?![ \t])|\r(?![ \t\n])")
|
6
8
|
|
@@ -29,6 +31,23 @@ def is_latin_1_encodable(value: object) -> bool:
|
|
29
31
|
return False
|
30
32
|
|
31
33
|
|
34
|
+
def check_header_name(name: str) -> None:
|
35
|
+
from requests.exceptions import InvalidHeader
|
36
|
+
from requests.utils import check_header_validity
|
37
|
+
|
38
|
+
if not name:
|
39
|
+
raise InvalidSchema("Header name should not be empty")
|
40
|
+
if not name.isascii():
|
41
|
+
# `urllib3` encodes header names to ASCII
|
42
|
+
raise InvalidSchema(f"Header name should be ASCII: {name}")
|
43
|
+
try:
|
44
|
+
check_header_validity((name, ""))
|
45
|
+
except InvalidHeader as exc:
|
46
|
+
raise InvalidSchema(str(exc)) from None
|
47
|
+
if bool(INVALID_HEADER_RE.search(name)):
|
48
|
+
raise InvalidSchema(f"Invalid header name: {name}")
|
49
|
+
|
50
|
+
|
32
51
|
SURROGATE_PAIR_RE = re.compile(r"[\ud800-\udfff]")
|
33
52
|
_contains_surrogate_pair = SURROGATE_PAIR_RE.search
|
34
53
|
|
schemathesis/engine/context.py
CHANGED
@@ -95,7 +95,7 @@ class EngineContext:
|
|
95
95
|
# Generate links from collected Location headers
|
96
96
|
inferencer = LinkInferencer.from_schema(self.schema)
|
97
97
|
for operation, entries in self.observations.location_headers.items():
|
98
|
-
injected += inferencer.inject_links(operation.
|
98
|
+
injected += inferencer.inject_links(operation.responses, entries)
|
99
99
|
|
100
100
|
return injected
|
101
101
|
|