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
@@ -3,8 +3,8 @@ from __future__ import annotations
|
|
3
3
|
import json
|
4
4
|
from typing import Any, Callable, Dict, Generator, List
|
5
5
|
|
6
|
+
from schemathesis.core.parameters import LOCATION_TO_CONTAINER
|
6
7
|
from schemathesis.schemas import APIOperation
|
7
|
-
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
8
8
|
|
9
9
|
Generated = Dict[str, Any]
|
10
10
|
Definition = Dict[str, Any]
|
@@ -57,7 +57,11 @@ def _serialize_openapi3(definitions: DefinitionList) -> Generator[Callable | Non
|
|
57
57
|
# Simple serialization
|
58
58
|
style = definition.get("style")
|
59
59
|
explode = definition.get("explode")
|
60
|
-
|
60
|
+
schema = definition.get("schema", {})
|
61
|
+
if isinstance(schema, dict):
|
62
|
+
type_ = schema.get("type")
|
63
|
+
else:
|
64
|
+
type_ = None
|
61
65
|
if definition["in"] == "path":
|
62
66
|
yield from _serialize_path_openapi3(name, type_, style, explode)
|
63
67
|
elif definition["in"] == "query":
|
@@ -69,7 +73,7 @@ def _serialize_openapi3(definitions: DefinitionList) -> Generator[Callable | Non
|
|
69
73
|
|
70
74
|
|
71
75
|
def _serialize_path_openapi3(
|
72
|
-
name: str, type_: str, style: str | None, explode: bool | None
|
76
|
+
name: str, type_: str | None, style: str | None, explode: bool | None
|
73
77
|
) -> Generator[Callable | None, None, None]:
|
74
78
|
if style == "simple":
|
75
79
|
if type_ == "object":
|
@@ -96,7 +100,7 @@ def _serialize_path_openapi3(
|
|
96
100
|
|
97
101
|
|
98
102
|
def _serialize_query_openapi3(
|
99
|
-
name: str, type_: str, style: str | None, explode: bool | None
|
103
|
+
name: str, type_: str | None, style: str | None, explode: bool | None
|
100
104
|
) -> Generator[Callable | None, None, None]:
|
101
105
|
if type_ == "object":
|
102
106
|
if style == "deepObject":
|
@@ -115,7 +119,9 @@ def _serialize_query_openapi3(
|
|
115
119
|
yield delimited(name, delimiter=",")
|
116
120
|
|
117
121
|
|
118
|
-
def _serialize_header_openapi3(
|
122
|
+
def _serialize_header_openapi3(
|
123
|
+
name: str, type_: str | None, explode: bool | None
|
124
|
+
) -> Generator[Callable | None, None, None]:
|
119
125
|
# Headers should be coerced to a string so we can check it for validity later
|
120
126
|
yield to_string(name)
|
121
127
|
# Header parameters always use the "simple" style, that is, comma-separated values
|
@@ -128,7 +134,9 @@ def _serialize_header_openapi3(name: str, type_: str, explode: bool | None) -> G
|
|
128
134
|
yield delimited_object(name)
|
129
135
|
|
130
136
|
|
131
|
-
def _serialize_cookie_openapi3(
|
137
|
+
def _serialize_cookie_openapi3(
|
138
|
+
name: str, type_: str | None, explode: bool | None
|
139
|
+
) -> Generator[Callable | None, None, None]:
|
132
140
|
# Cookies should be coerced to a string so we can check it for validity later
|
133
141
|
yield to_string(name)
|
134
142
|
# Cookie parameters always use the "form" style
|
@@ -213,7 +221,7 @@ def to_json(item: Generated, name: str) -> None:
|
|
213
221
|
|
214
222
|
@conversion
|
215
223
|
def delimited(item: Generated, name: str, delimiter: str) -> None:
|
216
|
-
item[name] = delimiter.join(map(str, force_iterable(item[name]
|
224
|
+
item[name] = delimiter.join(map(str, force_iterable(item[name] if item[name] is not None else ())))
|
217
225
|
|
218
226
|
|
219
227
|
@conversion
|
@@ -8,19 +8,20 @@ import jsonschema
|
|
8
8
|
from hypothesis import strategies as st
|
9
9
|
from hypothesis.stateful import Bundle, Rule, precondition, rule
|
10
10
|
|
11
|
-
from schemathesis.core.errors import InvalidStateMachine
|
11
|
+
from schemathesis.core.errors import InvalidStateMachine, InvalidTransition
|
12
|
+
from schemathesis.core.parameters import ParameterLocation
|
12
13
|
from schemathesis.core.result import Ok
|
13
14
|
from schemathesis.core.transforms import UNRESOLVABLE
|
14
15
|
from schemathesis.engine.recorder import ScenarioRecorder
|
15
16
|
from schemathesis.generation import GenerationMode
|
16
17
|
from schemathesis.generation.case import Case
|
17
18
|
from schemathesis.generation.hypothesis import strategies
|
18
|
-
from schemathesis.generation.meta import ComponentInfo,
|
19
|
+
from schemathesis.generation.meta import ComponentInfo, TestPhase
|
19
20
|
from schemathesis.generation.stateful import STATEFUL_TESTS_LABEL
|
20
21
|
from schemathesis.generation.stateful.state_machine import APIStateMachine, StepInput, StepOutput, _normalize_name
|
21
22
|
from schemathesis.schemas import APIOperation
|
22
23
|
from schemathesis.specs.openapi.stateful.control import TransitionController
|
23
|
-
from schemathesis.specs.openapi.stateful.links import OpenApiLink
|
24
|
+
from schemathesis.specs.openapi.stateful.links import OpenApiLink
|
24
25
|
from schemathesis.specs.openapi.utils import expand_status_code
|
25
26
|
|
26
27
|
if TYPE_CHECKING:
|
@@ -97,12 +98,14 @@ def collect_transitions(operations: list[APIOperation]) -> ApiTransitions:
|
|
97
98
|
selected_labels = {operation.label for operation in operations}
|
98
99
|
errors = []
|
99
100
|
for operation in operations:
|
100
|
-
for
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
101
|
+
for status_code, response in operation.responses.items():
|
102
|
+
for name, link in response.iter_links():
|
103
|
+
try:
|
104
|
+
link = OpenApiLink(name, status_code, link, operation)
|
105
|
+
if link.target.label in selected_labels:
|
106
|
+
transitions.add_outgoing(operation.label, link)
|
107
|
+
except InvalidTransition as exc:
|
108
|
+
errors.append(exc)
|
106
109
|
|
107
110
|
if errors:
|
108
111
|
raise InvalidStateMachine(errors)
|
@@ -118,7 +121,7 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
|
|
118
121
|
|
119
122
|
# Create bundles and matchers
|
120
123
|
for operation in operations:
|
121
|
-
all_status_codes =
|
124
|
+
all_status_codes = operation.responses.status_codes
|
122
125
|
bundle_matchers = []
|
123
126
|
|
124
127
|
if operation.label in transitions.operations:
|
@@ -287,9 +290,11 @@ def into_step_input(
|
|
287
290
|
# It is possible that the new body is now valid and the whole test case could be valid too
|
288
291
|
for alternative in case.operation.body:
|
289
292
|
if alternative.media_type == case.media_type:
|
290
|
-
schema = alternative.
|
293
|
+
schema = alternative.optimized_schema
|
291
294
|
if jsonschema.validators.validator_for(schema)(schema).is_valid(new):
|
292
|
-
case.meta.components[
|
295
|
+
case.meta.components[ParameterLocation.BODY] = ComponentInfo(
|
296
|
+
mode=GenerationMode.POSITIVE
|
297
|
+
)
|
293
298
|
if all(info.mode == GenerationMode.POSITIVE for info in case.meta.components.values()):
|
294
299
|
case.meta.generation.mode = GenerationMode.POSITIVE
|
295
300
|
return StepInput(case=case, transition=transition)
|
@@ -20,6 +20,8 @@ from urllib.parse import urlsplit
|
|
20
20
|
from werkzeug.exceptions import MethodNotAllowed, NotFound
|
21
21
|
from werkzeug.routing import Map, MapAdapter, Rule
|
22
22
|
|
23
|
+
from schemathesis.core.adapter import ResponsesContainer
|
24
|
+
|
23
25
|
if TYPE_CHECKING:
|
24
26
|
from schemathesis.engine.observations import LocationHeaderEntry
|
25
27
|
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
@@ -78,9 +80,9 @@ class LinkInferencer:
|
|
78
80
|
_operations: list[OperationReference]
|
79
81
|
_base_url: str | None
|
80
82
|
_base_path: str
|
81
|
-
|
83
|
+
_links_keyword: str
|
82
84
|
|
83
|
-
__slots__ = ("_adapter", "_operations", "_base_url", "_base_path", "
|
85
|
+
__slots__ = ("_adapter", "_operations", "_base_url", "_base_path", "_links_keyword")
|
84
86
|
|
85
87
|
@classmethod
|
86
88
|
def from_schema(cls, schema: BaseOpenAPISchema) -> LinkInferencer:
|
@@ -107,7 +109,7 @@ class LinkInferencer:
|
|
107
109
|
_operations=operations,
|
108
110
|
_base_url=schema.config.base_url,
|
109
111
|
_base_path=schema.base_path,
|
110
|
-
|
112
|
+
_links_keyword=schema.adapter.links_keyword,
|
111
113
|
)
|
112
114
|
|
113
115
|
def match(self, path: str) -> tuple[OperationReference, Mapping[str, str]] | None:
|
@@ -214,10 +216,7 @@ class LinkInferencer:
|
|
214
216
|
relative_path = path[len(base_path) :]
|
215
217
|
return relative_path if relative_path.startswith("/") else "/" + relative_path
|
216
218
|
|
217
|
-
def inject_links(self,
|
218
|
-
from schemathesis.specs.openapi.schemas import _get_response_definition_by_status
|
219
|
-
|
220
|
-
responses = operation.setdefault("responses", {})
|
219
|
+
def inject_links(self, responses: ResponsesContainer, entries: list[LocationHeaderEntry]) -> int:
|
221
220
|
# To avoid unnecessary work, we need to skip entries that we know will produce already inferred links
|
222
221
|
seen: set[SeenLinkKey] = set()
|
223
222
|
injected = 0
|
@@ -239,10 +238,13 @@ class LinkInferencer:
|
|
239
238
|
continue
|
240
239
|
seen.add(key)
|
241
240
|
# Find the right bucket for the response status or create a new one
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
241
|
+
response = responses.find_by_status_code(entry.status_code)
|
242
|
+
links: dict[str, dict[str, dict]]
|
243
|
+
if response is None:
|
244
|
+
links = {}
|
245
|
+
responses.add(str(entry.status_code), {self._links_keyword: links})
|
246
|
+
else:
|
247
|
+
links = response.definition.setdefault(self._links_keyword, {})
|
246
248
|
|
247
249
|
for idx, link in enumerate(self._build_links_from_matches(matches)):
|
248
250
|
links[f"X-Inferred-Link-{idx}"] = link
|
@@ -2,19 +2,17 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
from dataclasses import dataclass
|
4
4
|
from functools import lru_cache
|
5
|
-
from typing import Any, Callable
|
5
|
+
from typing import Any, Callable
|
6
6
|
|
7
7
|
from schemathesis.core import NOT_SET, NotSet
|
8
8
|
from schemathesis.core.errors import InvalidTransition, OperationNotFound, TransitionValidationError
|
9
|
+
from schemathesis.core.parameters import ParameterLocation
|
9
10
|
from schemathesis.core.result import Err, Ok, Result
|
10
11
|
from schemathesis.generation.stateful.state_machine import ExtractedParam, StepOutput, Transition
|
11
12
|
from schemathesis.schemas import APIOperation
|
12
13
|
from schemathesis.specs.openapi import expressions
|
13
|
-
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
14
|
-
from schemathesis.specs.openapi.references import RECURSION_DEPTH_LIMIT
|
15
14
|
|
16
15
|
SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
|
17
|
-
ParameterLocation = Literal["path", "query", "header", "cookie", "body"]
|
18
16
|
|
19
17
|
|
20
18
|
@dataclass
|
@@ -96,7 +94,7 @@ class OpenApiLink:
|
|
96
94
|
try:
|
97
95
|
# The parameter name is prefixed with its location. Example: `path.id`
|
98
96
|
_location, name = tuple(parameter.split("."))
|
99
|
-
location =
|
97
|
+
location = ParameterLocation(_location)
|
100
98
|
except ValueError:
|
101
99
|
location = None
|
102
100
|
name = parameter
|
@@ -135,11 +133,11 @@ class OpenApiLink:
|
|
135
133
|
def _get_parameter_container(self, location: ParameterLocation | None, name: str) -> str:
|
136
134
|
"""Resolve parameter container either from explicit location or by looking up in target operation."""
|
137
135
|
if location:
|
138
|
-
return
|
136
|
+
return location.container_name
|
139
137
|
|
140
138
|
for param in self.target.iter_parameters():
|
141
139
|
if param.name == name:
|
142
|
-
return
|
140
|
+
return param.location.container_name
|
143
141
|
raise TransitionValidationError(f"Parameter `{name}` is not defined in API operation `{self.target.label}`")
|
144
142
|
|
145
143
|
def extract(self, output: StepOutput) -> Transition:
|
@@ -195,16 +193,3 @@ class StepOutputWrapper:
|
|
195
193
|
def __eq__(self, other: object) -> bool:
|
196
194
|
assert isinstance(other, StepOutputWrapper)
|
197
195
|
return self.output.case.id == other.output.case.id
|
198
|
-
|
199
|
-
|
200
|
-
def get_all_links(
|
201
|
-
operation: APIOperation,
|
202
|
-
) -> Generator[tuple[str, Result[OpenApiLink, InvalidTransition]], None, None]:
|
203
|
-
for status_code, definition in operation.definition.raw["responses"].items():
|
204
|
-
definition = operation.schema.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 8) # type: ignore[attr-defined]
|
205
|
-
for name, link_definition in definition.get(operation.schema.links_field, {}).items(): # type: ignore
|
206
|
-
try:
|
207
|
-
link = OpenApiLink(name, status_code, link_definition, operation)
|
208
|
-
yield status_code, Ok(link)
|
209
|
-
except InvalidTransition as exc:
|
210
|
-
yield status_code, Err(exc)
|
@@ -0,0 +1,68 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import Any, Mapping, TypedDict, Union
|
4
|
+
|
5
|
+
from typing_extensions import NotRequired
|
6
|
+
|
7
|
+
Reference = TypedDict("Reference", {"$ref": str})
|
8
|
+
|
9
|
+
|
10
|
+
class Operation(TypedDict):
|
11
|
+
responses: Responses
|
12
|
+
requestBody: NotRequired[RequestBodyOrRef]
|
13
|
+
|
14
|
+
|
15
|
+
class RequestBody(TypedDict):
|
16
|
+
content: dict[str, MediaType]
|
17
|
+
required: NotRequired[bool]
|
18
|
+
|
19
|
+
|
20
|
+
class MediaType(TypedDict):
|
21
|
+
schema: Schema
|
22
|
+
example: Any
|
23
|
+
|
24
|
+
|
25
|
+
class Example(TypedDict):
|
26
|
+
value: NotRequired[Any]
|
27
|
+
externalValue: NotRequired[str]
|
28
|
+
|
29
|
+
|
30
|
+
class Link(TypedDict):
|
31
|
+
operationId: NotRequired[str]
|
32
|
+
operationRef: NotRequired[str]
|
33
|
+
parameters: NotRequired[dict[str, Any]]
|
34
|
+
requestBody: NotRequired[Any]
|
35
|
+
server: NotRequired[Any]
|
36
|
+
|
37
|
+
|
38
|
+
class Response(TypedDict):
|
39
|
+
headers: NotRequired[dict[str, HeaderOrRef]]
|
40
|
+
content: NotRequired[dict[str, MediaType]]
|
41
|
+
links: NotRequired[dict[str, LinkOrRef]]
|
42
|
+
|
43
|
+
|
44
|
+
_ResponsesBase = Mapping[str, Union[Response, Reference]]
|
45
|
+
|
46
|
+
|
47
|
+
class Responses(_ResponsesBase):
|
48
|
+
pass
|
49
|
+
|
50
|
+
|
51
|
+
class Header(TypedDict):
|
52
|
+
required: NotRequired[bool]
|
53
|
+
|
54
|
+
|
55
|
+
_HeadersBase = Mapping[str, Union[Header, Reference]]
|
56
|
+
|
57
|
+
|
58
|
+
class Headers(_HeadersBase):
|
59
|
+
pass
|
60
|
+
|
61
|
+
|
62
|
+
SchemaObject = TypedDict("SchemaObject", {"$ref": str})
|
63
|
+
Schema = Union[SchemaObject, bool]
|
64
|
+
RequestBodyOrRef = Union[RequestBody, Reference]
|
65
|
+
ExampleOrRef = Union[Example, Reference]
|
66
|
+
HeaderOrRef = Union[Header, Reference]
|
67
|
+
LinkOrRef = Union[Link, Reference]
|
68
|
+
ResponseOrRef = Union[Response, Reference]
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import string
|
4
4
|
from itertools import chain, product
|
5
|
-
from typing import
|
5
|
+
from typing import Generator
|
6
6
|
|
7
7
|
|
8
8
|
def expand_status_code(status_code: str | int) -> Generator[int, None, None]:
|
@@ -13,15 +13,3 @@ def expand_status_code(status_code: str | int) -> Generator[int, None, None]:
|
|
13
13
|
|
14
14
|
def expand_status_codes(status_codes: list[str]) -> set[int]:
|
15
15
|
return set(chain.from_iterable(expand_status_code(code) for code in status_codes))
|
16
|
-
|
17
|
-
|
18
|
-
def is_header_location(location: str) -> bool:
|
19
|
-
"""Whether this location affects HTTP headers."""
|
20
|
-
return location in ("header", "cookie")
|
21
|
-
|
22
|
-
|
23
|
-
def get_type(schema: dict[str, Any]) -> list[str]:
|
24
|
-
type_ = schema.get("type", ["null", "boolean", "integer", "number", "string", "array", "object"])
|
25
|
-
if isinstance(type_, str):
|
26
|
-
return [type_]
|
27
|
-
return type_
|
@@ -9,7 +9,7 @@ from urllib.parse import urlparse
|
|
9
9
|
|
10
10
|
from schemathesis.core import NotSet
|
11
11
|
from schemathesis.core.rate_limit import ratelimit
|
12
|
-
from schemathesis.core.transforms import
|
12
|
+
from schemathesis.core.transforms import merge_at
|
13
13
|
from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT, Response
|
14
14
|
from schemathesis.generation.overrides import Override
|
15
15
|
from schemathesis.transport import BaseTransport, SerializationContext
|
@@ -61,7 +61,7 @@ class RequestsTransport(BaseTransport["requests.Session"]):
|
|
61
61
|
|
62
62
|
# Replace empty dictionaries with empty strings, so the parameters actually present in the query string
|
63
63
|
if any(value == {} for value in (params or {}).values()):
|
64
|
-
params =
|
64
|
+
params = dict(params)
|
65
65
|
for key, value in params.items():
|
66
66
|
if value == {}:
|
67
67
|
params[key] = ""
|
@@ -243,7 +243,6 @@ def multipart_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any
|
|
243
243
|
if isinstance(value, bytes):
|
244
244
|
return {"data": value}
|
245
245
|
if isinstance(value, dict):
|
246
|
-
value = deepclone(value)
|
247
246
|
multipart = _prepare_form_data(value)
|
248
247
|
files, data = ctx.case.operation.prepare_multipart(multipart)
|
249
248
|
return {"files": files, "data": data}
|
@@ -256,14 +255,7 @@ def multipart_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any
|
|
256
255
|
|
257
256
|
@REQUESTS_TRANSPORT.serializer("application/xml", "text/xml")
|
258
257
|
def xml_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
|
259
|
-
|
260
|
-
|
261
|
-
assert media_type is not None
|
262
|
-
|
263
|
-
raw_schema = ctx.case.operation.get_raw_payload_schema(media_type)
|
264
|
-
resolved_schema = ctx.case.operation.get_resolved_payload_schema(media_type)
|
265
|
-
|
266
|
-
return serialize_xml(value, raw_schema, resolved_schema)
|
258
|
+
return serialize_xml(ctx.case, value)
|
267
259
|
|
268
260
|
|
269
261
|
@REQUESTS_TRANSPORT.serializer("application/x-www-form-urlencoded")
|
@@ -3,11 +3,15 @@ from __future__ import annotations
|
|
3
3
|
import re
|
4
4
|
from dataclasses import dataclass
|
5
5
|
from io import StringIO
|
6
|
-
from typing import Any, Dict, List, Union
|
6
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Union
|
7
7
|
from unicodedata import normalize
|
8
8
|
|
9
9
|
from schemathesis.core.errors import UnboundPrefix
|
10
|
-
from schemathesis.core.transforms import
|
10
|
+
from schemathesis.core.transforms import transform
|
11
|
+
|
12
|
+
if TYPE_CHECKING:
|
13
|
+
from schemathesis.core.compat import RefResolver
|
14
|
+
from schemathesis.generation.case import Case
|
11
15
|
|
12
16
|
|
13
17
|
@dataclass
|
@@ -76,44 +80,67 @@ DEFAULT_TAG_NAME = "data"
|
|
76
80
|
NAMESPACE_URL = "http://example.com/schema"
|
77
81
|
|
78
82
|
|
79
|
-
def serialize_xml(
|
80
|
-
|
81
|
-
|
83
|
+
def serialize_xml(case: Case, value: Any) -> dict[str, Any]:
|
84
|
+
media_type = case.media_type
|
85
|
+
|
86
|
+
assert media_type is not None
|
87
|
+
|
88
|
+
schema = None
|
89
|
+
resource_name = None
|
90
|
+
|
91
|
+
for body in case.operation.get_bodies_for_media_type(media_type):
|
92
|
+
schema = body.optimized_schema
|
93
|
+
resource_name = body.resource_name
|
94
|
+
break
|
95
|
+
assert schema is not None, (case.operation.body, media_type)
|
96
|
+
|
97
|
+
return _serialize_xml(value, schema, resource_name=resource_name)
|
98
|
+
|
99
|
+
|
100
|
+
def _serialize_xml(value: Any, schema: dict[str, Any], resource_name: str | None) -> dict[str, Any]:
|
82
101
|
"""Serialize a generated Python object as an XML string.
|
83
102
|
|
84
103
|
Schemas may contain additional information for fine-tuned XML serialization.
|
85
104
|
"""
|
105
|
+
from schemathesis.core.compat import RefResolver
|
106
|
+
|
86
107
|
if isinstance(value, (bytes, str)):
|
87
108
|
return {"data": value}
|
88
|
-
|
109
|
+
resolver = RefResolver.from_schema(schema)
|
110
|
+
if "$ref" in schema:
|
111
|
+
_, schema = resolver.resolve(schema["$ref"])
|
112
|
+
tag = _get_xml_tag(schema, resource_name)
|
89
113
|
buffer = StringIO()
|
90
114
|
# Collect all namespaces to ensure that all child nodes with prefixes have proper namespaces in their parent nodes
|
91
115
|
namespace_stack: list[str] = []
|
92
|
-
_write_xml(buffer, value, tag,
|
116
|
+
_write_xml(buffer, value, tag, schema, namespace_stack, resolver)
|
93
117
|
data = buffer.getvalue()
|
94
118
|
return {"data": data.encode("utf8")}
|
95
119
|
|
96
120
|
|
97
|
-
def _get_xml_tag(
|
121
|
+
def _get_xml_tag(schema: dict[str, Any] | None, resource_name: str | None) -> str:
|
98
122
|
# On the top level we need to detect the proper XML tag, in other cases it is known from object properties
|
99
|
-
if (
|
100
|
-
return (
|
101
|
-
|
102
|
-
|
103
|
-
if "$ref" in (raw_schema or {}):
|
104
|
-
return _get_tag_name_from_reference((raw_schema or {})["$ref"])
|
123
|
+
if (schema or {}).get("xml", {}).get("name"):
|
124
|
+
return (schema or {})["xml"]["name"]
|
125
|
+
if resource_name is not None:
|
126
|
+
return resource_name
|
105
127
|
|
106
128
|
# Here we don't have any name for the payload schema - no reference or the `xml` property
|
107
129
|
return DEFAULT_TAG_NAME
|
108
130
|
|
109
131
|
|
110
132
|
def _write_xml(
|
111
|
-
buffer: StringIO,
|
133
|
+
buffer: StringIO,
|
134
|
+
value: JSON,
|
135
|
+
tag: str,
|
136
|
+
schema: dict[str, Any] | None,
|
137
|
+
namespace_stack: list[str],
|
138
|
+
resolver: RefResolver,
|
112
139
|
) -> None:
|
113
140
|
if isinstance(value, dict):
|
114
|
-
_write_object(buffer, value, tag, schema, namespace_stack)
|
141
|
+
_write_object(buffer, value, tag, schema, namespace_stack, resolver)
|
115
142
|
elif isinstance(value, list):
|
116
|
-
_write_array(buffer, value, tag, schema, namespace_stack)
|
143
|
+
_write_array(buffer, value, tag, schema, namespace_stack, resolver)
|
117
144
|
else:
|
118
145
|
_write_primitive(buffer, value, tag, schema, namespace_stack)
|
119
146
|
|
@@ -138,7 +165,12 @@ def pop_namespace_if_any(namespace_stack: list[str], options: dict[str, Any]) ->
|
|
138
165
|
|
139
166
|
|
140
167
|
def _write_object(
|
141
|
-
buffer: StringIO,
|
168
|
+
buffer: StringIO,
|
169
|
+
obj: dict[str, JSON],
|
170
|
+
tag: str,
|
171
|
+
schema: dict[str, Any] | None,
|
172
|
+
stack: list[str],
|
173
|
+
resolver: RefResolver,
|
142
174
|
) -> None:
|
143
175
|
options = (schema or {}).get("xml", {})
|
144
176
|
push_namespace_if_any(stack, options)
|
@@ -155,6 +187,8 @@ def _write_object(
|
|
155
187
|
properties = (schema or {}).get("properties", {})
|
156
188
|
for child_name, value in obj.items():
|
157
189
|
property_schema = properties.get(child_name, {})
|
190
|
+
if "$ref" in property_schema:
|
191
|
+
_, property_schema = resolver.resolve(property_schema["$ref"])
|
158
192
|
child_options = property_schema.get("xml", {})
|
159
193
|
push_namespace_if_any(stack, child_options)
|
160
194
|
child_tag = child_options.get("name", child_name)
|
@@ -178,7 +212,7 @@ def _write_object(
|
|
178
212
|
_validate_prefix(child_options, stack)
|
179
213
|
prefix = child_options["prefix"]
|
180
214
|
child_tag = f"{prefix}:{child_tag}"
|
181
|
-
_write_xml(children_buffer, value, child_tag, property_schema, stack)
|
215
|
+
_write_xml(children_buffer, value, child_tag, property_schema, stack, resolver)
|
182
216
|
pop_namespace_if_any(stack, child_options)
|
183
217
|
|
184
218
|
# Write namespace declarations for attributes
|
@@ -193,7 +227,9 @@ def _write_object(
|
|
193
227
|
pop_namespace_if_any(stack, options)
|
194
228
|
|
195
229
|
|
196
|
-
def _write_array(
|
230
|
+
def _write_array(
|
231
|
+
buffer: StringIO, obj: list[JSON], tag: str, schema: dict[str, Any] | None, stack: list[str], resolver: RefResolver
|
232
|
+
) -> None:
|
197
233
|
options = (schema or {}).get("xml", {})
|
198
234
|
push_namespace_if_any(stack, options)
|
199
235
|
if options.get("prefix"):
|
@@ -207,7 +243,12 @@ def _write_array(buffer: StringIO, obj: list[JSON], tag: str, schema: dict[str,
|
|
207
243
|
_write_namespace(buffer, options)
|
208
244
|
buffer.write(">")
|
209
245
|
# In Open API `items` value should be an object and not an array
|
210
|
-
|
246
|
+
if schema:
|
247
|
+
items = dict(schema.get("items", {}))
|
248
|
+
else:
|
249
|
+
items = {}
|
250
|
+
if "$ref" in items:
|
251
|
+
_, items = resolver.resolve(items["$ref"])
|
211
252
|
child_options = items.get("xml", {})
|
212
253
|
child_tag = child_options.get("name", tag)
|
213
254
|
if not is_namespace_specified and "namespace" in options:
|
@@ -217,7 +258,7 @@ def _write_array(buffer: StringIO, obj: list[JSON], tag: str, schema: dict[str,
|
|
217
258
|
items["xml"] = child_options
|
218
259
|
_validate_prefix(child_options, stack)
|
219
260
|
for item in obj:
|
220
|
-
_write_xml(buffer, item, child_tag, items, stack)
|
261
|
+
_write_xml(buffer, item, child_tag, items, stack, resolver)
|
221
262
|
if wrapped:
|
222
263
|
buffer.write(f"</{tag}>")
|
223
264
|
pop_namespace_if_any(stack, options)
|
@@ -243,11 +284,6 @@ def _write_namespace(buffer: StringIO, options: dict[str, Any]) -> None:
|
|
243
284
|
buffer.write(f'="{options["namespace"]}"')
|
244
285
|
|
245
286
|
|
246
|
-
def _get_tag_name_from_reference(reference: str) -> str:
|
247
|
-
"""Extract object name from a reference."""
|
248
|
-
return reference.rsplit("/", maxsplit=1)[1]
|
249
|
-
|
250
|
-
|
251
287
|
def _escape_xml(value: JSON) -> str:
|
252
288
|
"""Escape special characters in XML content."""
|
253
289
|
if isinstance(value, (int, float, bool)):
|
schemathesis/transport/wsgi.py
CHANGED
@@ -154,14 +154,7 @@ def multipart_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any
|
|
154
154
|
|
155
155
|
@WSGI_TRANSPORT.serializer("application/xml", "text/xml")
|
156
156
|
def xml_serializer(ctx: SerializationContext, value: Any) -> dict[str, Any]:
|
157
|
-
|
158
|
-
|
159
|
-
assert media_type is not None
|
160
|
-
|
161
|
-
raw_schema = ctx.case.operation.get_raw_payload_schema(media_type)
|
162
|
-
resolved_schema = ctx.case.operation.get_resolved_payload_schema(media_type)
|
163
|
-
|
164
|
-
return serialize_xml(value, raw_schema, resolved_schema)
|
157
|
+
return serialize_xml(ctx.case, value)
|
165
158
|
|
166
159
|
|
167
160
|
@WSGI_TRANSPORT.serializer("application/x-www-form-urlencoded")
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: schemathesis
|
3
|
-
Version: 4.1
|
3
|
+
Version: 4.2.1
|
4
4
|
Summary: Property-based testing framework for Open API and GraphQL based apps
|
5
5
|
Project-URL: Documentation, https://schemathesis.readthedocs.io/en/stable/
|
6
6
|
Project-URL: Changelog, https://github.com/schemathesis/schemathesis/blob/master/CHANGELOG.md
|
@@ -32,7 +32,7 @@ Requires-Python: >=3.9
|
|
32
32
|
Requires-Dist: backoff<3.0,>=2.1.2
|
33
33
|
Requires-Dist: click<9,>=8.0
|
34
34
|
Requires-Dist: colorama<1.0,>=0.4
|
35
|
-
Requires-Dist: harfile<1.0,>=0.
|
35
|
+
Requires-Dist: harfile<1.0,>=0.4.0
|
36
36
|
Requires-Dist: httpx<1.0,>=0.22.0
|
37
37
|
Requires-Dist: hypothesis-graphql<1,>=0.11.1
|
38
38
|
Requires-Dist: hypothesis-jsonschema<0.24,>=0.23.1
|