schemathesis 3.15.4__py3-none-any.whl → 4.4.2__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 +53 -25
- schemathesis/auths.py +507 -0
- schemathesis/checks.py +190 -25
- schemathesis/cli/__init__.py +27 -1219
- schemathesis/cli/__main__.py +4 -0
- schemathesis/cli/commands/__init__.py +133 -0
- schemathesis/cli/commands/data.py +10 -0
- schemathesis/cli/commands/run/__init__.py +602 -0
- schemathesis/cli/commands/run/context.py +228 -0
- schemathesis/cli/commands/run/events.py +60 -0
- schemathesis/cli/commands/run/executor.py +157 -0
- schemathesis/cli/commands/run/filters.py +53 -0
- schemathesis/cli/commands/run/handlers/__init__.py +46 -0
- schemathesis/cli/commands/run/handlers/base.py +45 -0
- schemathesis/cli/commands/run/handlers/cassettes.py +464 -0
- schemathesis/cli/commands/run/handlers/junitxml.py +60 -0
- schemathesis/cli/commands/run/handlers/output.py +1750 -0
- schemathesis/cli/commands/run/loaders.py +118 -0
- schemathesis/cli/commands/run/validation.py +256 -0
- schemathesis/cli/constants.py +5 -0
- schemathesis/cli/core.py +19 -0
- schemathesis/cli/ext/fs.py +16 -0
- schemathesis/cli/ext/groups.py +203 -0
- schemathesis/cli/ext/options.py +81 -0
- schemathesis/config/__init__.py +202 -0
- schemathesis/config/_auth.py +51 -0
- schemathesis/config/_checks.py +268 -0
- schemathesis/config/_diff_base.py +101 -0
- schemathesis/config/_env.py +21 -0
- schemathesis/config/_error.py +163 -0
- schemathesis/config/_generation.py +157 -0
- schemathesis/config/_health_check.py +24 -0
- schemathesis/config/_operations.py +335 -0
- schemathesis/config/_output.py +171 -0
- schemathesis/config/_parameters.py +19 -0
- schemathesis/config/_phases.py +253 -0
- schemathesis/config/_projects.py +543 -0
- schemathesis/config/_rate_limit.py +17 -0
- schemathesis/config/_report.py +120 -0
- schemathesis/config/_validator.py +9 -0
- schemathesis/config/_warnings.py +89 -0
- schemathesis/config/schema.json +975 -0
- schemathesis/core/__init__.py +72 -0
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/compat.py +32 -0
- schemathesis/core/control.py +2 -0
- schemathesis/core/curl.py +100 -0
- schemathesis/core/deserialization.py +210 -0
- schemathesis/core/errors.py +588 -0
- schemathesis/core/failures.py +316 -0
- schemathesis/core/fs.py +19 -0
- schemathesis/core/hooks.py +20 -0
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +183 -0
- schemathesis/core/jsonschema/keywords.py +40 -0
- schemathesis/core/jsonschema/references.py +222 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/lazy_import.py +15 -0
- schemathesis/core/loaders.py +107 -0
- schemathesis/core/marks.py +66 -0
- schemathesis/core/media_types.py +79 -0
- schemathesis/core/output/__init__.py +46 -0
- schemathesis/core/output/sanitization.py +54 -0
- schemathesis/core/parameters.py +45 -0
- schemathesis/core/rate_limit.py +60 -0
- schemathesis/core/registries.py +34 -0
- schemathesis/core/result.py +27 -0
- schemathesis/core/schema_analysis.py +17 -0
- schemathesis/core/shell.py +203 -0
- schemathesis/core/transforms.py +144 -0
- schemathesis/core/transport.py +223 -0
- schemathesis/core/validation.py +73 -0
- schemathesis/core/version.py +7 -0
- schemathesis/engine/__init__.py +28 -0
- schemathesis/engine/context.py +152 -0
- schemathesis/engine/control.py +44 -0
- schemathesis/engine/core.py +201 -0
- schemathesis/engine/errors.py +446 -0
- schemathesis/engine/events.py +284 -0
- schemathesis/engine/observations.py +42 -0
- schemathesis/engine/phases/__init__.py +108 -0
- schemathesis/engine/phases/analysis.py +28 -0
- schemathesis/engine/phases/probes.py +172 -0
- schemathesis/engine/phases/stateful/__init__.py +68 -0
- schemathesis/engine/phases/stateful/_executor.py +364 -0
- schemathesis/engine/phases/stateful/context.py +85 -0
- schemathesis/engine/phases/unit/__init__.py +220 -0
- schemathesis/engine/phases/unit/_executor.py +459 -0
- schemathesis/engine/phases/unit/_pool.py +82 -0
- schemathesis/engine/recorder.py +254 -0
- schemathesis/errors.py +47 -0
- schemathesis/filters.py +395 -0
- schemathesis/generation/__init__.py +25 -0
- schemathesis/generation/case.py +478 -0
- schemathesis/generation/coverage.py +1528 -0
- schemathesis/generation/hypothesis/__init__.py +121 -0
- schemathesis/generation/hypothesis/builder.py +992 -0
- schemathesis/generation/hypothesis/examples.py +56 -0
- schemathesis/generation/hypothesis/given.py +66 -0
- schemathesis/generation/hypothesis/reporting.py +285 -0
- schemathesis/generation/meta.py +227 -0
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +20 -0
- schemathesis/generation/overrides.py +127 -0
- schemathesis/generation/stateful/__init__.py +37 -0
- schemathesis/generation/stateful/state_machine.py +294 -0
- schemathesis/graphql/__init__.py +15 -0
- schemathesis/graphql/checks.py +109 -0
- schemathesis/graphql/loaders.py +285 -0
- schemathesis/hooks.py +270 -91
- schemathesis/openapi/__init__.py +13 -0
- schemathesis/openapi/checks.py +467 -0
- schemathesis/openapi/generation/__init__.py +0 -0
- schemathesis/openapi/generation/filters.py +72 -0
- schemathesis/openapi/loaders.py +315 -0
- schemathesis/pytest/__init__.py +5 -0
- schemathesis/pytest/control_flow.py +7 -0
- schemathesis/pytest/lazy.py +341 -0
- schemathesis/pytest/loaders.py +36 -0
- schemathesis/pytest/plugin.py +357 -0
- schemathesis/python/__init__.py +0 -0
- schemathesis/python/asgi.py +12 -0
- schemathesis/python/wsgi.py +12 -0
- schemathesis/schemas.py +682 -257
- schemathesis/specs/graphql/__init__.py +0 -1
- schemathesis/specs/graphql/nodes.py +26 -2
- schemathesis/specs/graphql/scalars.py +77 -12
- schemathesis/specs/graphql/schemas.py +367 -148
- schemathesis/specs/graphql/validation.py +33 -0
- schemathesis/specs/openapi/__init__.py +9 -1
- schemathesis/specs/openapi/_hypothesis.py +555 -318
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +729 -0
- schemathesis/specs/openapi/adapter/protocol.py +59 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +368 -0
- schemathesis/specs/openapi/adapter/security.py +144 -0
- schemathesis/specs/openapi/adapter/v2.py +30 -0
- schemathesis/specs/openapi/adapter/v3_0.py +30 -0
- schemathesis/specs/openapi/adapter/v3_1.py +30 -0
- schemathesis/specs/openapi/analysis.py +96 -0
- schemathesis/specs/openapi/checks.py +748 -82
- schemathesis/specs/openapi/converter.py +176 -37
- schemathesis/specs/openapi/definitions.py +599 -4
- schemathesis/specs/openapi/examples.py +581 -165
- schemathesis/specs/openapi/expressions/__init__.py +52 -5
- schemathesis/specs/openapi/expressions/extractors.py +25 -0
- schemathesis/specs/openapi/expressions/lexer.py +34 -31
- schemathesis/specs/openapi/expressions/nodes.py +97 -46
- schemathesis/specs/openapi/expressions/parser.py +35 -13
- schemathesis/specs/openapi/formats.py +122 -0
- schemathesis/specs/openapi/media_types.py +75 -0
- schemathesis/specs/openapi/negative/__init__.py +93 -73
- schemathesis/specs/openapi/negative/mutations.py +294 -103
- schemathesis/specs/openapi/negative/utils.py +0 -9
- schemathesis/specs/openapi/patterns.py +458 -0
- schemathesis/specs/openapi/references.py +60 -81
- schemathesis/specs/openapi/schemas.py +647 -666
- schemathesis/specs/openapi/serialization.py +53 -30
- schemathesis/specs/openapi/stateful/__init__.py +403 -68
- schemathesis/specs/openapi/stateful/control.py +87 -0
- schemathesis/specs/openapi/stateful/dependencies/__init__.py +232 -0
- schemathesis/specs/openapi/stateful/dependencies/inputs.py +428 -0
- schemathesis/specs/openapi/stateful/dependencies/models.py +341 -0
- schemathesis/specs/openapi/stateful/dependencies/naming.py +491 -0
- schemathesis/specs/openapi/stateful/dependencies/outputs.py +34 -0
- schemathesis/specs/openapi/stateful/dependencies/resources.py +339 -0
- schemathesis/specs/openapi/stateful/dependencies/schemas.py +447 -0
- schemathesis/specs/openapi/stateful/inference.py +254 -0
- schemathesis/specs/openapi/stateful/links.py +219 -78
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/common.py +23 -0
- schemathesis/specs/openapi/types/v2.py +129 -0
- schemathesis/specs/openapi/types/v3.py +134 -0
- schemathesis/specs/openapi/utils.py +7 -6
- schemathesis/specs/openapi/warnings.py +75 -0
- schemathesis/transport/__init__.py +224 -0
- schemathesis/transport/asgi.py +26 -0
- schemathesis/transport/prepare.py +126 -0
- schemathesis/transport/requests.py +278 -0
- schemathesis/transport/serialization.py +329 -0
- schemathesis/transport/wsgi.py +175 -0
- schemathesis-4.4.2.dist-info/METADATA +213 -0
- schemathesis-4.4.2.dist-info/RECORD +192 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info}/WHEEL +1 -1
- schemathesis-4.4.2.dist-info/entry_points.txt +6 -0
- {schemathesis-3.15.4.dist-info → schemathesis-4.4.2.dist-info/licenses}/LICENSE +1 -1
- schemathesis/_compat.py +0 -57
- schemathesis/_hypothesis.py +0 -123
- schemathesis/auth.py +0 -214
- schemathesis/cli/callbacks.py +0 -240
- schemathesis/cli/cassettes.py +0 -351
- schemathesis/cli/context.py +0 -38
- schemathesis/cli/debug.py +0 -21
- schemathesis/cli/handlers.py +0 -11
- schemathesis/cli/junitxml.py +0 -41
- schemathesis/cli/options.py +0 -70
- schemathesis/cli/output/__init__.py +0 -1
- schemathesis/cli/output/default.py +0 -521
- schemathesis/cli/output/short.py +0 -40
- schemathesis/constants.py +0 -88
- schemathesis/exceptions.py +0 -257
- schemathesis/extra/_aiohttp.py +0 -27
- schemathesis/extra/_flask.py +0 -10
- schemathesis/extra/_server.py +0 -16
- schemathesis/extra/pytest_plugin.py +0 -251
- schemathesis/failures.py +0 -145
- schemathesis/fixups/__init__.py +0 -29
- schemathesis/fixups/fast_api.py +0 -30
- schemathesis/graphql.py +0 -5
- schemathesis/internal.py +0 -6
- schemathesis/lazy.py +0 -301
- schemathesis/models.py +0 -1113
- schemathesis/parameters.py +0 -91
- schemathesis/runner/__init__.py +0 -470
- schemathesis/runner/events.py +0 -242
- schemathesis/runner/impl/__init__.py +0 -3
- schemathesis/runner/impl/core.py +0 -791
- schemathesis/runner/impl/solo.py +0 -85
- schemathesis/runner/impl/threadpool.py +0 -367
- schemathesis/runner/serialization.py +0 -206
- schemathesis/serializers.py +0 -253
- schemathesis/service/__init__.py +0 -18
- schemathesis/service/auth.py +0 -10
- schemathesis/service/client.py +0 -62
- schemathesis/service/constants.py +0 -25
- schemathesis/service/events.py +0 -39
- schemathesis/service/handler.py +0 -46
- schemathesis/service/hosts.py +0 -74
- schemathesis/service/metadata.py +0 -42
- schemathesis/service/models.py +0 -21
- schemathesis/service/serialization.py +0 -184
- schemathesis/service/worker.py +0 -39
- schemathesis/specs/graphql/loaders.py +0 -215
- schemathesis/specs/openapi/constants.py +0 -7
- schemathesis/specs/openapi/expressions/context.py +0 -12
- schemathesis/specs/openapi/expressions/pointers.py +0 -29
- schemathesis/specs/openapi/filters.py +0 -44
- schemathesis/specs/openapi/links.py +0 -303
- schemathesis/specs/openapi/loaders.py +0 -453
- schemathesis/specs/openapi/parameters.py +0 -430
- schemathesis/specs/openapi/security.py +0 -129
- schemathesis/specs/openapi/validation.py +0 -24
- schemathesis/stateful.py +0 -358
- schemathesis/targets.py +0 -32
- schemathesis/types.py +0 -38
- schemathesis/utils.py +0 -475
- schemathesis-3.15.4.dist-info/METADATA +0 -202
- schemathesis-3.15.4.dist-info/RECORD +0 -99
- schemathesis-3.15.4.dist-info/entry_points.txt +0 -7
- /schemathesis/{extra → cli/ext}/__init__.py +0 -0
|
@@ -1,154 +1,251 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
from
|
|
5
|
-
from
|
|
6
|
-
from copy import deepcopy
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import string
|
|
4
|
+
from contextlib import contextmanager, suppress
|
|
5
|
+
from dataclasses import dataclass
|
|
7
6
|
from difflib import get_close_matches
|
|
8
|
-
from
|
|
7
|
+
from functools import cached_property, lru_cache
|
|
9
8
|
from json import JSONDecodeError
|
|
10
|
-
from
|
|
11
|
-
from typing import
|
|
12
|
-
Any,
|
|
13
|
-
Callable,
|
|
14
|
-
ClassVar,
|
|
15
|
-
Dict,
|
|
16
|
-
Generator,
|
|
17
|
-
Iterable,
|
|
18
|
-
List,
|
|
19
|
-
Optional,
|
|
20
|
-
Sequence,
|
|
21
|
-
Tuple,
|
|
22
|
-
Type,
|
|
23
|
-
TypeVar,
|
|
24
|
-
Union,
|
|
25
|
-
)
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Callable, Generator, Iterator, Mapping, NoReturn, Sequence, cast
|
|
26
11
|
from urllib.parse import urlsplit
|
|
27
12
|
|
|
28
|
-
import attr
|
|
29
13
|
import jsonschema
|
|
30
|
-
import
|
|
31
|
-
from hypothesis.strategies import SearchStrategy
|
|
14
|
+
from packaging import version
|
|
32
15
|
from requests.structures import CaseInsensitiveDict
|
|
33
16
|
|
|
34
|
-
from
|
|
35
|
-
from
|
|
36
|
-
from
|
|
37
|
-
from
|
|
17
|
+
from schemathesis.core import INJECTED_PATH_PARAMETER_KEY, NOT_SET, NotSet, Specification, deserialization, media_types
|
|
18
|
+
from schemathesis.core.adapter import OperationParameter, ResponsesContainer
|
|
19
|
+
from schemathesis.core.compat import RefResolutionError
|
|
20
|
+
from schemathesis.core.errors import (
|
|
21
|
+
SCHEMA_ERROR_SUGGESTION,
|
|
22
|
+
InfiniteRecursiveReference,
|
|
38
23
|
InvalidSchema,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
get_response_parsing_error,
|
|
42
|
-
get_schema_validation_error,
|
|
24
|
+
OperationNotFound,
|
|
25
|
+
SchemaLocation,
|
|
43
26
|
)
|
|
44
|
-
from
|
|
45
|
-
from
|
|
46
|
-
from
|
|
47
|
-
from
|
|
48
|
-
from
|
|
49
|
-
from
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
traverse_schema,
|
|
27
|
+
from schemathesis.core.failures import Failure, FailureGroup, MalformedJson
|
|
28
|
+
from schemathesis.core.parameters import ParameterLocation
|
|
29
|
+
from schemathesis.core.result import Err, Ok, Result
|
|
30
|
+
from schemathesis.core.transport import Response
|
|
31
|
+
from schemathesis.generation.case import Case
|
|
32
|
+
from schemathesis.generation.meta import CaseMetadata
|
|
33
|
+
from schemathesis.openapi.checks import JsonSchemaError, MissingContentType
|
|
34
|
+
from schemathesis.specs.openapi import adapter
|
|
35
|
+
from schemathesis.specs.openapi.adapter import OpenApiResponses
|
|
36
|
+
from schemathesis.specs.openapi.adapter.parameters import (
|
|
37
|
+
COMBINED_FORM_DATA_MARKER,
|
|
38
|
+
OpenApiParameter,
|
|
39
|
+
OpenApiParameterSet,
|
|
58
40
|
)
|
|
59
|
-
from . import
|
|
60
|
-
from .
|
|
61
|
-
from .
|
|
41
|
+
from schemathesis.specs.openapi.adapter.protocol import SpecificationAdapter
|
|
42
|
+
from schemathesis.specs.openapi.adapter.security import OpenApiSecurityParameters
|
|
43
|
+
from schemathesis.specs.openapi.analysis import OpenAPIAnalysis
|
|
44
|
+
|
|
45
|
+
from ...generation import GenerationMode
|
|
46
|
+
from ...hooks import HookContext, HookDispatcher
|
|
47
|
+
from ...schemas import APIOperation, APIOperationMap, ApiStatistic, BaseSchema, OperationDefinition
|
|
48
|
+
from . import serialization
|
|
49
|
+
from ._hypothesis import openapi_cases
|
|
50
|
+
from .definitions import OPENAPI_30_VALIDATOR, OPENAPI_31_VALIDATOR, SWAGGER_20_VALIDATOR
|
|
62
51
|
from .examples import get_strategies_from_examples
|
|
63
|
-
from .
|
|
64
|
-
should_skip_by_operation_id,
|
|
65
|
-
should_skip_by_tag,
|
|
66
|
-
should_skip_deprecated,
|
|
67
|
-
should_skip_endpoint,
|
|
68
|
-
should_skip_method,
|
|
69
|
-
)
|
|
70
|
-
from .parameters import (
|
|
71
|
-
OpenAPI20Body,
|
|
72
|
-
OpenAPI20CompositeBody,
|
|
73
|
-
OpenAPI20Parameter,
|
|
74
|
-
OpenAPI30Body,
|
|
75
|
-
OpenAPI30Parameter,
|
|
76
|
-
OpenAPIParameter,
|
|
77
|
-
)
|
|
78
|
-
from .references import RECURSION_DEPTH_LIMIT, ConvertingResolver, InliningResolver
|
|
79
|
-
from .security import BaseSecurityProcessor, OpenAPISecurityProcessor, SwaggerSecurityProcessor
|
|
52
|
+
from .references import ReferenceResolver
|
|
80
53
|
from .stateful import create_state_machine
|
|
81
54
|
|
|
82
|
-
|
|
83
|
-
|
|
55
|
+
if TYPE_CHECKING:
|
|
56
|
+
from hypothesis.strategies import SearchStrategy
|
|
57
|
+
|
|
58
|
+
from schemathesis.auths import AuthStorage
|
|
59
|
+
from schemathesis.generation.stateful import APIStateMachine
|
|
60
|
+
|
|
61
|
+
HTTP_METHODS = frozenset({"get", "put", "post", "delete", "options", "head", "patch", "trace"})
|
|
62
|
+
SCHEMA_PARSING_ERRORS = (KeyError, AttributeError, RefResolutionError, InvalidSchema, InfiniteRecursiveReference)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@lru_cache()
|
|
66
|
+
def get_template_fields(template: str) -> set[str]:
|
|
67
|
+
"""Extract named placeholders from a string template."""
|
|
68
|
+
try:
|
|
69
|
+
parameters = {name for _, name, _, _ in string.Formatter().parse(template) if name is not None}
|
|
70
|
+
# Check for malformed params to avoid injecting them - they will be checked later on in the workflow
|
|
71
|
+
template.format(**dict.fromkeys(parameters, ""))
|
|
72
|
+
return parameters
|
|
73
|
+
except (ValueError, IndexError):
|
|
74
|
+
return set()
|
|
84
75
|
|
|
85
76
|
|
|
86
|
-
@
|
|
77
|
+
@dataclass(eq=False, repr=False)
|
|
87
78
|
class BaseOpenAPISchema(BaseSchema):
|
|
88
|
-
|
|
89
|
-
links_field: str
|
|
90
|
-
security: BaseSecurityProcessor
|
|
91
|
-
component_locations: ClassVar[Tuple[Tuple[str, ...], ...]] = ()
|
|
92
|
-
_operations_by_id: Dict[str, APIOperation]
|
|
93
|
-
_inline_reference_cache: Dict[str, Any]
|
|
94
|
-
# Inline references cache can be populated from multiple threads, therefore we need some synchronisation to avoid
|
|
95
|
-
# excessive resolving
|
|
96
|
-
_inline_reference_cache_lock: RLock
|
|
97
|
-
|
|
98
|
-
def __attrs_post_init__(self) -> None:
|
|
99
|
-
self._inline_reference_cache = {}
|
|
100
|
-
self._inline_reference_cache_lock = RLock()
|
|
101
|
-
|
|
102
|
-
@property # pragma: no mutate
|
|
103
|
-
def spec_version(self) -> str:
|
|
104
|
-
raise NotImplementedError
|
|
79
|
+
adapter: SpecificationAdapter = None # type: ignore[assignment]
|
|
105
80
|
|
|
106
|
-
def
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
super().__post_init__()
|
|
83
|
+
self.analysis = OpenAPIAnalysis(self)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def specification(self) -> Specification:
|
|
87
|
+
raise NotImplementedError
|
|
112
88
|
|
|
113
89
|
def __repr__(self) -> str:
|
|
114
90
|
info = self.raw_schema["info"]
|
|
115
|
-
return f"{self.__class__.__name__} for {info['title']}
|
|
116
|
-
|
|
117
|
-
def
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
91
|
+
return f"<{self.__class__.__name__} for {info['title']} {info['version']}>"
|
|
92
|
+
|
|
93
|
+
def __iter__(self) -> Iterator[str]:
|
|
94
|
+
paths = self._get_paths()
|
|
95
|
+
if paths is None:
|
|
96
|
+
return iter(())
|
|
97
|
+
return iter(paths)
|
|
98
|
+
|
|
99
|
+
@cached_property
|
|
100
|
+
def default_media_types(self) -> list[str]:
|
|
101
|
+
raise NotImplementedError
|
|
102
|
+
|
|
103
|
+
def _get_paths(self) -> Mapping[str, Any] | None:
|
|
104
|
+
paths = self.raw_schema.get("paths")
|
|
105
|
+
if paths is None:
|
|
106
|
+
return None
|
|
107
|
+
assert isinstance(paths, Mapping)
|
|
108
|
+
return cast(Mapping[str, Any], paths)
|
|
109
|
+
|
|
110
|
+
def _get_operation_map(self, path: str) -> APIOperationMap:
|
|
111
|
+
paths = self._get_paths()
|
|
112
|
+
if paths is None:
|
|
113
|
+
raise KeyError(path)
|
|
114
|
+
path_item = paths[path]
|
|
115
|
+
with in_scope(self.resolver, self.location or ""):
|
|
116
|
+
scope, path_item = self._resolve_path_item(path_item)
|
|
117
|
+
self.dispatch_hook("before_process_path", HookContext(), path, path_item)
|
|
118
|
+
map = APIOperationMap(self, {})
|
|
119
|
+
map._data = MethodMap(map, scope, path, CaseInsensitiveDict(path_item))
|
|
120
|
+
return map
|
|
121
|
+
|
|
122
|
+
def find_operation_by_label(self, label: str) -> APIOperation | None:
|
|
123
|
+
method, path = label.split(" ", maxsplit=1)
|
|
124
|
+
return self[path][method]
|
|
125
|
+
|
|
126
|
+
def on_missing_operation(self, item: str, exc: KeyError) -> NoReturn:
|
|
127
|
+
matches = get_close_matches(item, list(self))
|
|
128
|
+
self._on_missing_operation(item, exc, matches)
|
|
129
|
+
|
|
130
|
+
def _on_missing_operation(self, item: str, exc: KeyError | None, matches: list[str]) -> NoReturn:
|
|
131
|
+
message = f"`{item}` not found"
|
|
132
|
+
if matches:
|
|
133
|
+
message += f". Did you mean `{matches[0]}`?"
|
|
134
|
+
raise OperationNotFound(message=message, item=item) from exc
|
|
135
|
+
|
|
136
|
+
def _should_skip(
|
|
137
|
+
self,
|
|
138
|
+
path: str,
|
|
139
|
+
method: str,
|
|
140
|
+
definition: dict[str, Any],
|
|
141
|
+
_ctx_cache: SimpleNamespace = SimpleNamespace(
|
|
142
|
+
operation=APIOperation(
|
|
143
|
+
method="",
|
|
144
|
+
path="",
|
|
145
|
+
label="",
|
|
146
|
+
definition=OperationDefinition(raw=None),
|
|
147
|
+
schema=None, # type: ignore[arg-type]
|
|
148
|
+
responses=None,
|
|
149
|
+
security=None,
|
|
150
|
+
)
|
|
151
|
+
),
|
|
152
|
+
) -> bool:
|
|
153
|
+
if method not in HTTP_METHODS:
|
|
154
|
+
return True
|
|
155
|
+
if self.filter_set.is_empty():
|
|
156
|
+
return False
|
|
157
|
+
# Attribute assignment is way faster than creating a new namespace every time
|
|
158
|
+
operation = _ctx_cache.operation
|
|
159
|
+
operation.method = method
|
|
160
|
+
operation.path = path
|
|
161
|
+
operation.label = f"{method.upper()} {path}"
|
|
162
|
+
operation.definition.raw = definition
|
|
163
|
+
operation.schema = self
|
|
164
|
+
return not self.filter_set.match(_ctx_cache)
|
|
165
|
+
|
|
166
|
+
def _measure_statistic(self) -> ApiStatistic:
|
|
167
|
+
statistic = ApiStatistic()
|
|
168
|
+
paths = self._get_paths()
|
|
169
|
+
if paths is None:
|
|
170
|
+
return statistic
|
|
125
171
|
|
|
126
|
-
@property
|
|
127
|
-
def operations_count(self) -> int:
|
|
128
|
-
try:
|
|
129
|
-
paths = self.raw_schema["paths"]
|
|
130
|
-
except KeyError:
|
|
131
|
-
return 0
|
|
132
|
-
total = 0
|
|
133
172
|
resolve = self.resolver.resolve
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
173
|
+
resolve_path_item = self._resolve_path_item
|
|
174
|
+
should_skip = self._should_skip
|
|
175
|
+
links_keyword = self.adapter.links_keyword
|
|
176
|
+
|
|
177
|
+
# For operationId lookup
|
|
178
|
+
selected_operations_by_id: set[str] = set()
|
|
179
|
+
# Tuples of (method, path)
|
|
180
|
+
selected_operations_by_path: set[tuple[str, str]] = set()
|
|
181
|
+
collected_links: list[dict] = []
|
|
182
|
+
|
|
183
|
+
for path, path_item in paths.items():
|
|
184
|
+
try:
|
|
185
|
+
scope, path_item = resolve_path_item(path_item)
|
|
186
|
+
self.resolver.push_scope(scope)
|
|
187
|
+
try:
|
|
188
|
+
for method, definition in path_item.items():
|
|
189
|
+
if method not in HTTP_METHODS or not definition:
|
|
190
|
+
continue
|
|
191
|
+
statistic.operations.total += 1
|
|
192
|
+
is_selected = not should_skip(path, method, definition)
|
|
193
|
+
if is_selected:
|
|
194
|
+
statistic.operations.selected += 1
|
|
195
|
+
# Store both identifiers
|
|
196
|
+
if "operationId" in definition:
|
|
197
|
+
selected_operations_by_id.add(definition["operationId"])
|
|
198
|
+
selected_operations_by_path.add((method, path))
|
|
199
|
+
for response in definition.get("responses", {}).values():
|
|
200
|
+
if "$ref" in response:
|
|
201
|
+
_, response = resolve(response["$ref"])
|
|
202
|
+
defined_links = response.get(links_keyword)
|
|
203
|
+
if defined_links is not None:
|
|
204
|
+
statistic.links.total += len(defined_links)
|
|
205
|
+
if is_selected:
|
|
206
|
+
collected_links.extend(defined_links.values())
|
|
207
|
+
finally:
|
|
208
|
+
self.resolver.pop_scope()
|
|
209
|
+
except SCHEMA_PARSING_ERRORS:
|
|
137
210
|
continue
|
|
211
|
+
|
|
212
|
+
def is_link_selected(link: dict) -> bool:
|
|
213
|
+
if "$ref" in link:
|
|
214
|
+
_, link = resolve(link["$ref"])
|
|
215
|
+
|
|
216
|
+
if "operationId" in link:
|
|
217
|
+
return link["operationId"] in selected_operations_by_id
|
|
218
|
+
else:
|
|
219
|
+
try:
|
|
220
|
+
scope, _ = resolve(link["operationRef"])
|
|
221
|
+
path, method = scope.rsplit("/", maxsplit=2)[-2:]
|
|
222
|
+
path = path.replace("~1", "/").replace("~0", "~")
|
|
223
|
+
return (method, path) in selected_operations_by_path
|
|
224
|
+
except Exception:
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
for link in collected_links:
|
|
228
|
+
if is_link_selected(link):
|
|
229
|
+
statistic.links.selected += 1
|
|
230
|
+
|
|
231
|
+
return statistic
|
|
232
|
+
|
|
233
|
+
def _operation_iter(self) -> Iterator[tuple[str, str, dict[str, Any]]]:
|
|
234
|
+
paths = self._get_paths()
|
|
235
|
+
if paths is None:
|
|
236
|
+
return
|
|
237
|
+
resolve = self.resolver.resolve
|
|
238
|
+
should_skip = self._should_skip
|
|
239
|
+
for path, path_item in paths.items():
|
|
138
240
|
try:
|
|
139
|
-
if "$ref" in
|
|
140
|
-
_,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
# Straightforward iteration is faster than converting to a set & calculating length.
|
|
144
|
-
for method, definition in resolved_methods.items():
|
|
145
|
-
if self._should_skip(method, definition):
|
|
241
|
+
if "$ref" in path_item:
|
|
242
|
+
_, path_item = resolve(path_item["$ref"])
|
|
243
|
+
for method, definition in path_item.items():
|
|
244
|
+
if should_skip(path, method, definition):
|
|
146
245
|
continue
|
|
147
|
-
|
|
246
|
+
yield (method, path, definition)
|
|
148
247
|
except SCHEMA_PARSING_ERRORS:
|
|
149
|
-
# Ignore errors
|
|
150
248
|
continue
|
|
151
|
-
return total
|
|
152
249
|
|
|
153
250
|
def get_all_operations(self) -> Generator[Result[APIOperation, InvalidSchema], None, None]:
|
|
154
251
|
"""Iterate over all operations defined in the API.
|
|
@@ -166,426 +263,355 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
166
263
|
In both cases, Schemathesis lets the callee decide what to do with these variants. It allows it to test valid
|
|
167
264
|
operations and show errors for invalid ones.
|
|
168
265
|
"""
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
266
|
+
__tracebackhide__ = True
|
|
267
|
+
paths = self._get_paths()
|
|
268
|
+
if paths is None:
|
|
269
|
+
if version.parse(self.specification.version) >= version.parse("3.1"):
|
|
270
|
+
return
|
|
271
|
+
self._raise_invalid_schema(KeyError("paths"))
|
|
174
272
|
|
|
175
273
|
context = HookContext()
|
|
176
|
-
|
|
274
|
+
# Optimization: local variables are faster than attribute access
|
|
275
|
+
dispatch_hook = self.dispatch_hook
|
|
276
|
+
resolve_path_item = self._resolve_path_item
|
|
277
|
+
should_skip = self._should_skip
|
|
278
|
+
iter_parameters = self._iter_parameters
|
|
279
|
+
make_operation = self.make_operation
|
|
280
|
+
for path, path_item in paths.items():
|
|
177
281
|
method = None
|
|
178
282
|
try:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
for method, definition in raw_methods.items():
|
|
186
|
-
try:
|
|
187
|
-
# Setting a low recursion limit doesn't solve the problem with recursive references & inlining
|
|
188
|
-
# too much but decreases the number of cases when Schemathesis stuck on this step.
|
|
189
|
-
self.resolver.push_scope(scope)
|
|
190
|
-
try:
|
|
191
|
-
resolved_definition = self.resolver.resolve_all(definition, RECURSION_DEPTH_LIMIT - 5)
|
|
192
|
-
finally:
|
|
193
|
-
self.resolver.pop_scope()
|
|
194
|
-
# Only method definitions are parsed
|
|
195
|
-
if self._should_skip(method, resolved_definition):
|
|
283
|
+
dispatch_hook("before_process_path", context, path, path_item)
|
|
284
|
+
scope, path_item = resolve_path_item(path_item)
|
|
285
|
+
with in_scope(self.resolver, scope):
|
|
286
|
+
shared_parameters = path_item.get("parameters", [])
|
|
287
|
+
for method, entry in path_item.items():
|
|
288
|
+
if method not in HTTP_METHODS:
|
|
196
289
|
continue
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
290
|
+
try:
|
|
291
|
+
if should_skip(path, method, entry):
|
|
292
|
+
continue
|
|
293
|
+
parameters = iter_parameters(entry, shared_parameters)
|
|
294
|
+
operation = make_operation(
|
|
295
|
+
path,
|
|
296
|
+
method,
|
|
297
|
+
parameters,
|
|
298
|
+
entry,
|
|
299
|
+
scope,
|
|
300
|
+
)
|
|
301
|
+
yield Ok(operation)
|
|
302
|
+
except SCHEMA_PARSING_ERRORS as exc:
|
|
303
|
+
yield self._into_err(exc, path, method)
|
|
209
304
|
except SCHEMA_PARSING_ERRORS as exc:
|
|
210
305
|
yield self._into_err(exc, path, method)
|
|
211
306
|
|
|
212
|
-
def _into_err(self, error: Exception, path:
|
|
307
|
+
def _into_err(self, error: Exception, path: str | None, method: str | None) -> Err[InvalidSchema]:
|
|
308
|
+
__tracebackhide__ = True
|
|
213
309
|
try:
|
|
214
|
-
|
|
215
|
-
raise InvalidSchema(SCHEMA_ERROR_MESSAGE, path=path, method=method, full_path=full_path) from error
|
|
310
|
+
self._raise_invalid_schema(error, path, method)
|
|
216
311
|
except InvalidSchema as exc:
|
|
217
312
|
return Err(exc)
|
|
218
313
|
|
|
219
|
-
def
|
|
220
|
-
self,
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
314
|
+
def _raise_invalid_schema(
|
|
315
|
+
self,
|
|
316
|
+
error: Exception,
|
|
317
|
+
path: str | None = None,
|
|
318
|
+
method: str | None = None,
|
|
319
|
+
) -> NoReturn:
|
|
320
|
+
__tracebackhide__ = True
|
|
321
|
+
if isinstance(error, InfiniteRecursiveReference):
|
|
322
|
+
raise InvalidSchema(str(error), path=path, method=method) from None
|
|
323
|
+
if isinstance(error, RefResolutionError):
|
|
324
|
+
raise InvalidSchema.from_reference_resolution_error(error, path=path, method=method) from None
|
|
325
|
+
try:
|
|
326
|
+
self.validate()
|
|
327
|
+
except jsonschema.ValidationError as exc:
|
|
328
|
+
raise InvalidSchema.from_jsonschema_error(
|
|
329
|
+
exc,
|
|
330
|
+
path=path,
|
|
331
|
+
method=method,
|
|
332
|
+
config=self.config.output,
|
|
333
|
+
location=SchemaLocation.maybe_from_error_path(list(exc.absolute_path), self.specification.version),
|
|
334
|
+
) from None
|
|
335
|
+
raise InvalidSchema(SCHEMA_ERROR_SUGGESTION, path=path, method=method) from error
|
|
336
|
+
|
|
337
|
+
def validate(self) -> None:
|
|
338
|
+
with suppress(TypeError):
|
|
339
|
+
self._validate()
|
|
340
|
+
|
|
341
|
+
def _validate(self) -> None:
|
|
227
342
|
raise NotImplementedError
|
|
228
343
|
|
|
229
|
-
def
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
344
|
+
def _iter_parameters(
|
|
345
|
+
self, definition: dict[str, Any], shared_parameters: Sequence[dict[str, Any]]
|
|
346
|
+
) -> list[OperationParameter]:
|
|
347
|
+
return list(
|
|
348
|
+
self.adapter.iter_parameters(
|
|
349
|
+
definition, shared_parameters, self.default_media_types, self.resolver, self.adapter
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def _parse_responses(self, definition: dict[str, Any], scope: str) -> OpenApiResponses:
|
|
354
|
+
responses = definition.get("responses", {})
|
|
355
|
+
return OpenApiResponses.from_definition(
|
|
356
|
+
definition=responses, resolver=self.resolver, scope=scope, adapter=self.adapter
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def _parse_security(self, definition: dict[str, Any]) -> OpenApiSecurityParameters:
|
|
360
|
+
return OpenApiSecurityParameters.from_definition(
|
|
361
|
+
schema=self.raw_schema,
|
|
362
|
+
operation=definition,
|
|
363
|
+
resolver=self.resolver,
|
|
364
|
+
adapter=self.adapter,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def _resolve_path_item(self, methods: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
368
|
+
# The path item could be behind a reference
|
|
369
|
+
# In this case, we need to resolve it to get the proper scope for reference inside the item.
|
|
370
|
+
# It is mostly for validating responses.
|
|
233
371
|
if "$ref" in methods:
|
|
234
|
-
return
|
|
235
|
-
return self.resolver.resolution_scope,
|
|
372
|
+
return self.resolver.resolve(methods["$ref"])
|
|
373
|
+
return self.resolver.resolution_scope, methods
|
|
236
374
|
|
|
237
375
|
def make_operation(
|
|
238
376
|
self,
|
|
239
377
|
path: str,
|
|
240
378
|
method: str,
|
|
241
|
-
parameters:
|
|
242
|
-
|
|
379
|
+
parameters: list[OperationParameter],
|
|
380
|
+
definition: dict[str, Any],
|
|
381
|
+
scope: str,
|
|
243
382
|
) -> APIOperation:
|
|
244
|
-
|
|
383
|
+
__tracebackhide__ = True
|
|
245
384
|
base_url = self.get_base_url()
|
|
246
|
-
|
|
385
|
+
responses = self._parse_responses(definition, scope)
|
|
386
|
+
security = self._parse_security(definition)
|
|
387
|
+
operation: APIOperation[OperationParameter, ResponsesContainer, OpenApiSecurityParameters] = APIOperation(
|
|
247
388
|
path=path,
|
|
248
389
|
method=method,
|
|
249
|
-
definition=
|
|
390
|
+
definition=OperationDefinition(definition),
|
|
250
391
|
base_url=base_url,
|
|
251
392
|
app=self.app,
|
|
252
393
|
schema=self,
|
|
394
|
+
responses=responses,
|
|
395
|
+
security=security,
|
|
396
|
+
path_parameters=OpenApiParameterSet(ParameterLocation.PATH),
|
|
397
|
+
query=OpenApiParameterSet(ParameterLocation.QUERY),
|
|
398
|
+
headers=OpenApiParameterSet(ParameterLocation.HEADER),
|
|
399
|
+
cookies=OpenApiParameterSet(ParameterLocation.COOKIE),
|
|
253
400
|
)
|
|
254
401
|
for parameter in parameters:
|
|
255
402
|
operation.add_parameter(parameter)
|
|
256
|
-
|
|
403
|
+
# Inject unconstrained path parameters if any is missing
|
|
404
|
+
missing_parameter_names = get_template_fields(operation.path) - {
|
|
405
|
+
parameter.name for parameter in operation.path_parameters
|
|
406
|
+
}
|
|
407
|
+
for name in missing_parameter_names:
|
|
408
|
+
operation.add_parameter(
|
|
409
|
+
self.adapter.build_path_parameter({"name": name, INJECTED_PATH_PARAMETER_KEY: True})
|
|
410
|
+
)
|
|
411
|
+
config = self.config.generation_for(operation=operation)
|
|
412
|
+
if config.with_security_parameters:
|
|
413
|
+
for param in operation.security.iter_parameters():
|
|
414
|
+
param_name = param.get("name")
|
|
415
|
+
param_location = param.get("in")
|
|
416
|
+
if (
|
|
417
|
+
param_name is not None
|
|
418
|
+
and param_location is not None
|
|
419
|
+
and operation.get_parameter(name=param_name, location=param_location) is not None
|
|
420
|
+
):
|
|
421
|
+
continue
|
|
422
|
+
operation.add_parameter(
|
|
423
|
+
OpenApiParameter.from_definition(definition=param, name_to_uri={}, adapter=self.adapter)
|
|
424
|
+
)
|
|
257
425
|
self.dispatch_hook("before_init_operation", HookContext(operation=operation), operation)
|
|
258
426
|
return operation
|
|
259
427
|
|
|
260
428
|
@property
|
|
261
|
-
def resolver(self) ->
|
|
429
|
+
def resolver(self) -> ReferenceResolver:
|
|
262
430
|
if not hasattr(self, "_resolver"):
|
|
263
|
-
|
|
264
|
-
self._resolver = InliningResolver(self.location or "", self.raw_schema)
|
|
431
|
+
self._resolver = ReferenceResolver(self.location or "", self.raw_schema)
|
|
265
432
|
return self._resolver
|
|
266
433
|
|
|
267
|
-
def get_content_types(self, operation: APIOperation, response:
|
|
434
|
+
def get_content_types(self, operation: APIOperation, response: Response) -> list[str]:
|
|
268
435
|
"""Content types available for this API operation."""
|
|
269
436
|
raise NotImplementedError
|
|
270
437
|
|
|
271
|
-
def get_strategies_from_examples(self, operation: APIOperation) ->
|
|
438
|
+
def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
|
|
272
439
|
"""Get examples from the API operation."""
|
|
273
440
|
raise NotImplementedError
|
|
274
441
|
|
|
275
|
-
def
|
|
276
|
-
"""
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
common_parameters = self.resolver.resolve_all(methods.get("parameters", []), RECURSION_DEPTH_LIMIT - 5)
|
|
293
|
-
for method, definition in methods.items():
|
|
294
|
-
if method not in HTTP_METHODS or "operationId" not in definition:
|
|
442
|
+
def find_operation_by_id(self, operation_id: str) -> APIOperation:
|
|
443
|
+
"""Find an `APIOperation` instance by its `operationId`."""
|
|
444
|
+
resolve = self.resolver.resolve
|
|
445
|
+
default_scope = self.resolver.resolution_scope
|
|
446
|
+
paths = self._get_paths()
|
|
447
|
+
if paths is None:
|
|
448
|
+
self._on_missing_operation(operation_id, None, [])
|
|
449
|
+
assert paths is not None
|
|
450
|
+
for path, path_item in paths.items():
|
|
451
|
+
# If the path is behind a reference we have to keep the scope
|
|
452
|
+
# The scope is used to resolve nested components later on
|
|
453
|
+
if "$ref" in path_item:
|
|
454
|
+
scope, path_item = resolve(path_item["$ref"])
|
|
455
|
+
else:
|
|
456
|
+
scope = default_scope
|
|
457
|
+
for method, operation in path_item.items():
|
|
458
|
+
if method not in HTTP_METHODS:
|
|
295
459
|
continue
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
self.resolver.pop_scope()
|
|
301
|
-
parameters = self.collect_parameters(
|
|
302
|
-
itertools.chain(resolved_definition.get("parameters", ()), common_parameters), resolved_definition
|
|
303
|
-
)
|
|
304
|
-
raw_definition = OperationDefinition(raw_methods[method], resolved_definition, scope, parameters)
|
|
305
|
-
yield resolved_definition["operationId"], self.make_operation(path, method, parameters, raw_definition)
|
|
460
|
+
if "operationId" in operation and operation["operationId"] == operation_id:
|
|
461
|
+
parameters = self._iter_parameters(operation, path_item.get("parameters", []))
|
|
462
|
+
return self.make_operation(path, method, parameters, operation, scope)
|
|
463
|
+
self._on_missing_operation(operation_id, None, [])
|
|
306
464
|
|
|
307
|
-
def
|
|
308
|
-
"""
|
|
465
|
+
def find_operation_by_reference(self, reference: str) -> APIOperation:
|
|
466
|
+
"""Find local or external `APIOperation` instance by reference.
|
|
309
467
|
|
|
310
468
|
Reference example: #/paths/~1users~1{user_id}/patch
|
|
311
469
|
"""
|
|
312
|
-
scope,
|
|
470
|
+
scope, operation = self.resolver.resolve(reference)
|
|
313
471
|
path, method = scope.rsplit("/", maxsplit=2)[-2:]
|
|
314
472
|
path = path.replace("~1", "/").replace("~0", "~")
|
|
315
|
-
resolved_definition = self.resolver.resolve_all(data)
|
|
316
473
|
parent_ref, _ = reference.rsplit("/", maxsplit=1)
|
|
317
|
-
_,
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
474
|
+
_, path_item = self.resolver.resolve(parent_ref)
|
|
475
|
+
with in_scope(self.resolver, scope):
|
|
476
|
+
parameters = self._iter_parameters(operation, path_item.get("parameters", []))
|
|
477
|
+
return self.make_operation(path, method, parameters, operation, scope)
|
|
478
|
+
|
|
479
|
+
def find_operation_by_path(self, method: str, path: str) -> APIOperation | None:
|
|
480
|
+
"""Find an `APIOperation` by matching an actual request path.
|
|
481
|
+
|
|
482
|
+
Matches path templates with parameters, e.g., /users/42 matches /users/{user_id}.
|
|
483
|
+
Returns None if no operation matches.
|
|
484
|
+
"""
|
|
485
|
+
from werkzeug.exceptions import MethodNotAllowed, NotFound
|
|
486
|
+
|
|
487
|
+
from schemathesis.specs.openapi.stateful.inference import OperationById
|
|
488
|
+
|
|
489
|
+
# Match path and method using werkzeug router
|
|
490
|
+
try:
|
|
491
|
+
operation_ref, _ = self.analysis.inferencer._adapter.match(path, method=method.upper())
|
|
492
|
+
except (NotFound, MethodNotAllowed):
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
if isinstance(operation_ref, OperationById):
|
|
496
|
+
return self.find_operation_by_id(operation_ref.value)
|
|
497
|
+
return self.find_operation_by_reference(operation_ref.value)
|
|
324
498
|
|
|
325
499
|
def get_case_strategy(
|
|
326
500
|
self,
|
|
327
501
|
operation: APIOperation,
|
|
328
|
-
hooks:
|
|
329
|
-
auth_storage:
|
|
330
|
-
|
|
502
|
+
hooks: HookDispatcher | None = None,
|
|
503
|
+
auth_storage: AuthStorage | None = None,
|
|
504
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
|
505
|
+
**kwargs: Any,
|
|
331
506
|
) -> SearchStrategy:
|
|
332
|
-
return
|
|
333
|
-
operation=operation,
|
|
507
|
+
return openapi_cases(
|
|
508
|
+
operation=operation,
|
|
509
|
+
hooks=hooks,
|
|
510
|
+
auth_storage=auth_storage,
|
|
511
|
+
generation_mode=generation_mode,
|
|
512
|
+
**kwargs,
|
|
334
513
|
)
|
|
335
514
|
|
|
336
|
-
def get_parameter_serializer(self, operation: APIOperation, location: str) ->
|
|
337
|
-
definitions = [item for item in operation.
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
definitions.extend(security_parameters)
|
|
515
|
+
def get_parameter_serializer(self, operation: APIOperation, location: str) -> Callable | None:
|
|
516
|
+
definitions = [item.definition for item in operation.iter_parameters() if item.location == location]
|
|
517
|
+
config = self.config.generation_for(operation=operation)
|
|
518
|
+
if config.with_security_parameters:
|
|
519
|
+
security_parameters = [param for param in operation.security.iter_parameters() if param["in"] == location]
|
|
520
|
+
if security_parameters:
|
|
521
|
+
definitions.extend(security_parameters)
|
|
344
522
|
if definitions:
|
|
345
523
|
return self._get_parameter_serializer(definitions)
|
|
346
524
|
return None
|
|
347
525
|
|
|
348
|
-
def _get_parameter_serializer(self, definitions:
|
|
526
|
+
def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
|
|
349
527
|
raise NotImplementedError
|
|
350
528
|
|
|
351
|
-
def
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
return responses[status_code]
|
|
360
|
-
if "default" in responses:
|
|
361
|
-
return responses["default"]
|
|
362
|
-
return None
|
|
529
|
+
def as_state_machine(self) -> type[APIStateMachine]:
|
|
530
|
+
# Apply dependency inference if configured and not already done
|
|
531
|
+
if self.analysis.should_inject_links():
|
|
532
|
+
self.analysis.inject_links()
|
|
533
|
+
return create_state_machine(self)
|
|
534
|
+
|
|
535
|
+
def get_tags(self, operation: APIOperation) -> list[str] | None:
|
|
536
|
+
return operation.definition.raw.get("tags")
|
|
363
537
|
|
|
364
|
-
def
|
|
365
|
-
|
|
366
|
-
|
|
538
|
+
def validate_response(
|
|
539
|
+
self,
|
|
540
|
+
operation: APIOperation,
|
|
541
|
+
response: Response,
|
|
542
|
+
*,
|
|
543
|
+
case: Case | None = None,
|
|
544
|
+
) -> bool | None:
|
|
545
|
+
__tracebackhide__ = True
|
|
546
|
+
definition = operation.responses.find_by_status_code(response.status_code)
|
|
547
|
+
if definition is None or definition.schema is None:
|
|
548
|
+
# No definition for the given HTTP response, or missing "schema" in the matching definition
|
|
367
549
|
return None
|
|
368
|
-
return definitions.get("headers")
|
|
369
550
|
|
|
370
|
-
|
|
371
|
-
return create_state_machine(self)
|
|
551
|
+
failures: list[Failure] = []
|
|
372
552
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
) -> None:
|
|
382
|
-
"""Add a new Open API link to the schema definition.
|
|
383
|
-
|
|
384
|
-
:param APIOperation source: This operation is the source of data
|
|
385
|
-
:param target: This operation will receive the data from this link.
|
|
386
|
-
Can be an ``APIOperation`` instance or a reference like this - ``#/paths/~1users~1{userId}/get``
|
|
387
|
-
:param str status_code: The link is triggered when the source API operation responds with this status code.
|
|
388
|
-
:param parameters: A dictionary that describes how parameters should be extracted from the matched response.
|
|
389
|
-
The key represents the parameter name in the target API operation, and the value is a runtime
|
|
390
|
-
expression string.
|
|
391
|
-
:param request_body: A literal value or runtime expression to use as a request body when
|
|
392
|
-
calling the target operation.
|
|
393
|
-
:param str name: Explicit link name.
|
|
394
|
-
|
|
395
|
-
.. code-block:: python
|
|
396
|
-
|
|
397
|
-
schema = schemathesis.from_uri("http://0.0.0.0/schema.yaml")
|
|
398
|
-
|
|
399
|
-
schema.add_link(
|
|
400
|
-
source=schema["/users/"]["POST"],
|
|
401
|
-
target=schema["/users/{userId}"]["GET"],
|
|
402
|
-
status_code="201",
|
|
403
|
-
parameters={"userId": "$response.body#/id"},
|
|
404
|
-
)
|
|
405
|
-
"""
|
|
406
|
-
if parameters is None and request_body is None:
|
|
407
|
-
raise ValueError("You need to provide `parameters` or `request_body`.")
|
|
408
|
-
if hasattr(self, "_operations"):
|
|
409
|
-
delattr(self, "_operations")
|
|
410
|
-
for operation, methods in self.raw_schema["paths"].items():
|
|
411
|
-
if operation == source.path:
|
|
412
|
-
# Methods should be completely resolved now, otherwise they might miss a resolving scope when
|
|
413
|
-
# they will be fully resolved later
|
|
414
|
-
methods = self.resolver.resolve_all(methods)
|
|
415
|
-
found = False
|
|
416
|
-
for method, definition in methods.items():
|
|
417
|
-
if method.upper() == source.method.upper():
|
|
418
|
-
found = True
|
|
419
|
-
links.add_link(
|
|
420
|
-
responses=definition["responses"],
|
|
421
|
-
links_field=self.links_field,
|
|
422
|
-
parameters=parameters,
|
|
423
|
-
request_body=request_body,
|
|
424
|
-
status_code=status_code,
|
|
425
|
-
target=target,
|
|
426
|
-
name=name,
|
|
427
|
-
)
|
|
428
|
-
# If methods are behind a reference, then on the next resolving they will miss the new link
|
|
429
|
-
# Therefore we need to modify it this way
|
|
430
|
-
self.raw_schema["paths"][operation][method] = definition
|
|
431
|
-
# The reference should be removed completely, otherwise new keys in this dictionary will be ignored
|
|
432
|
-
# due to the `$ref` keyword behavior
|
|
433
|
-
self.raw_schema["paths"][operation].pop("$ref", None)
|
|
434
|
-
if found:
|
|
435
|
-
return
|
|
436
|
-
name = f"{source.method.upper()} {source.path}"
|
|
437
|
-
# Use a name without basePath, as the user doesn't use it.
|
|
438
|
-
# E.g. `source=schema["/users/"]["POST"]` without a prefix
|
|
439
|
-
message = f"No such API operation: `{name}`."
|
|
440
|
-
possibilities = [
|
|
441
|
-
f"{op.ok().method.upper()} {op.ok().path}" for op in self.get_all_operations() if isinstance(op, Ok)
|
|
442
|
-
]
|
|
443
|
-
matches = get_close_matches(name, possibilities)
|
|
444
|
-
if matches:
|
|
445
|
-
message += f" Did you mean `{matches[0]}`?"
|
|
446
|
-
message += " Check if the requested API operation passes the filters in the schema."
|
|
447
|
-
raise ValueError(message)
|
|
448
|
-
|
|
449
|
-
def get_links(self, operation: APIOperation) -> Dict[str, Dict[str, Any]]:
|
|
450
|
-
result: Dict[str, Dict[str, Any]] = defaultdict(dict)
|
|
451
|
-
for status_code, link in links.get_all_links(operation):
|
|
452
|
-
result[status_code][link.name] = link
|
|
453
|
-
return result
|
|
454
|
-
|
|
455
|
-
def validate_response(self, operation: APIOperation, response: GenericResponse) -> None:
|
|
456
|
-
responses = {str(key): value for key, value in operation.definition.raw.get("responses", {}).items()}
|
|
457
|
-
status_code = str(response.status_code)
|
|
458
|
-
if status_code in responses:
|
|
459
|
-
definition = responses[status_code]
|
|
460
|
-
elif "default" in responses:
|
|
461
|
-
definition = responses["default"]
|
|
553
|
+
content_types = response.headers.get("content-type")
|
|
554
|
+
if content_types is None:
|
|
555
|
+
all_media_types = self.get_content_types(operation, response)
|
|
556
|
+
formatted_content_types = [f"\n- `{content_type}`" for content_type in all_media_types]
|
|
557
|
+
message = f"The following media types are documented in the schema:{''.join(formatted_content_types)}"
|
|
558
|
+
failures.append(MissingContentType(operation=operation.label, message=message, media_types=all_media_types))
|
|
559
|
+
# Default content type
|
|
560
|
+
content_type = "application/json"
|
|
462
561
|
else:
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
# No schema to check against
|
|
468
|
-
return
|
|
469
|
-
content_type = response.headers.get("Content-Type")
|
|
470
|
-
if content_type is None:
|
|
471
|
-
media_types = self.get_content_types(operation, response)
|
|
472
|
-
formatted_media_types = "\n ".join(media_types)
|
|
473
|
-
raise get_missing_content_type_error()(
|
|
474
|
-
"The response is missing the `Content-Type` header. The schema defines the following media types:\n\n"
|
|
475
|
-
f" {formatted_media_types}",
|
|
476
|
-
context=failures.MissingContentType(media_types),
|
|
477
|
-
)
|
|
478
|
-
if not is_json_media_type(content_type):
|
|
479
|
-
return
|
|
562
|
+
content_type = content_types[0]
|
|
563
|
+
|
|
564
|
+
context = deserialization.DeserializationContext(operation=operation, case=case)
|
|
565
|
+
|
|
480
566
|
try:
|
|
481
|
-
|
|
482
|
-
data = json.loads(response.text)
|
|
483
|
-
else:
|
|
484
|
-
data = response.json
|
|
567
|
+
data = deserialization.deserialize_response(response, content_type, context=context)
|
|
485
568
|
except JSONDecodeError as exc:
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
569
|
+
failures.append(MalformedJson.from_exception(operation=operation.label, exc=exc))
|
|
570
|
+
_maybe_raise_one_or_more(failures)
|
|
571
|
+
return None
|
|
572
|
+
except NotImplementedError:
|
|
573
|
+
# No deserializer available for this media type - skip validation
|
|
574
|
+
# This is expected for many media types (images, binary formats, etc.)
|
|
575
|
+
return None
|
|
576
|
+
except Exception as exc:
|
|
577
|
+
failures.append(
|
|
578
|
+
Failure(
|
|
579
|
+
operation=operation.label,
|
|
580
|
+
title="Content deserialization error",
|
|
581
|
+
message=f"Failed to deserialize response content:\n\n {exc}",
|
|
582
|
+
)
|
|
583
|
+
)
|
|
584
|
+
_maybe_raise_one_or_more(failures)
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
try:
|
|
588
|
+
definition.validator.validate(data)
|
|
589
|
+
except jsonschema.SchemaError as exc:
|
|
590
|
+
raise InvalidSchema.from_jsonschema_error(
|
|
591
|
+
exc,
|
|
592
|
+
path=operation.path,
|
|
593
|
+
method=operation.method,
|
|
594
|
+
config=self.config.output,
|
|
595
|
+
location=SchemaLocation.response_schema(self.specification.version),
|
|
493
596
|
) from exc
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
f"The received response does not conform to the defined schema!\n\nDetails: \n\n{exc}",
|
|
504
|
-
context=failures.ValidationErrorContext(
|
|
505
|
-
validation_message=exc.message,
|
|
506
|
-
schema_path=list(exc.absolute_schema_path),
|
|
507
|
-
schema=exc.schema,
|
|
508
|
-
instance_path=list(exc.absolute_path),
|
|
509
|
-
instance=exc.instance,
|
|
510
|
-
),
|
|
511
|
-
) from exc
|
|
597
|
+
except jsonschema.ValidationError as exc:
|
|
598
|
+
failures.append(
|
|
599
|
+
JsonSchemaError.from_exception(
|
|
600
|
+
operation=operation.label,
|
|
601
|
+
exc=exc,
|
|
602
|
+
config=operation.schema.config.output,
|
|
603
|
+
)
|
|
604
|
+
)
|
|
605
|
+
_maybe_raise_one_or_more(failures)
|
|
512
606
|
return None # explicitly return None for mypy
|
|
513
607
|
|
|
514
|
-
@property
|
|
515
|
-
def rewritten_components(self) -> Dict[str, Any]:
|
|
516
|
-
if not hasattr(self, "_rewritten_components"):
|
|
517
|
-
|
|
518
|
-
def callback(_schema: Dict[str, Any], nullable_name: str) -> Dict[str, Any]:
|
|
519
|
-
_schema = to_json_schema(_schema, nullable_name=nullable_name, copy=False)
|
|
520
|
-
return self._rewrite_references(_schema, self.resolver)
|
|
521
|
-
|
|
522
|
-
# pylint: disable=attribute-defined-outside-init
|
|
523
|
-
# Different spec versions allow different keywords to store possible reference targets
|
|
524
|
-
components: Dict[str, Any] = {}
|
|
525
|
-
for path in self.component_locations:
|
|
526
|
-
schema = self.raw_schema
|
|
527
|
-
target = components
|
|
528
|
-
for chunk in path:
|
|
529
|
-
if chunk in schema:
|
|
530
|
-
schema = schema[chunk]
|
|
531
|
-
target = target.setdefault(chunk, {})
|
|
532
|
-
else:
|
|
533
|
-
break
|
|
534
|
-
else:
|
|
535
|
-
target.update(traverse_schema(deepcopy(schema), callback, self.nullable_name))
|
|
536
|
-
self._rewritten_components = components
|
|
537
|
-
return self._rewritten_components
|
|
538
|
-
|
|
539
|
-
def prepare_schema(self, schema: Any) -> Any:
|
|
540
|
-
"""Inline Open API definitions.
|
|
541
608
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
# Note that not all of them might be used for data generation, but at this point it is the simplest way to go
|
|
549
|
-
if self._inline_reference_cache:
|
|
550
|
-
schema[INLINED_REFERENCES_KEY] = self._inline_reference_cache
|
|
551
|
-
return schema
|
|
552
|
-
|
|
553
|
-
def _rewrite_references(self, schema: Dict[str, Any], resolver: InliningResolver) -> Dict[str, Any]:
|
|
554
|
-
"""Rewrite references present in the schema.
|
|
555
|
-
|
|
556
|
-
The idea is to resolve references, cache the result and replace these references with new ones
|
|
557
|
-
that point to a local path which is populated from this cache later on.
|
|
558
|
-
"""
|
|
559
|
-
reference = schema.get("$ref")
|
|
560
|
-
# If `$ref` is not a property name and should be processed
|
|
561
|
-
if reference is not None and isinstance(reference, str) and not reference.startswith("#/"):
|
|
562
|
-
key = _make_reference_key(resolver._scopes_stack, reference)
|
|
563
|
-
with self._inline_reference_cache_lock:
|
|
564
|
-
if key not in self._inline_reference_cache:
|
|
565
|
-
with resolver.resolving(reference) as resolved:
|
|
566
|
-
# Resolved object also may have references
|
|
567
|
-
self._inline_reference_cache[key] = traverse_schema(
|
|
568
|
-
resolved, lambda s: self._rewrite_references(s, resolver)
|
|
569
|
-
)
|
|
570
|
-
# Rewrite the reference with the new location
|
|
571
|
-
schema["$ref"] = f"#/{INLINED_REFERENCES_KEY}/{key}"
|
|
572
|
-
return schema
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
def _make_reference_key(scopes: List[str], reference: str) -> str:
|
|
576
|
-
"""A name under which the resolved reference data will be stored."""
|
|
577
|
-
# Using a hexdigest is the simplest way to associate practically unique keys with each reference
|
|
578
|
-
digest = sha1()
|
|
579
|
-
for scope in scopes:
|
|
580
|
-
digest.update(scope.encode("utf-8"))
|
|
581
|
-
# Separator to avoid collissions like this: ["a"], "bc" vs. ["ab"], "c". Otherwise, the resulting digest
|
|
582
|
-
# will be the same for both cases
|
|
583
|
-
digest.update(b"#")
|
|
584
|
-
digest.update(reference.encode("utf-8"))
|
|
585
|
-
return digest.hexdigest()
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
INLINED_REFERENCES_KEY = "x-inlined"
|
|
609
|
+
def _maybe_raise_one_or_more(failures: list[Failure]) -> None:
|
|
610
|
+
if not failures:
|
|
611
|
+
return
|
|
612
|
+
if len(failures) == 1:
|
|
613
|
+
raise failures[0] from None
|
|
614
|
+
raise FailureGroup(failures) from None
|
|
589
615
|
|
|
590
616
|
|
|
591
617
|
@contextmanager
|
|
@@ -597,185 +623,154 @@ def in_scope(resolver: jsonschema.RefResolver, scope: str) -> Generator[None, No
|
|
|
597
623
|
resolver.pop_scope()
|
|
598
624
|
|
|
599
625
|
|
|
600
|
-
@
|
|
601
|
-
|
|
602
|
-
"""
|
|
626
|
+
@dataclass
|
|
627
|
+
class MethodMap(Mapping):
|
|
628
|
+
"""Container for accessing API operations.
|
|
603
629
|
|
|
604
|
-
|
|
605
|
-
could be a stack of two scopes maximum. This context manager handles both cases (1 or 2 scope changes) in the same
|
|
606
|
-
way.
|
|
630
|
+
Provides a more specific error message if API operation is not found.
|
|
607
631
|
"""
|
|
608
|
-
with ExitStack() as stack:
|
|
609
|
-
for scope in scopes:
|
|
610
|
-
stack.enter_context(in_scope(resolver, scope))
|
|
611
|
-
yield
|
|
612
632
|
|
|
633
|
+
_parent: APIOperationMap
|
|
634
|
+
# Reference resolution scope
|
|
635
|
+
_scope: str
|
|
636
|
+
# Methods are stored for this path
|
|
637
|
+
_path: str
|
|
638
|
+
# Storage for definitions
|
|
639
|
+
_path_item: CaseInsensitiveDict
|
|
640
|
+
|
|
641
|
+
__slots__ = ("_parent", "_scope", "_path", "_path_item")
|
|
642
|
+
|
|
643
|
+
def __len__(self) -> int:
|
|
644
|
+
return len(self._path_item)
|
|
645
|
+
|
|
646
|
+
def __iter__(self) -> Iterator[str]:
|
|
647
|
+
return iter(self._path_item)
|
|
648
|
+
|
|
649
|
+
def _init_operation(self, method: str) -> APIOperation:
|
|
650
|
+
method = method.lower()
|
|
651
|
+
operation = self._path_item[method]
|
|
652
|
+
schema = cast(BaseOpenAPISchema, self._parent._schema)
|
|
653
|
+
path = self._path
|
|
654
|
+
scope = self._scope
|
|
655
|
+
with in_scope(schema.resolver, scope):
|
|
656
|
+
try:
|
|
657
|
+
parameters = schema._iter_parameters(operation, self._path_item.get("parameters", []))
|
|
658
|
+
except SCHEMA_PARSING_ERRORS as exc:
|
|
659
|
+
schema._raise_invalid_schema(exc, path, method)
|
|
660
|
+
return schema.make_operation(path, method, parameters, operation, scope)
|
|
613
661
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
662
|
+
def __getitem__(self, item: str) -> APIOperation:
|
|
663
|
+
try:
|
|
664
|
+
return self._init_operation(item)
|
|
665
|
+
except LookupError as exc:
|
|
666
|
+
available_methods = ", ".join(key.upper() for key in self if key in HTTP_METHODS)
|
|
667
|
+
message = f"Method `{item.upper()}` not found."
|
|
668
|
+
if available_methods:
|
|
669
|
+
message += f" Available methods: {available_methods}"
|
|
670
|
+
raise LookupError(message) from exc
|
|
617
671
|
|
|
618
672
|
|
|
619
673
|
class SwaggerV20(BaseOpenAPISchema):
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
security = SwaggerSecurityProcessor()
|
|
624
|
-
component_locations: ClassVar[Tuple[Tuple[str, ...], ...]] = (("definitions",),)
|
|
625
|
-
links_field = "x-links"
|
|
674
|
+
def __post_init__(self) -> None:
|
|
675
|
+
self.adapter = adapter.v2
|
|
676
|
+
super().__post_init__()
|
|
626
677
|
|
|
627
678
|
@property
|
|
628
|
-
def
|
|
629
|
-
|
|
679
|
+
def specification(self) -> Specification:
|
|
680
|
+
version = self.raw_schema.get("swagger", "2.0")
|
|
681
|
+
return Specification.openapi(version=version)
|
|
630
682
|
|
|
631
|
-
@
|
|
632
|
-
def
|
|
633
|
-
return
|
|
683
|
+
@cached_property
|
|
684
|
+
def default_media_types(self) -> list[str]:
|
|
685
|
+
return self.raw_schema.get("consumes", [])
|
|
686
|
+
|
|
687
|
+
def _validate(self) -> None:
|
|
688
|
+
SWAGGER_20_VALIDATOR.validate(self.raw_schema)
|
|
634
689
|
|
|
635
690
|
def _get_base_path(self) -> str:
|
|
636
691
|
return self.raw_schema.get("basePath", "/")
|
|
637
692
|
|
|
638
|
-
def
|
|
639
|
-
self, parameters: Iterable[Dict[str, Any]], definition: Dict[str, Any]
|
|
640
|
-
) -> List[OpenAPIParameter]:
|
|
641
|
-
# The main difference with Open API 3.0 is that it has `body` and `form` parameters that we need to handle
|
|
642
|
-
# differently.
|
|
643
|
-
collected: List[OpenAPIParameter] = []
|
|
644
|
-
# NOTE. The Open API 2.0 spec doesn't strictly imply having media types in the "consumes" keyword.
|
|
645
|
-
# It is not enforced by the meta schema and has no "MUST" verb in the spec text.
|
|
646
|
-
# Also, not every API has operations with payload (they might have only GET operations without payloads).
|
|
647
|
-
# For these reasons, it might be (and often is) absent, and we need to provide the proper media type in case
|
|
648
|
-
# we have operations with a payload.
|
|
649
|
-
media_types = self._get_consumes_for_operation(definition)
|
|
650
|
-
# For `in=body` parameters, we imply `application/json` as the default media type because it is the most common.
|
|
651
|
-
body_media_types = media_types or (OPENAPI_20_DEFAULT_BODY_MEDIA_TYPE,)
|
|
652
|
-
# If an API operation has parameters with `in=formData`, Schemathesis should know how to serialize it.
|
|
653
|
-
# We can't be 100% sure what media type is expected by the server and chose `multipart/form-data` as
|
|
654
|
-
# the default because it is broader since it allows us to upload files.
|
|
655
|
-
form_data_media_types = media_types or (OPENAPI_20_DEFAULT_FORM_MEDIA_TYPE,)
|
|
656
|
-
|
|
657
|
-
form_parameters = []
|
|
658
|
-
for parameter in parameters:
|
|
659
|
-
if parameter["in"] == "formData":
|
|
660
|
-
# We need to gather form parameters first before creating a composite parameter for them
|
|
661
|
-
form_parameters.append(parameter)
|
|
662
|
-
elif parameter["in"] == "body":
|
|
663
|
-
for media_type in body_media_types:
|
|
664
|
-
collected.append(OpenAPI20Body(definition=parameter, media_type=media_type))
|
|
665
|
-
else:
|
|
666
|
-
collected.append(OpenAPI20Parameter(definition=parameter))
|
|
667
|
-
|
|
668
|
-
if form_parameters:
|
|
669
|
-
for media_type in form_data_media_types:
|
|
670
|
-
collected.append(
|
|
671
|
-
# Individual `formData` parameters are joined into a single "composite" one.
|
|
672
|
-
OpenAPI20CompositeBody.from_parameters(*form_parameters, media_type=media_type)
|
|
673
|
-
)
|
|
674
|
-
return collected
|
|
675
|
-
|
|
676
|
-
def get_strategies_from_examples(self, operation: APIOperation) -> List[SearchStrategy[Case]]:
|
|
693
|
+
def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
|
|
677
694
|
"""Get examples from the API operation."""
|
|
678
|
-
return get_strategies_from_examples(operation,
|
|
679
|
-
|
|
680
|
-
def
|
|
681
|
-
scopes, definition = self.resolver.resolve_in_scope(deepcopy(definition), scope)
|
|
682
|
-
schema = definition.get("schema")
|
|
683
|
-
if not schema:
|
|
684
|
-
return scopes, None
|
|
685
|
-
# Extra conversion to JSON Schema is needed here if there was one $ref in the input
|
|
686
|
-
# because it is not converted
|
|
687
|
-
return scopes, to_json_schema_recursive(schema, self.nullable_name, is_response_schema=True)
|
|
688
|
-
|
|
689
|
-
def get_content_types(self, operation: APIOperation, response: GenericResponse) -> List[str]:
|
|
695
|
+
return get_strategies_from_examples(operation, **kwargs)
|
|
696
|
+
|
|
697
|
+
def get_content_types(self, operation: APIOperation, response: Response) -> list[str]:
|
|
690
698
|
produces = operation.definition.raw.get("produces", None)
|
|
691
699
|
if produces:
|
|
692
700
|
return produces
|
|
693
701
|
return self.raw_schema.get("produces", [])
|
|
694
702
|
|
|
695
|
-
def _get_parameter_serializer(self, definitions:
|
|
703
|
+
def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
|
|
696
704
|
return serialization.serialize_swagger2_parameters(definitions)
|
|
697
705
|
|
|
698
706
|
def prepare_multipart(
|
|
699
|
-
self, form_data:
|
|
700
|
-
) ->
|
|
701
|
-
"""Prepare form data for sending with `requests`.
|
|
702
|
-
|
|
703
|
-
:param form_data: Raw generated data as a dictionary.
|
|
704
|
-
:param operation: The tested API operation for which the data was generated.
|
|
705
|
-
:return: `files` and `data` values for `requests.request`.
|
|
706
|
-
"""
|
|
707
|
+
self, form_data: dict[str, Any], operation: APIOperation
|
|
708
|
+
) -> tuple[list | None, dict[str, Any] | None]:
|
|
707
709
|
files, data = [], {}
|
|
708
710
|
# If there is no content types specified for the request or "application/x-www-form-urlencoded" is specified
|
|
709
711
|
# explicitly, then use it., but if "multipart/form-data" is specified, then use it
|
|
710
712
|
content_types = self.get_request_payload_content_types(operation)
|
|
711
713
|
is_multipart = "multipart/form-data" in content_types
|
|
712
714
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
715
|
+
known_fields: dict[str, dict] = {}
|
|
716
|
+
|
|
717
|
+
for parameter in operation.body:
|
|
718
|
+
if COMBINED_FORM_DATA_MARKER in parameter.definition:
|
|
719
|
+
known_fields.update(parameter.definition["schema"].get("properties", {}))
|
|
720
|
+
|
|
721
|
+
def add_file(name: str, value: Any) -> None:
|
|
722
|
+
if isinstance(value, list):
|
|
723
|
+
for item in value:
|
|
716
724
|
files.append((name, (None, item)))
|
|
717
725
|
else:
|
|
718
|
-
files.append((name,
|
|
719
|
-
|
|
720
|
-
for
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
data[name] = value
|
|
726
|
+
files.append((name, value))
|
|
727
|
+
|
|
728
|
+
for name, value in form_data.items():
|
|
729
|
+
param_def = known_fields.get(name)
|
|
730
|
+
if param_def:
|
|
731
|
+
if param_def.get("type") == "file" or is_multipart:
|
|
732
|
+
add_file(name, value)
|
|
733
|
+
else:
|
|
734
|
+
data[name] = value
|
|
735
|
+
else:
|
|
736
|
+
# Unknown field — treat it as a file (safe default under multipart/form-data)
|
|
737
|
+
add_file(name, value)
|
|
731
738
|
# `None` is the default value for `files` and `data` arguments in `requests.request`
|
|
732
739
|
return files or None, data or None
|
|
733
740
|
|
|
734
|
-
def get_request_payload_content_types(self, operation: APIOperation) ->
|
|
735
|
-
return self._get_consumes_for_operation(operation.definition.
|
|
741
|
+
def get_request_payload_content_types(self, operation: APIOperation) -> list[str]:
|
|
742
|
+
return self._get_consumes_for_operation(operation.definition.raw)
|
|
736
743
|
|
|
737
744
|
def make_case(
|
|
738
745
|
self,
|
|
739
746
|
*,
|
|
740
|
-
case_cls: Type[C],
|
|
741
747
|
operation: APIOperation,
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
748
|
+
method: str | None = None,
|
|
749
|
+
path: str | None = None,
|
|
750
|
+
path_parameters: dict[str, Any] | None = None,
|
|
751
|
+
headers: dict[str, Any] | CaseInsensitiveDict | None = None,
|
|
752
|
+
cookies: dict[str, Any] | None = None,
|
|
753
|
+
query: dict[str, Any] | None = None,
|
|
754
|
+
body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet = NOT_SET,
|
|
755
|
+
media_type: str | None = None,
|
|
756
|
+
meta: CaseMetadata | None = None,
|
|
757
|
+
) -> Case:
|
|
749
758
|
if body is not NOT_SET and media_type is None:
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
if len(media_types) == 1:
|
|
753
|
-
# The only available option
|
|
754
|
-
media_type = media_types[0]
|
|
755
|
-
else:
|
|
756
|
-
media_types_repr = ", ".join(media_types)
|
|
757
|
-
raise UsageError(
|
|
758
|
-
"Can not detect appropriate media type. "
|
|
759
|
-
"You can either specify one of the defined media types "
|
|
760
|
-
f"or pass any other media type available for serialization. Defined media types: {media_types_repr}"
|
|
761
|
-
)
|
|
762
|
-
return case_cls(
|
|
759
|
+
media_type = operation._get_default_media_type()
|
|
760
|
+
return Case(
|
|
763
761
|
operation=operation,
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
762
|
+
method=method or operation.method.upper(),
|
|
763
|
+
path=path or operation.path,
|
|
764
|
+
path_parameters=path_parameters or {},
|
|
765
|
+
headers=CaseInsensitiveDict() if headers is None else CaseInsensitiveDict(headers),
|
|
766
|
+
cookies=cookies or {},
|
|
767
|
+
query=query or {},
|
|
768
768
|
body=body,
|
|
769
769
|
media_type=media_type,
|
|
770
|
+
meta=meta,
|
|
770
771
|
)
|
|
771
772
|
|
|
772
|
-
def _get_consumes_for_operation(self, definition:
|
|
773
|
-
"""Get the `consumes` value for the given API operation.
|
|
774
|
-
|
|
775
|
-
:param definition: Raw API operation definition.
|
|
776
|
-
:return: A list of media-types for this operation.
|
|
777
|
-
:rtype: List[str]
|
|
778
|
-
"""
|
|
773
|
+
def _get_consumes_for_operation(self, definition: dict[str, Any]) -> list[str]:
|
|
779
774
|
global_consumes = self.raw_schema.get("consumes", [])
|
|
780
775
|
consumes = definition.get("consumes", [])
|
|
781
776
|
if not consumes:
|
|
@@ -783,21 +778,29 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
|
783
778
|
return consumes
|
|
784
779
|
|
|
785
780
|
|
|
786
|
-
class OpenApi30(SwaggerV20):
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
781
|
+
class OpenApi30(SwaggerV20):
|
|
782
|
+
def __post_init__(self) -> None:
|
|
783
|
+
if self.specification.version.startswith("3.1"):
|
|
784
|
+
self.adapter = adapter.v3_1
|
|
785
|
+
else:
|
|
786
|
+
self.adapter = adapter.v3_0
|
|
787
|
+
BaseOpenAPISchema.__post_init__(self)
|
|
793
788
|
|
|
794
789
|
@property
|
|
795
|
-
def
|
|
796
|
-
|
|
790
|
+
def specification(self) -> Specification:
|
|
791
|
+
version = self.raw_schema["openapi"]
|
|
792
|
+
return Specification.openapi(version=version)
|
|
797
793
|
|
|
798
|
-
@
|
|
799
|
-
def
|
|
800
|
-
return
|
|
794
|
+
@cached_property
|
|
795
|
+
def default_media_types(self) -> list[str]:
|
|
796
|
+
return []
|
|
797
|
+
|
|
798
|
+
def _validate(self) -> None:
|
|
799
|
+
if self.specification.version.startswith("3.1"):
|
|
800
|
+
# Currently we treat Open API 3.1 as 3.0 in some regard
|
|
801
|
+
OPENAPI_31_VALIDATOR.validate(self.raw_schema)
|
|
802
|
+
else:
|
|
803
|
+
OPENAPI_30_VALIDATOR.validate(self.raw_schema)
|
|
801
804
|
|
|
802
805
|
def _get_base_path(self) -> str:
|
|
803
806
|
servers = self.raw_schema.get("servers", [])
|
|
@@ -808,68 +811,46 @@ class OpenApi30(SwaggerV20): # pylint: disable=too-many-ancestors
|
|
|
808
811
|
return urlsplit(url).path
|
|
809
812
|
return "/"
|
|
810
813
|
|
|
811
|
-
def
|
|
812
|
-
self, parameters: Iterable[Dict[str, Any]], definition: Dict[str, Any]
|
|
813
|
-
) -> List[OpenAPIParameter]:
|
|
814
|
-
# Open API 3.0 has the `requestBody` keyword, which may contain multiple different payload variants.
|
|
815
|
-
collected: List[OpenAPIParameter] = [OpenAPI30Parameter(definition=parameter) for parameter in parameters]
|
|
816
|
-
if "requestBody" in definition:
|
|
817
|
-
required = definition["requestBody"].get("required", False)
|
|
818
|
-
description = definition["requestBody"].get("description")
|
|
819
|
-
for media_type, content in definition["requestBody"]["content"].items():
|
|
820
|
-
collected.append(
|
|
821
|
-
OpenAPI30Body(content, description=description, media_type=media_type, required=required)
|
|
822
|
-
)
|
|
823
|
-
return collected
|
|
824
|
-
|
|
825
|
-
def get_response_schema(self, definition: Dict[str, Any], scope: str) -> Tuple[List[str], Optional[Dict[str, Any]]]:
|
|
826
|
-
scopes, definition = self.resolver.resolve_in_scope(deepcopy(definition), scope)
|
|
827
|
-
options = iter(definition.get("content", {}).values())
|
|
828
|
-
option = next(options, None)
|
|
829
|
-
# "schema" is an optional key in the `MediaType` object
|
|
830
|
-
if option and "schema" in option:
|
|
831
|
-
# Extra conversion to JSON Schema is needed here if there was one $ref in the input
|
|
832
|
-
# because it is not converted
|
|
833
|
-
return scopes, to_json_schema_recursive(option["schema"], self.nullable_name, is_response_schema=True)
|
|
834
|
-
return scopes, None
|
|
835
|
-
|
|
836
|
-
def get_strategies_from_examples(self, operation: APIOperation) -> List[SearchStrategy[Case]]:
|
|
814
|
+
def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
|
|
837
815
|
"""Get examples from the API operation."""
|
|
838
|
-
return get_strategies_from_examples(operation,
|
|
816
|
+
return get_strategies_from_examples(operation, **kwargs)
|
|
839
817
|
|
|
840
|
-
def get_content_types(self, operation: APIOperation, response:
|
|
841
|
-
|
|
842
|
-
if
|
|
818
|
+
def get_content_types(self, operation: APIOperation, response: Response) -> list[str]:
|
|
819
|
+
definition = operation.responses.find_by_status_code(response.status_code)
|
|
820
|
+
if definition is None:
|
|
843
821
|
return []
|
|
844
|
-
return list(
|
|
822
|
+
return list(definition.definition.get("content", {}).keys())
|
|
845
823
|
|
|
846
|
-
def _get_parameter_serializer(self, definitions:
|
|
824
|
+
def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
|
|
847
825
|
return serialization.serialize_openapi3_parameters(definitions)
|
|
848
826
|
|
|
849
|
-
def get_request_payload_content_types(self, operation: APIOperation) ->
|
|
850
|
-
return
|
|
827
|
+
def get_request_payload_content_types(self, operation: APIOperation) -> list[str]:
|
|
828
|
+
return [body.media_type for body in operation.body]
|
|
851
829
|
|
|
852
830
|
def prepare_multipart(
|
|
853
|
-
self, form_data:
|
|
854
|
-
) ->
|
|
855
|
-
"""Prepare form data for sending with `requests`.
|
|
856
|
-
|
|
857
|
-
:param form_data: Raw generated data as a dictionary.
|
|
858
|
-
:param operation: The tested API operation for which the data was generated.
|
|
859
|
-
:return: `files` and `data` values for `requests.request`.
|
|
860
|
-
"""
|
|
831
|
+
self, form_data: dict[str, Any], operation: APIOperation
|
|
832
|
+
) -> tuple[list | None, dict[str, Any] | None]:
|
|
861
833
|
files = []
|
|
862
|
-
content = operation.definition.resolved["requestBody"]["content"]
|
|
863
834
|
# Open API 3.0 requires media types to be present. We can get here only if the schema defines
|
|
864
|
-
# the "multipart/form-data" media type
|
|
865
|
-
schema =
|
|
866
|
-
for
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
835
|
+
# the "multipart/form-data" media type, or any other more general media type that matches it (like `*/*`)
|
|
836
|
+
schema = {}
|
|
837
|
+
for body in operation.body:
|
|
838
|
+
main, sub = media_types.parse(body.media_type)
|
|
839
|
+
if main in ("*", "multipart") and sub in ("*", "form-data", "mixed"):
|
|
840
|
+
schema = body.definition.get("schema")
|
|
841
|
+
break
|
|
842
|
+
for name, value in form_data.items():
|
|
843
|
+
property_schema = (schema or {}).get("properties", {}).get(name)
|
|
844
|
+
if property_schema:
|
|
845
|
+
if isinstance(value, list):
|
|
846
|
+
files.extend([(name, item) for item in value])
|
|
870
847
|
elif property_schema.get("format") in ("binary", "base64"):
|
|
871
|
-
files.append((name,
|
|
848
|
+
files.append((name, value))
|
|
872
849
|
else:
|
|
873
|
-
files.append((name, (None,
|
|
850
|
+
files.append((name, (None, value)))
|
|
851
|
+
elif isinstance(value, list):
|
|
852
|
+
files.extend([(name, item) for item in value])
|
|
853
|
+
else:
|
|
854
|
+
files.append((name, (None, value)))
|
|
874
855
|
# `None` is the default value for `files` and `data` arguments in `requests.request`
|
|
875
856
|
return files or None, None
|