schemathesis 3.29.2__py3-none-any.whl → 3.30.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/__init__.py +3 -3
- schemathesis/_compat.py +2 -2
- schemathesis/_dependency_versions.py +1 -3
- schemathesis/_hypothesis.py +6 -0
- schemathesis/_lazy_import.py +1 -0
- schemathesis/_override.py +1 -0
- schemathesis/_rate_limiter.py +2 -1
- schemathesis/_xml.py +1 -0
- schemathesis/auths.py +4 -2
- schemathesis/checks.py +8 -5
- schemathesis/cli/__init__.py +8 -1
- schemathesis/cli/callbacks.py +3 -4
- schemathesis/cli/cassettes.py +6 -4
- schemathesis/cli/constants.py +2 -0
- schemathesis/cli/context.py +3 -0
- schemathesis/cli/debug.py +2 -1
- schemathesis/cli/handlers.py +1 -1
- schemathesis/cli/options.py +1 -0
- schemathesis/cli/output/default.py +50 -22
- schemathesis/cli/output/short.py +21 -10
- schemathesis/cli/sanitization.py +1 -0
- schemathesis/code_samples.py +1 -0
- schemathesis/constants.py +1 -0
- schemathesis/contrib/openapi/__init__.py +1 -1
- schemathesis/contrib/openapi/fill_missing_examples.py +2 -0
- schemathesis/contrib/openapi/formats/uuid.py +2 -1
- schemathesis/contrib/unique_data.py +2 -1
- schemathesis/exceptions.py +40 -26
- schemathesis/experimental/__init__.py +14 -0
- schemathesis/extra/_aiohttp.py +1 -0
- schemathesis/extra/_server.py +1 -0
- schemathesis/extra/pytest_plugin.py +13 -24
- schemathesis/failures.py +32 -3
- schemathesis/filters.py +2 -1
- schemathesis/fixups/__init__.py +1 -0
- schemathesis/fixups/fast_api.py +2 -2
- schemathesis/fixups/utf8_bom.py +1 -2
- schemathesis/generation/__init__.py +2 -1
- schemathesis/hooks.py +3 -1
- schemathesis/internal/copy.py +19 -3
- schemathesis/internal/deprecation.py +1 -1
- schemathesis/internal/jsonschema.py +2 -1
- schemathesis/internal/result.py +1 -1
- schemathesis/internal/transformation.py +1 -0
- schemathesis/lazy.py +3 -2
- schemathesis/loaders.py +4 -2
- schemathesis/models.py +20 -5
- schemathesis/parameters.py +1 -0
- schemathesis/runner/__init__.py +1 -1
- schemathesis/runner/events.py +21 -4
- schemathesis/runner/impl/core.py +61 -33
- schemathesis/runner/impl/solo.py +2 -1
- schemathesis/runner/impl/threadpool.py +4 -0
- schemathesis/runner/probes.py +1 -1
- schemathesis/runner/serialization.py +1 -1
- schemathesis/sanitization.py +2 -0
- schemathesis/schemas.py +1 -4
- schemathesis/service/ci.py +1 -0
- schemathesis/service/client.py +7 -7
- schemathesis/service/events.py +2 -1
- schemathesis/service/extensions.py +5 -5
- schemathesis/service/hosts.py +1 -0
- schemathesis/service/metadata.py +2 -1
- schemathesis/service/models.py +2 -1
- schemathesis/service/report.py +3 -3
- schemathesis/service/serialization.py +54 -23
- schemathesis/service/usage.py +1 -0
- schemathesis/specs/graphql/_cache.py +1 -1
- schemathesis/specs/graphql/loaders.py +1 -1
- schemathesis/specs/graphql/nodes.py +1 -0
- schemathesis/specs/graphql/scalars.py +2 -2
- schemathesis/specs/graphql/schemas.py +7 -7
- schemathesis/specs/graphql/validation.py +1 -2
- schemathesis/specs/openapi/_hypothesis.py +17 -11
- schemathesis/specs/openapi/checks.py +102 -9
- schemathesis/specs/openapi/converter.py +2 -1
- schemathesis/specs/openapi/definitions.py +2 -1
- schemathesis/specs/openapi/examples.py +7 -9
- schemathesis/specs/openapi/expressions/__init__.py +29 -2
- schemathesis/specs/openapi/expressions/context.py +1 -1
- schemathesis/specs/openapi/expressions/extractors.py +23 -0
- schemathesis/specs/openapi/expressions/lexer.py +19 -18
- schemathesis/specs/openapi/expressions/nodes.py +24 -4
- schemathesis/specs/openapi/expressions/parser.py +26 -5
- schemathesis/specs/openapi/filters.py +1 -0
- schemathesis/specs/openapi/links.py +35 -7
- schemathesis/specs/openapi/loaders.py +13 -11
- schemathesis/specs/openapi/negative/__init__.py +2 -1
- schemathesis/specs/openapi/negative/mutations.py +1 -0
- schemathesis/specs/openapi/parameters.py +1 -0
- schemathesis/specs/openapi/schemas.py +27 -38
- schemathesis/specs/openapi/security.py +1 -0
- schemathesis/specs/openapi/serialization.py +1 -0
- schemathesis/specs/openapi/stateful/__init__.py +159 -70
- schemathesis/specs/openapi/stateful/statistic.py +198 -0
- schemathesis/specs/openapi/stateful/types.py +13 -0
- schemathesis/specs/openapi/utils.py +1 -0
- schemathesis/specs/openapi/validation.py +1 -0
- schemathesis/stateful/__init__.py +4 -2
- schemathesis/stateful/config.py +66 -0
- schemathesis/stateful/context.py +93 -0
- schemathesis/stateful/events.py +209 -0
- schemathesis/stateful/runner.py +233 -0
- schemathesis/stateful/sink.py +68 -0
- schemathesis/stateful/state_machine.py +39 -22
- schemathesis/stateful/statistic.py +20 -0
- schemathesis/stateful/validation.py +66 -0
- schemathesis/targets.py +1 -0
- schemathesis/throttling.py +23 -3
- schemathesis/transports/__init__.py +28 -10
- schemathesis/transports/auth.py +1 -0
- schemathesis/transports/content_types.py +1 -1
- schemathesis/transports/headers.py +2 -1
- schemathesis/transports/responses.py +6 -4
- schemathesis/types.py +1 -0
- schemathesis/utils.py +1 -0
- {schemathesis-3.29.2.dist-info → schemathesis-3.30.0.dist-info}/METADATA +1 -1
- schemathesis-3.30.0.dist-info/RECORD +150 -0
- schemathesis/specs/openapi/stateful/links.py +0 -92
- schemathesis-3.29.2.dist-info/RECORD +0 -141
- {schemathesis-3.29.2.dist-info → schemathesis-3.30.0.dist-info}/WHEEL +0 -0
- {schemathesis-3.29.2.dist-info → schemathesis-3.30.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.29.2.dist-info → schemathesis-3.30.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -7,7 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
9
|
from difflib import get_close_matches
|
|
10
|
-
from typing import TYPE_CHECKING, Any, Generator, NoReturn, Sequence, Union
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Generator, NoReturn, Sequence, TypedDict, Union
|
|
11
11
|
|
|
12
12
|
from jsonschema import RefResolver
|
|
13
13
|
|
|
@@ -21,7 +21,7 @@ from ...types import NotSet
|
|
|
21
21
|
from . import expressions
|
|
22
22
|
from .constants import LOCATION_TO_CONTAINER
|
|
23
23
|
from .parameters import OpenAPI20Body, OpenAPI30Body, OpenAPIParameter
|
|
24
|
-
from .references import
|
|
24
|
+
from .references import RECURSION_DEPTH_LIMIT, Unresolvable
|
|
25
25
|
|
|
26
26
|
if TYPE_CHECKING:
|
|
27
27
|
from ...transports.responses import GenericResponse
|
|
@@ -32,6 +32,7 @@ class Link(StatefulTest):
|
|
|
32
32
|
operation: APIOperation
|
|
33
33
|
parameters: dict[str, Any]
|
|
34
34
|
request_body: Any = NOT_SET
|
|
35
|
+
merge_body: bool = True
|
|
35
36
|
|
|
36
37
|
def __post_init__(self) -> None:
|
|
37
38
|
if self.request_body is not NOT_SET and not self.operation.body:
|
|
@@ -51,6 +52,7 @@ class Link(StatefulTest):
|
|
|
51
52
|
operation = source_operation.schema.get_operation_by_id(definition["operationId"]) # type: ignore
|
|
52
53
|
else:
|
|
53
54
|
operation = source_operation.schema.get_operation_by_reference(definition["operationRef"]) # type: ignore
|
|
55
|
+
extension = definition.get(SCHEMATHESIS_LINK_EXTENSION)
|
|
54
56
|
return cls(
|
|
55
57
|
# Pylint can't detect that the API operation is always defined at this point
|
|
56
58
|
# E.g. if there is no matching operation or no operations at all, then a ValueError will be risen
|
|
@@ -58,6 +60,7 @@ class Link(StatefulTest):
|
|
|
58
60
|
operation=operation,
|
|
59
61
|
parameters=definition.get("parameters", {}),
|
|
60
62
|
request_body=definition.get("requestBody", NOT_SET), # `None` might be a valid value - `null`
|
|
63
|
+
merge_body=extension.get("merge_body", True) if extension is not None else True,
|
|
61
64
|
)
|
|
62
65
|
|
|
63
66
|
def parse(self, case: Case, response: GenericResponse) -> ParsedData:
|
|
@@ -69,10 +72,9 @@ class Link(StatefulTest):
|
|
|
69
72
|
if isinstance(evaluated, Unresolvable):
|
|
70
73
|
raise UnresolvableLink(f"Unresolvable reference in the link: {expression}")
|
|
71
74
|
parameters[parameter] = evaluated
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
body = expressions.evaluate(self.request_body, context)
|
|
75
|
+
body = expressions.evaluate(self.request_body, context, evaluate_nested=True)
|
|
76
|
+
if self.merge_body:
|
|
77
|
+
body = merge_body(case.body, body)
|
|
76
78
|
return ParsedData(parameters=parameters, body=body)
|
|
77
79
|
|
|
78
80
|
def make_operation(self, collected: list[ParsedData]) -> APIOperation:
|
|
@@ -170,6 +172,13 @@ def get_links(response: GenericResponse, operation: APIOperation, field: str) ->
|
|
|
170
172
|
return [Link.from_definition(name, definition, operation) for name, definition in links.items()]
|
|
171
173
|
|
|
172
174
|
|
|
175
|
+
SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class SchemathesisLink(TypedDict):
|
|
179
|
+
merge_body: bool
|
|
180
|
+
|
|
181
|
+
|
|
173
182
|
@dataclass(repr=False)
|
|
174
183
|
class OpenAPILink(Direction):
|
|
175
184
|
"""Alternative approach to link processing.
|
|
@@ -183,13 +192,22 @@ class OpenAPILink(Direction):
|
|
|
183
192
|
operation: APIOperation
|
|
184
193
|
parameters: list[tuple[str | None, str, str]] = field(init=False)
|
|
185
194
|
body: dict[str, Any] | NotSet = field(init=False)
|
|
195
|
+
merge_body: bool = True
|
|
196
|
+
|
|
197
|
+
def __repr__(self) -> str:
|
|
198
|
+
path = self.operation.path
|
|
199
|
+
method = self.operation.method
|
|
200
|
+
return f"state.schema['{path}']['{method}'].links['{self.status_code}']['{self.name}']"
|
|
186
201
|
|
|
187
202
|
def __post_init__(self) -> None:
|
|
203
|
+
extension = self.definition.get(SCHEMATHESIS_LINK_EXTENSION)
|
|
188
204
|
self.parameters = [
|
|
189
205
|
normalize_parameter(parameter, expression)
|
|
190
206
|
for parameter, expression in self.definition.get("parameters", {}).items()
|
|
191
207
|
]
|
|
192
208
|
self.body = self.definition.get("requestBody", NOT_SET)
|
|
209
|
+
if extension is not None:
|
|
210
|
+
self.merge_body = extension.get("merge_body", True)
|
|
193
211
|
|
|
194
212
|
def set_data(self, case: Case, elapsed: float, **kwargs: Any) -> None:
|
|
195
213
|
"""Assign all linked definitions to the new case instance."""
|
|
@@ -215,7 +233,11 @@ class OpenAPILink(Direction):
|
|
|
215
233
|
|
|
216
234
|
def set_body(self, case: Case, context: expressions.ExpressionContext) -> None:
|
|
217
235
|
if self.body is not NOT_SET:
|
|
218
|
-
|
|
236
|
+
evaluated = expressions.evaluate(self.body, context, evaluate_nested=True)
|
|
237
|
+
if self.merge_body:
|
|
238
|
+
case.body = merge_body(case.body, evaluated)
|
|
239
|
+
else:
|
|
240
|
+
case.body = evaluated
|
|
219
241
|
|
|
220
242
|
def get_target_operation(self) -> APIOperation:
|
|
221
243
|
if "operationId" in self.definition:
|
|
@@ -223,6 +245,12 @@ class OpenAPILink(Direction):
|
|
|
223
245
|
return self.operation.schema.get_operation_by_reference(self.definition["operationRef"]) # type: ignore
|
|
224
246
|
|
|
225
247
|
|
|
248
|
+
def merge_body(old: Any, new: Any) -> Any:
|
|
249
|
+
if isinstance(old, dict) and isinstance(new, dict):
|
|
250
|
+
return {**old, **new}
|
|
251
|
+
return new
|
|
252
|
+
|
|
253
|
+
|
|
226
254
|
def get_container(case: Case, location: str | None, name: str) -> dict[str, Any] | None:
|
|
227
255
|
"""Get a container that suppose to store the given parameter."""
|
|
228
256
|
if location:
|
|
@@ -1,37 +1,38 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import io
|
|
3
4
|
import json
|
|
4
5
|
import pathlib
|
|
5
6
|
import re
|
|
6
|
-
from typing import IO, Any, Callable, cast
|
|
7
|
+
from typing import IO, TYPE_CHECKING, Any, Callable, cast
|
|
7
8
|
from urllib.parse import urljoin
|
|
8
9
|
|
|
9
10
|
from ... import experimental, fixups
|
|
10
11
|
from ...code_samples import CodeSampleStyle
|
|
12
|
+
from ...constants import NOT_SET, WAIT_FOR_SCHEMA_INTERVAL
|
|
13
|
+
from ...exceptions import SchemaError, SchemaErrorType
|
|
11
14
|
from ...generation import (
|
|
12
15
|
DEFAULT_DATA_GENERATION_METHODS,
|
|
13
|
-
DataGenerationMethodInput,
|
|
14
16
|
DataGenerationMethod,
|
|
17
|
+
DataGenerationMethodInput,
|
|
15
18
|
GenerationConfig,
|
|
16
19
|
)
|
|
17
|
-
from ...constants import WAIT_FOR_SCHEMA_INTERVAL
|
|
18
|
-
from ...exceptions import SchemaError, SchemaErrorType
|
|
19
20
|
from ...hooks import HookContext, dispatch
|
|
21
|
+
from ...internal.validation import require_relative_url
|
|
20
22
|
from ...loaders import load_schema_from_url, load_yaml
|
|
21
23
|
from ...throttling import build_limiter
|
|
22
|
-
from ...types import Filter, NotSet, PathLike
|
|
23
24
|
from ...transports.content_types import is_json_media_type, is_yaml_media_type
|
|
24
25
|
from ...transports.headers import setup_default_headers
|
|
25
|
-
from ...
|
|
26
|
-
from ...constants import NOT_SET
|
|
26
|
+
from ...types import Filter, NotSet, PathLike
|
|
27
27
|
from . import definitions, validation
|
|
28
28
|
|
|
29
29
|
if TYPE_CHECKING:
|
|
30
|
-
from .schemas import BaseOpenAPISchema
|
|
31
|
-
from ...transports.responses import GenericResponse
|
|
32
30
|
import jsonschema
|
|
33
31
|
from pyrate_limiter import Limiter
|
|
32
|
+
|
|
34
33
|
from ...lazy import LazySchema
|
|
34
|
+
from ...transports.responses import GenericResponse
|
|
35
|
+
from .schemas import BaseOpenAPISchema
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
def _is_json_response(response: GenericResponse) -> bool:
|
|
@@ -303,8 +304,8 @@ def from_dict(
|
|
|
303
304
|
|
|
304
305
|
:param dict raw_schema: A schema to load.
|
|
305
306
|
"""
|
|
306
|
-
from .schemas import OpenApi30, SwaggerV20
|
|
307
307
|
from ... import transports
|
|
308
|
+
from .schemas import OpenApi30, SwaggerV20
|
|
308
309
|
|
|
309
310
|
if not isinstance(raw_schema, dict):
|
|
310
311
|
raise SchemaError(SchemaErrorType.OPEN_API_INVALID_SCHEMA, SCHEMA_INVALID_ERROR)
|
|
@@ -533,9 +534,10 @@ def from_wsgi(
|
|
|
533
534
|
:param str schema_path: An in-app relative URL to the schema.
|
|
534
535
|
:param app: A WSGI app instance.
|
|
535
536
|
"""
|
|
536
|
-
from ...transports.responses import WSGIResponse
|
|
537
537
|
from werkzeug.test import Client
|
|
538
538
|
|
|
539
|
+
from ...transports.responses import WSGIResponse
|
|
540
|
+
|
|
539
541
|
require_relative_url(schema_path)
|
|
540
542
|
setup_default_headers(kwargs)
|
|
541
543
|
client = Client(app, WSGIResponse)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from dataclasses import dataclass
|
|
3
4
|
from functools import lru_cache
|
|
4
5
|
from typing import Any
|
|
@@ -8,10 +9,10 @@ import jsonschema
|
|
|
8
9
|
from hypothesis import strategies as st
|
|
9
10
|
from hypothesis_jsonschema import from_schema
|
|
10
11
|
|
|
12
|
+
from ....generation import GenerationConfig
|
|
11
13
|
from ..constants import ALL_KEYWORDS
|
|
12
14
|
from .mutations import MutationContext
|
|
13
15
|
from .types import Draw, Schema
|
|
14
|
-
from ....generation import GenerationConfig
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
@dataclass
|
|
@@ -40,7 +40,6 @@ from ...exceptions import (
|
|
|
40
40
|
OperationSchemaError,
|
|
41
41
|
SchemaError,
|
|
42
42
|
SchemaErrorType,
|
|
43
|
-
UsageError,
|
|
44
43
|
get_missing_content_type_error,
|
|
45
44
|
get_response_parsing_error,
|
|
46
45
|
get_schema_validation_error,
|
|
@@ -286,27 +285,28 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
286
285
|
continue
|
|
287
286
|
dispatch_hook("before_process_path", context, path, path_item)
|
|
288
287
|
scope, path_item = resolve_path_item(path_item)
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
try:
|
|
294
|
-
resolved = resolve_operation(entry)
|
|
295
|
-
if should_skip(method, resolved):
|
|
296
|
-
continue
|
|
297
|
-
parameters = resolved.get("parameters", ())
|
|
298
|
-
parameters = collect_parameters(itertools.chain(parameters, shared_parameters), resolved)
|
|
299
|
-
operation = make_operation(path, method, parameters, entry, resolved, scope)
|
|
300
|
-
context = HookContext(operation=operation)
|
|
301
|
-
if (
|
|
302
|
-
should_skip_operation(GLOBAL_HOOK_DISPATCHER, context)
|
|
303
|
-
or should_skip_operation(hooks, context)
|
|
304
|
-
or (hooks and should_skip_operation(hooks, context))
|
|
305
|
-
):
|
|
288
|
+
with in_scope(self.resolver, scope):
|
|
289
|
+
shared_parameters = resolve_shared_parameters(path_item)
|
|
290
|
+
for method, entry in path_item.items():
|
|
291
|
+
if method not in HTTP_METHODS:
|
|
306
292
|
continue
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
293
|
+
try:
|
|
294
|
+
resolved = resolve_operation(entry)
|
|
295
|
+
if should_skip(method, resolved):
|
|
296
|
+
continue
|
|
297
|
+
parameters = resolved.get("parameters", ())
|
|
298
|
+
parameters = collect_parameters(itertools.chain(parameters, shared_parameters), resolved)
|
|
299
|
+
operation = make_operation(path, method, parameters, entry, resolved, scope)
|
|
300
|
+
context = HookContext(operation=operation)
|
|
301
|
+
if (
|
|
302
|
+
should_skip_operation(GLOBAL_HOOK_DISPATCHER, context)
|
|
303
|
+
or should_skip_operation(hooks, context)
|
|
304
|
+
or (hooks and should_skip_operation(hooks, context))
|
|
305
|
+
):
|
|
306
|
+
continue
|
|
307
|
+
yield Ok(operation)
|
|
308
|
+
except SCHEMA_PARSING_ERRORS as exc:
|
|
309
|
+
yield self._into_err(exc, path, method)
|
|
310
310
|
except SCHEMA_PARSING_ERRORS as exc:
|
|
311
311
|
yield self._into_err(exc, path, method)
|
|
312
312
|
|
|
@@ -626,7 +626,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
626
626
|
formatted_content_types = [f"\n- `{content_type}`" for content_type in media_types]
|
|
627
627
|
message = f"The following media types are documented in the schema:{''.join(formatted_content_types)}"
|
|
628
628
|
try:
|
|
629
|
-
raise get_missing_content_type_error()(
|
|
629
|
+
raise get_missing_content_type_error(operation.verbose_name)(
|
|
630
630
|
failures.MissingContentType.title,
|
|
631
631
|
context=failures.MissingContentType(message=message, media_types=media_types),
|
|
632
632
|
)
|
|
@@ -638,7 +638,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
638
638
|
try:
|
|
639
639
|
data = get_json(response)
|
|
640
640
|
except JSONDecodeError as exc:
|
|
641
|
-
exc_class = get_response_parsing_error(exc)
|
|
641
|
+
exc_class = get_response_parsing_error(operation.verbose_name, exc)
|
|
642
642
|
context = failures.JSONDecodeErrorContext.from_exception(exc)
|
|
643
643
|
try:
|
|
644
644
|
raise exc_class(context.title, context=context) from exc
|
|
@@ -656,7 +656,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
656
656
|
try:
|
|
657
657
|
jsonschema.validate(data, schema, cls=cls, resolver=resolver)
|
|
658
658
|
except jsonschema.ValidationError as exc:
|
|
659
|
-
exc_class = get_schema_validation_error(exc)
|
|
659
|
+
exc_class = get_schema_validation_error(operation.verbose_name, exc)
|
|
660
660
|
ctx = failures.ValidationErrorContext.from_exception(exc)
|
|
661
661
|
try:
|
|
662
662
|
raise exc_class(ctx.title, context=ctx) from exc
|
|
@@ -926,7 +926,7 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
926
926
|
|
|
927
927
|
def get_strategies_from_examples(self, operation: APIOperation) -> list[SearchStrategy[Case]]:
|
|
928
928
|
"""Get examples from the API operation."""
|
|
929
|
-
return get_strategies_from_examples(operation
|
|
929
|
+
return get_strategies_from_examples(operation)
|
|
930
930
|
|
|
931
931
|
def get_response_schema(self, definition: dict[str, Any], scope: str) -> tuple[list[str], dict[str, Any] | None]:
|
|
932
932
|
scopes, definition = self.resolver.resolve_in_scope(definition, scope)
|
|
@@ -998,18 +998,7 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
998
998
|
media_type: str | None = None,
|
|
999
999
|
) -> C:
|
|
1000
1000
|
if body is not NOT_SET and media_type is None:
|
|
1001
|
-
|
|
1002
|
-
media_types = operation.get_request_payload_content_types()
|
|
1003
|
-
if len(media_types) == 1:
|
|
1004
|
-
# The only available option
|
|
1005
|
-
media_type = media_types[0]
|
|
1006
|
-
else:
|
|
1007
|
-
media_types_repr = ", ".join(media_types)
|
|
1008
|
-
raise UsageError(
|
|
1009
|
-
"Can not detect appropriate media type. "
|
|
1010
|
-
"You can either specify one of the defined media types "
|
|
1011
|
-
f"or pass any other media type available for serialization. Defined media types: {media_types_repr}"
|
|
1012
|
-
)
|
|
1001
|
+
media_type = operation._get_default_media_type()
|
|
1013
1002
|
return case_cls(
|
|
1014
1003
|
operation=operation,
|
|
1015
1004
|
path_parameters=path_parameters,
|
|
@@ -1101,7 +1090,7 @@ class OpenApi30(SwaggerV20):
|
|
|
1101
1090
|
|
|
1102
1091
|
def get_strategies_from_examples(self, operation: APIOperation) -> list[SearchStrategy[Case]]:
|
|
1103
1092
|
"""Get examples from the API operation."""
|
|
1104
|
-
return get_strategies_from_examples(operation
|
|
1093
|
+
return get_strategies_from_examples(operation)
|
|
1105
1094
|
|
|
1106
1095
|
def get_content_types(self, operation: APIOperation, response: GenericResponse) -> list[str]:
|
|
1107
1096
|
definitions = self._get_response_definitions(operation, response)
|
|
@@ -1,28 +1,46 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from collections import defaultdict
|
|
3
|
-
from
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterator
|
|
4
6
|
|
|
5
7
|
from hypothesis import strategies as st
|
|
6
|
-
from hypothesis.stateful import Bundle, Rule,
|
|
7
|
-
from requests.structures import CaseInsensitiveDict
|
|
8
|
+
from hypothesis.stateful import Bundle, Rule, rule
|
|
8
9
|
|
|
10
|
+
from ....constants import NOT_SET
|
|
9
11
|
from ....internal.result import Ok
|
|
10
12
|
from ....stateful.state_machine import APIStateMachine, Direction, StepResult
|
|
11
|
-
from ....
|
|
13
|
+
from ....types import NotSet
|
|
12
14
|
from .. import expressions
|
|
13
|
-
from
|
|
15
|
+
from ..links import get_all_links
|
|
16
|
+
from ..utils import expand_status_code
|
|
17
|
+
from .statistic import OpenAPILinkStats
|
|
18
|
+
from .types import FilterFunction, LinkName, StatusCode, TargetName
|
|
14
19
|
|
|
15
20
|
if TYPE_CHECKING:
|
|
16
|
-
from ....models import
|
|
21
|
+
from ....models import Case
|
|
17
22
|
from ..schemas import BaseOpenAPISchema
|
|
18
23
|
|
|
19
24
|
|
|
20
25
|
class OpenAPIStateMachine(APIStateMachine):
|
|
26
|
+
_transition_stats_template: ClassVar[OpenAPILinkStats]
|
|
27
|
+
_response_matchers: dict[str, Callable[[StepResult], str | None]]
|
|
28
|
+
|
|
29
|
+
def _get_target_for_result(self, result: StepResult) -> str | None:
|
|
30
|
+
matcher = self._response_matchers.get(result.case.operation.verbose_name)
|
|
31
|
+
if matcher is None:
|
|
32
|
+
return None
|
|
33
|
+
return matcher(result)
|
|
34
|
+
|
|
21
35
|
def transform(self, result: StepResult, direction: Direction, case: Case) -> Case:
|
|
22
36
|
context = expressions.ExpressionContext(case=result.case, response=result.response)
|
|
23
37
|
direction.set_data(case, elapsed=result.elapsed, context=context)
|
|
24
38
|
return case
|
|
25
39
|
|
|
40
|
+
@classmethod
|
|
41
|
+
def format_rules(cls) -> str:
|
|
42
|
+
return "\n".join(item.line for item in cls._transition_stats_template.iter_with_format())
|
|
43
|
+
|
|
26
44
|
|
|
27
45
|
def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
|
|
28
46
|
"""Create a state machine class.
|
|
@@ -33,75 +51,146 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
|
|
|
33
51
|
|
|
34
52
|
This state machine won't make calls to (2) without having a proper response from (1) first.
|
|
35
53
|
"""
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
connections: APIOperationConnections = defaultdict(list)
|
|
54
|
+
from ....stateful.state_machine import _normalize_name
|
|
55
|
+
|
|
39
56
|
operations = [result.ok() for result in schema.get_all_operations() if isinstance(result, Ok)]
|
|
57
|
+
bundles = {}
|
|
58
|
+
incoming_transitions = defaultdict(list)
|
|
59
|
+
_response_matchers: dict[str, Callable[[StepResult], str | None]] = {}
|
|
60
|
+
# Statistic structure follows the links and count for each response status code
|
|
61
|
+
transitions = {}
|
|
40
62
|
for operation in operations:
|
|
41
|
-
|
|
63
|
+
operation_links: dict[StatusCode, dict[TargetName, dict[LinkName, dict[int | None, int]]]] = {}
|
|
64
|
+
all_status_codes = tuple(operation.definition.raw["responses"])
|
|
65
|
+
bundle_matchers = []
|
|
66
|
+
for _, link in get_all_links(operation):
|
|
67
|
+
bundle_name = f"{operation.verbose_name} -> {link.status_code}"
|
|
68
|
+
bundles[bundle_name] = Bundle(bundle_name)
|
|
69
|
+
target_operation = link.get_target_operation()
|
|
70
|
+
incoming_transitions[target_operation.verbose_name].append(link)
|
|
71
|
+
response_targets = operation_links.setdefault(link.status_code, {})
|
|
72
|
+
target_links = response_targets.setdefault(target_operation.verbose_name, {})
|
|
73
|
+
target_links[link.name] = {}
|
|
74
|
+
bundle_matchers.append((bundle_name, make_response_filter(link.status_code, all_status_codes)))
|
|
75
|
+
if operation_links:
|
|
76
|
+
transitions[operation.verbose_name] = operation_links
|
|
77
|
+
if bundle_matchers:
|
|
78
|
+
_response_matchers[operation.verbose_name] = make_response_matcher(bundle_matchers)
|
|
79
|
+
rules = {}
|
|
80
|
+
catch_all = Bundle("catch_all")
|
|
81
|
+
|
|
82
|
+
for target in operations:
|
|
83
|
+
incoming = incoming_transitions.get(target.verbose_name)
|
|
84
|
+
if incoming is not None:
|
|
85
|
+
for link in incoming:
|
|
86
|
+
source = link.operation
|
|
87
|
+
bundle_name = f"{source.verbose_name} -> {link.status_code}"
|
|
88
|
+
name = _normalize_name(f"{target.verbose_name} -> {link.status_code}")
|
|
89
|
+
rules[name] = transition(
|
|
90
|
+
name=name,
|
|
91
|
+
target=catch_all,
|
|
92
|
+
previous=bundles[bundle_name],
|
|
93
|
+
case=target.as_strategy(),
|
|
94
|
+
link=st.just(link),
|
|
95
|
+
)
|
|
96
|
+
elif any(
|
|
97
|
+
incoming.operation.verbose_name == target.verbose_name
|
|
98
|
+
for transitions in incoming_transitions.values()
|
|
99
|
+
for incoming in transitions
|
|
100
|
+
):
|
|
101
|
+
# No incoming transitions, but has at least one outgoing transition
|
|
102
|
+
# For example, POST /users/ -> GET /users/{id}/
|
|
103
|
+
# The source operation has no prerequisite, but we need to allow this rule to be executed
|
|
104
|
+
# in order to reach other transitions
|
|
105
|
+
name = _normalize_name(f"{target.verbose_name} -> X")
|
|
106
|
+
rules[name] = transition(
|
|
107
|
+
name=name,
|
|
108
|
+
target=catch_all,
|
|
109
|
+
previous=st.none(),
|
|
110
|
+
case=target.as_strategy(),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return type(
|
|
114
|
+
"APIWorkflow",
|
|
115
|
+
(OpenAPIStateMachine,),
|
|
116
|
+
{
|
|
117
|
+
"schema": schema,
|
|
118
|
+
"bundles": bundles,
|
|
119
|
+
"_transition_stats_template": OpenAPILinkStats(transitions=transitions),
|
|
120
|
+
"_response_matchers": _response_matchers,
|
|
121
|
+
**rules,
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def transition(
|
|
127
|
+
*,
|
|
128
|
+
name: str,
|
|
129
|
+
target: Bundle,
|
|
130
|
+
previous: Bundle | st.SearchStrategy,
|
|
131
|
+
case: st.SearchStrategy,
|
|
132
|
+
link: st.SearchStrategy | NotSet = NOT_SET,
|
|
133
|
+
) -> Callable[[Callable], Rule]:
|
|
134
|
+
def step_function(*args_: Any, **kwargs_: Any) -> StepResult:
|
|
135
|
+
return APIStateMachine._step(*args_, **kwargs_)
|
|
136
|
+
|
|
137
|
+
step_function.__name__ = name
|
|
138
|
+
|
|
139
|
+
kwargs = {"target": target, "previous": previous, "case": case}
|
|
140
|
+
if not isinstance(link, NotSet):
|
|
141
|
+
kwargs["link"] = link
|
|
142
|
+
|
|
143
|
+
return rule(**kwargs)(step_function)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def make_response_matcher(matchers: list[tuple[str, FilterFunction]]) -> Callable[[StepResult], str | None]:
|
|
147
|
+
def compare(result: StepResult) -> str | None:
|
|
148
|
+
for bundle_name, response_filter in matchers:
|
|
149
|
+
if response_filter(result):
|
|
150
|
+
return bundle_name
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
return compare
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@lru_cache()
|
|
157
|
+
def make_response_filter(status_code: str, all_status_codes: Iterator[str]) -> FilterFunction:
|
|
158
|
+
"""Create a filter for stored responses.
|
|
159
|
+
|
|
160
|
+
This filter will decide whether some response is suitable to use as a source for requesting some API operation.
|
|
161
|
+
"""
|
|
162
|
+
if status_code == "default":
|
|
163
|
+
return default_status_code(all_status_codes)
|
|
164
|
+
return match_status_code(status_code)
|
|
165
|
+
|
|
42
166
|
|
|
43
|
-
|
|
167
|
+
def match_status_code(status_code: str) -> FilterFunction:
|
|
168
|
+
"""Create a filter function that matches all responses with the given status code.
|
|
44
169
|
|
|
45
|
-
|
|
46
|
-
|
|
170
|
+
Note that the status code can contain "X", which means any digit.
|
|
171
|
+
For example, 50X will match all status codes from 500 to 509.
|
|
172
|
+
"""
|
|
173
|
+
status_codes = set(expand_status_code(status_code))
|
|
174
|
+
|
|
175
|
+
def compare(result: StepResult) -> bool:
|
|
176
|
+
return result.response.status_code in status_codes
|
|
177
|
+
|
|
178
|
+
compare.__name__ = f"match_{status_code}_response"
|
|
179
|
+
|
|
180
|
+
return compare
|
|
47
181
|
|
|
48
182
|
|
|
49
|
-
def
|
|
50
|
-
"""Create
|
|
183
|
+
def default_status_code(status_codes: Iterator[str]) -> FilterFunction:
|
|
184
|
+
"""Create a filter that matches all "default" responses.
|
|
51
185
|
|
|
52
|
-
|
|
53
|
-
|
|
186
|
+
In Open API, the "default" response is the one that is used if no other options were matched.
|
|
187
|
+
Therefore, we need to match only responses that were not matched by other listed status codes.
|
|
54
188
|
"""
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def make_all_rules(
|
|
65
|
-
operations: list[APIOperation],
|
|
66
|
-
bundles: dict[str, CaseInsensitiveDict],
|
|
67
|
-
connections: APIOperationConnections,
|
|
68
|
-
) -> dict[str, Rule]:
|
|
69
|
-
"""Create rules for all API operations, based on the provided connections."""
|
|
70
|
-
rules = {}
|
|
71
|
-
for operation in operations:
|
|
72
|
-
new_rule = make_rule(operation, bundles[operation.path][operation.method.upper()], connections)
|
|
73
|
-
if new_rule is not None:
|
|
74
|
-
rules[f"rule {operation.verbose_name}"] = new_rule
|
|
75
|
-
return rules
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def make_rule(
|
|
79
|
-
operation: APIOperation,
|
|
80
|
-
bundle: Bundle,
|
|
81
|
-
connections: APIOperationConnections,
|
|
82
|
-
) -> Rule | None:
|
|
83
|
-
"""Create a rule for an API operation."""
|
|
84
|
-
|
|
85
|
-
def _make_rule(previous: st.SearchStrategy) -> Rule:
|
|
86
|
-
decorator = rule(target=bundle, previous=previous, case=operation.as_strategy()) # type: ignore
|
|
87
|
-
return decorator(APIStateMachine._step)
|
|
88
|
-
|
|
89
|
-
incoming = connections.get(operation.verbose_name)
|
|
90
|
-
if incoming is not None:
|
|
91
|
-
incoming_connections = cast(List[Connection], incoming)
|
|
92
|
-
strategies = [connection.strategy for connection in incoming_connections]
|
|
93
|
-
_rule = _make_rule(combine_strategies(strategies))
|
|
94
|
-
|
|
95
|
-
def has_source_response(self: OpenAPIStateMachine) -> bool:
|
|
96
|
-
# To trigger this transition, there should be matching responses from the source operations
|
|
97
|
-
return any(connection.source in self.bundles for connection in incoming_connections)
|
|
98
|
-
|
|
99
|
-
return precondition(has_source_response)(_rule)
|
|
100
|
-
# No incoming transitions - make rules only for operations that have at least one outgoing transition
|
|
101
|
-
if any(
|
|
102
|
-
connection.source == operation.verbose_name
|
|
103
|
-
for operation_connections in connections.values()
|
|
104
|
-
for connection in operation_connections
|
|
105
|
-
):
|
|
106
|
-
return _make_rule(st.none())
|
|
107
|
-
return None
|
|
189
|
+
expanded_status_codes = {
|
|
190
|
+
status_code for value in status_codes if value != "default" for status_code in expand_status_code(value)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
def match_default_response(result: StepResult) -> bool:
|
|
194
|
+
return result.response.status_code not in expanded_status_codes
|
|
195
|
+
|
|
196
|
+
return match_default_response
|