schemathesis 4.1.4__py3-none-any.whl → 4.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemathesis/cli/commands/run/executor.py +1 -1
- schemathesis/cli/commands/run/handlers/base.py +28 -1
- schemathesis/cli/commands/run/handlers/cassettes.py +10 -12
- schemathesis/cli/commands/run/handlers/junitxml.py +5 -6
- schemathesis/cli/commands/run/handlers/output.py +7 -1
- schemathesis/cli/ext/fs.py +1 -1
- schemathesis/config/_diff_base.py +3 -1
- schemathesis/config/_operations.py +2 -0
- schemathesis/config/_phases.py +21 -4
- schemathesis/config/_projects.py +10 -2
- schemathesis/core/adapter.py +34 -0
- schemathesis/core/errors.py +29 -5
- schemathesis/core/jsonschema/__init__.py +13 -0
- schemathesis/core/jsonschema/bundler.py +163 -0
- schemathesis/{specs/openapi/constants.py → core/jsonschema/keywords.py} +0 -8
- schemathesis/core/jsonschema/references.py +122 -0
- schemathesis/core/jsonschema/types.py +41 -0
- schemathesis/core/media_types.py +6 -4
- schemathesis/core/parameters.py +37 -0
- schemathesis/core/transforms.py +25 -2
- schemathesis/core/validation.py +19 -0
- schemathesis/engine/context.py +1 -1
- schemathesis/engine/errors.py +11 -18
- schemathesis/engine/phases/stateful/_executor.py +1 -1
- schemathesis/engine/phases/unit/_executor.py +30 -13
- schemathesis/errors.py +4 -0
- schemathesis/filters.py +2 -2
- schemathesis/generation/coverage.py +87 -11
- schemathesis/generation/hypothesis/__init__.py +4 -1
- schemathesis/generation/hypothesis/builder.py +108 -70
- schemathesis/generation/meta.py +5 -14
- schemathesis/generation/overrides.py +17 -17
- schemathesis/pytest/lazy.py +1 -1
- schemathesis/pytest/plugin.py +1 -6
- schemathesis/schemas.py +22 -72
- schemathesis/specs/graphql/schemas.py +27 -16
- schemathesis/specs/openapi/_hypothesis.py +83 -68
- schemathesis/specs/openapi/adapter/__init__.py +10 -0
- schemathesis/specs/openapi/adapter/parameters.py +504 -0
- schemathesis/specs/openapi/adapter/protocol.py +57 -0
- schemathesis/specs/openapi/adapter/references.py +19 -0
- schemathesis/specs/openapi/adapter/responses.py +329 -0
- schemathesis/specs/openapi/adapter/security.py +141 -0
- schemathesis/specs/openapi/adapter/v2.py +28 -0
- schemathesis/specs/openapi/adapter/v3_0.py +28 -0
- schemathesis/specs/openapi/adapter/v3_1.py +28 -0
- schemathesis/specs/openapi/checks.py +99 -90
- schemathesis/specs/openapi/converter.py +114 -27
- schemathesis/specs/openapi/examples.py +210 -168
- schemathesis/specs/openapi/negative/__init__.py +12 -7
- schemathesis/specs/openapi/negative/mutations.py +68 -40
- schemathesis/specs/openapi/references.py +2 -175
- schemathesis/specs/openapi/schemas.py +142 -490
- schemathesis/specs/openapi/serialization.py +15 -7
- schemathesis/specs/openapi/stateful/__init__.py +17 -12
- schemathesis/specs/openapi/stateful/inference.py +13 -11
- schemathesis/specs/openapi/stateful/links.py +5 -20
- schemathesis/specs/openapi/types/__init__.py +3 -0
- schemathesis/specs/openapi/types/v3.py +68 -0
- schemathesis/specs/openapi/utils.py +1 -13
- schemathesis/transport/requests.py +3 -11
- schemathesis/transport/serialization.py +63 -27
- schemathesis/transport/wsgi.py +1 -8
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.0.dist-info}/METADATA +2 -2
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.0.dist-info}/RECORD +68 -53
- schemathesis/specs/openapi/parameters.py +0 -405
- schemathesis/specs/openapi/security.py +0 -162
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.0.dist-info}/WHEEL +0 -0
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.1.4.dist-info → schemathesis-4.2.0.dist-info}/licenses/LICENSE +0 -0
schemathesis/schemas.py
CHANGED
@@ -18,7 +18,8 @@ from urllib.parse import quote, unquote, urljoin, urlsplit, urlunsplit
|
|
18
18
|
|
19
19
|
from schemathesis import transport
|
20
20
|
from schemathesis.config import ProjectConfig
|
21
|
-
from schemathesis.core import NOT_SET, NotSet
|
21
|
+
from schemathesis.core import NOT_SET, NotSet, media_types
|
22
|
+
from schemathesis.core.adapter import OperationParameter, ResponsesContainer
|
22
23
|
from schemathesis.core.errors import IncorrectUsage, InvalidSchema
|
23
24
|
from schemathesis.core.result import Ok, Result
|
24
25
|
from schemathesis.core.transport import Response
|
@@ -302,9 +303,6 @@ class BaseSchema(Mapping):
|
|
302
303
|
def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
|
303
304
|
raise NotImplementedError
|
304
305
|
|
305
|
-
def get_security_requirements(self, operation: APIOperation) -> list[str]:
|
306
|
-
raise NotImplementedError
|
307
|
-
|
308
306
|
def get_parameter_serializer(self, operation: APIOperation, location: str) -> Callable | None:
|
309
307
|
raise NotImplementedError
|
310
308
|
|
@@ -432,21 +430,12 @@ class BaseSchema(Mapping):
|
|
432
430
|
"""
|
433
431
|
raise NotImplementedError
|
434
432
|
|
435
|
-
def get_links(self, operation: APIOperation) -> dict[str, dict[str, Any]]:
|
436
|
-
raise NotImplementedError
|
437
|
-
|
438
433
|
def get_tags(self, operation: APIOperation) -> list[str] | None:
|
439
434
|
raise NotImplementedError
|
440
435
|
|
441
436
|
def validate_response(self, operation: APIOperation, response: Response) -> bool | None:
|
442
437
|
raise NotImplementedError
|
443
438
|
|
444
|
-
def prepare_schema(self, schema: Any) -> Any:
|
445
|
-
raise NotImplementedError
|
446
|
-
|
447
|
-
def _get_payload_schema(self, definition: dict[str, Any], media_type: str) -> dict[str, Any] | None:
|
448
|
-
raise NotImplementedError
|
449
|
-
|
450
439
|
def as_strategy(
|
451
440
|
self,
|
452
441
|
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
@@ -514,39 +503,7 @@ class APIOperationMap(Mapping):
|
|
514
503
|
return strategies.combine(_strategies)
|
515
504
|
|
516
505
|
|
517
|
-
|
518
|
-
class Parameter:
|
519
|
-
"""A logically separate parameter bound to a location (e.g., to "query string").
|
520
|
-
|
521
|
-
For example, if the API requires multiple headers to be present, each header is presented as a separate
|
522
|
-
`Parameter` instance.
|
523
|
-
"""
|
524
|
-
|
525
|
-
# The parameter definition in the language acceptable by the API
|
526
|
-
definition: Any
|
527
|
-
|
528
|
-
__slots__ = ("definition",)
|
529
|
-
|
530
|
-
@property
|
531
|
-
def location(self) -> str:
|
532
|
-
"""Where this parameter is located.
|
533
|
-
|
534
|
-
E.g. "query" or "body"
|
535
|
-
"""
|
536
|
-
raise NotImplementedError
|
537
|
-
|
538
|
-
@property
|
539
|
-
def name(self) -> str:
|
540
|
-
"""Parameter name."""
|
541
|
-
raise NotImplementedError
|
542
|
-
|
543
|
-
@property
|
544
|
-
def is_required(self) -> bool:
|
545
|
-
"""Whether the parameter is required for a successful API call."""
|
546
|
-
raise NotImplementedError
|
547
|
-
|
548
|
-
|
549
|
-
P = TypeVar("P", bound=Parameter)
|
506
|
+
P = TypeVar("P", bound=OperationParameter)
|
550
507
|
|
551
508
|
|
552
509
|
@dataclass
|
@@ -572,14 +529,11 @@ class ParameterSet(Generic[P]):
|
|
572
529
|
return parameter
|
573
530
|
return None
|
574
531
|
|
575
|
-
def
|
576
|
-
|
577
|
-
|
578
|
-
|
579
|
-
return
|
580
|
-
|
581
|
-
def __bool__(self) -> bool:
|
582
|
-
return bool(self.items)
|
532
|
+
def __contains__(self, name: str) -> bool:
|
533
|
+
for parameter in self.items:
|
534
|
+
if parameter.name == name:
|
535
|
+
return True
|
536
|
+
return False
|
583
537
|
|
584
538
|
def __iter__(self) -> Generator[P, None, None]:
|
585
539
|
yield from iter(self.items)
|
@@ -595,6 +549,8 @@ class PayloadAlternatives(ParameterSet[P]):
|
|
595
549
|
"""A set of alternative payloads."""
|
596
550
|
|
597
551
|
|
552
|
+
R = TypeVar("R", bound=ResponsesContainer)
|
553
|
+
S = TypeVar("S")
|
598
554
|
D = TypeVar("D", bound=dict)
|
599
555
|
|
600
556
|
|
@@ -608,16 +564,14 @@ class OperationDefinition(Generic[D]):
|
|
608
564
|
"""
|
609
565
|
|
610
566
|
raw: D
|
611
|
-
resolved: D
|
612
|
-
scope: str
|
613
567
|
|
614
|
-
__slots__ = ("raw",
|
568
|
+
__slots__ = ("raw",)
|
615
569
|
|
616
570
|
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
617
571
|
|
618
572
|
|
619
573
|
@dataclass()
|
620
|
-
class APIOperation(Generic[P]):
|
574
|
+
class APIOperation(Generic[P, R, S]):
|
621
575
|
"""An API operation (e.g., `GET /users`)."""
|
622
576
|
|
623
577
|
# `path` does not contain `basePath`
|
@@ -627,6 +581,8 @@ class APIOperation(Generic[P]):
|
|
627
581
|
method: str
|
628
582
|
definition: OperationDefinition = field(repr=False)
|
629
583
|
schema: BaseSchema
|
584
|
+
responses: R
|
585
|
+
security: S
|
630
586
|
label: str = None # type: ignore
|
631
587
|
app: Any = None
|
632
588
|
base_url: str | None = None
|
@@ -640,7 +596,7 @@ class APIOperation(Generic[P]):
|
|
640
596
|
if self.label is None:
|
641
597
|
self.label = f"{self.method.upper()} {self.path}" # type: ignore
|
642
598
|
|
643
|
-
def __deepcopy__(self, memo: dict) -> APIOperation[P]:
|
599
|
+
def __deepcopy__(self, memo: dict) -> APIOperation[P, R, S]:
|
644
600
|
return self
|
645
601
|
|
646
602
|
def __hash__(self) -> int:
|
@@ -655,10 +611,6 @@ class APIOperation(Generic[P]):
|
|
655
611
|
def full_path(self) -> str:
|
656
612
|
return self.schema.get_full_path(self.path)
|
657
613
|
|
658
|
-
@property
|
659
|
-
def links(self) -> dict[str, dict[str, Any]]:
|
660
|
-
return self.schema.get_links(self)
|
661
|
-
|
662
614
|
@property
|
663
615
|
def tags(self) -> list[str] | None:
|
664
616
|
return self.schema.get_tags(self)
|
@@ -690,6 +642,13 @@ class APIOperation(Generic[P]):
|
|
690
642
|
return container.get(name)
|
691
643
|
return None
|
692
644
|
|
645
|
+
def get_bodies_for_media_type(self, media_type: str) -> Iterator[P]:
|
646
|
+
main_target, sub_target = media_types.parse(media_type)
|
647
|
+
for body in self.body:
|
648
|
+
main, sub = media_types.parse(body.media_type) # type:ignore[attr-defined]
|
649
|
+
if main in ("*", main_target) and sub in ("*", sub_target):
|
650
|
+
yield body
|
651
|
+
|
693
652
|
def as_strategy(
|
694
653
|
self,
|
695
654
|
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
@@ -739,9 +698,6 @@ class APIOperation(Generic[P]):
|
|
739
698
|
strategy = _apply_hooks(hooks, strategy)
|
740
699
|
return strategy
|
741
700
|
|
742
|
-
def get_security_requirements(self) -> list[str]:
|
743
|
-
return self.schema.get_security_requirements(self)
|
744
|
-
|
745
701
|
def get_strategies_from_examples(self, **kwargs: Any) -> list[SearchStrategy[Case]]:
|
746
702
|
return self.schema.get_strategies_from_examples(self, **kwargs)
|
747
703
|
|
@@ -839,9 +795,3 @@ class APIOperation(Generic[P]):
|
|
839
795
|
return True
|
840
796
|
except AssertionError:
|
841
797
|
return False
|
842
|
-
|
843
|
-
def get_raw_payload_schema(self, media_type: str) -> dict[str, Any] | None:
|
844
|
-
return self.schema._get_payload_schema(self.definition.raw, media_type)
|
845
|
-
|
846
|
-
def get_resolved_payload_schema(self, media_type: str) -> dict[str, Any] | None:
|
847
|
-
return self.schema._get_payload_schema(self.definition.resolved, media_type)
|
@@ -27,13 +27,13 @@ from requests.structures import CaseInsensitiveDict
|
|
27
27
|
from schemathesis import auths
|
28
28
|
from schemathesis.core import NOT_SET, NotSet, Specification
|
29
29
|
from schemathesis.core.errors import InvalidSchema, OperationNotFound
|
30
|
+
from schemathesis.core.parameters import ParameterLocation
|
30
31
|
from schemathesis.core.result import Ok, Result
|
31
32
|
from schemathesis.generation import GenerationMode
|
32
33
|
from schemathesis.generation.case import Case
|
33
34
|
from schemathesis.generation.meta import (
|
34
35
|
CaseMetadata,
|
35
36
|
ComponentInfo,
|
36
|
-
ComponentKind,
|
37
37
|
ExamplesPhaseData,
|
38
38
|
FuzzingPhaseData,
|
39
39
|
GenerationInfo,
|
@@ -48,7 +48,6 @@ from schemathesis.schemas import (
|
|
48
48
|
BaseSchema,
|
49
49
|
OperationDefinition,
|
50
50
|
)
|
51
|
-
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
52
51
|
|
53
52
|
from .scalars import CUSTOM_SCALARS, get_extra_scalar_strategies
|
54
53
|
|
@@ -70,7 +69,7 @@ class GraphQLOperationDefinition(OperationDefinition):
|
|
70
69
|
type_: graphql.GraphQLType
|
71
70
|
root_type: RootType
|
72
71
|
|
73
|
-
__slots__ = ("raw", "
|
72
|
+
__slots__ = ("raw", "field_name", "type_", "root_type")
|
74
73
|
|
75
74
|
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
76
75
|
|
@@ -83,6 +82,14 @@ class GraphQLOperationDefinition(OperationDefinition):
|
|
83
82
|
return self.root_type == RootType.MUTATION
|
84
83
|
|
85
84
|
|
85
|
+
class GraphQLResponses:
|
86
|
+
def find_by_status_code(self, status_code: int) -> None:
|
87
|
+
return None # pragma: no cover
|
88
|
+
|
89
|
+
def add(self, status_code: str, definition: dict[str, Any]) -> None:
|
90
|
+
return None # pragma: no cover
|
91
|
+
|
92
|
+
|
86
93
|
@dataclass
|
87
94
|
class GraphQLSchema(BaseSchema):
|
88
95
|
def __repr__(self) -> str:
|
@@ -158,6 +165,8 @@ class GraphQLSchema(BaseSchema):
|
|
158
165
|
label="",
|
159
166
|
method="POST",
|
160
167
|
schema=self,
|
168
|
+
responses=GraphQLResponses(),
|
169
|
+
security=None,
|
161
170
|
definition=None, # type: ignore
|
162
171
|
)
|
163
172
|
|
@@ -210,11 +219,11 @@ class GraphQLSchema(BaseSchema):
|
|
210
219
|
method="POST",
|
211
220
|
app=self.app,
|
212
221
|
schema=self,
|
222
|
+
responses=GraphQLResponses(),
|
223
|
+
security=None,
|
213
224
|
# Parameters are not yet supported
|
214
225
|
definition=GraphQLOperationDefinition(
|
215
226
|
raw=field,
|
216
|
-
resolved=field,
|
217
|
-
scope="",
|
218
227
|
type_=operation_type,
|
219
228
|
field_name=field_name,
|
220
229
|
root_type=root_type,
|
@@ -348,10 +357,12 @@ def graphql_cases(
|
|
348
357
|
strategy = apply_to_all_dispatchers(operation, hook_context, hooks, strategy, "body").map(graphql.print_ast)
|
349
358
|
body = draw(strategy)
|
350
359
|
|
351
|
-
path_parameters_ = _generate_parameter(
|
352
|
-
|
353
|
-
|
354
|
-
|
360
|
+
path_parameters_ = _generate_parameter(
|
361
|
+
ParameterLocation.PATH, path_parameters, draw, operation, hook_context, hooks
|
362
|
+
)
|
363
|
+
headers_ = _generate_parameter(ParameterLocation.HEADER, headers, draw, operation, hook_context, hooks)
|
364
|
+
cookies_ = _generate_parameter(ParameterLocation.COOKIE, cookies, draw, operation, hook_context, hooks)
|
365
|
+
query_ = _generate_parameter(ParameterLocation.QUERY, query, draw, operation, hook_context, hooks)
|
355
366
|
|
356
367
|
_phase_data = {
|
357
368
|
TestPhase.EXAMPLES: ExamplesPhaseData(),
|
@@ -373,11 +384,11 @@ def graphql_cases(
|
|
373
384
|
components={
|
374
385
|
kind: ComponentInfo(mode=generation_mode)
|
375
386
|
for kind, value in [
|
376
|
-
(
|
377
|
-
(
|
378
|
-
(
|
379
|
-
(
|
380
|
-
(
|
387
|
+
(ParameterLocation.QUERY, query_),
|
388
|
+
(ParameterLocation.PATH, path_parameters_),
|
389
|
+
(ParameterLocation.HEADER, headers_),
|
390
|
+
(ParameterLocation.COOKIE, cookies_),
|
391
|
+
(ParameterLocation.BODY, body),
|
381
392
|
]
|
382
393
|
if value is not NOT_SET
|
383
394
|
},
|
@@ -393,7 +404,7 @@ def graphql_cases(
|
|
393
404
|
|
394
405
|
|
395
406
|
def _generate_parameter(
|
396
|
-
location:
|
407
|
+
location: ParameterLocation,
|
397
408
|
explicit: NotSet | dict[str, Any],
|
398
409
|
draw: Callable,
|
399
410
|
operation: APIOperation,
|
@@ -401,7 +412,7 @@ def _generate_parameter(
|
|
401
412
|
hooks: HookDispatcher | None,
|
402
413
|
) -> Any:
|
403
414
|
# Schemathesis does not generate anything but `body` for GraphQL, hence use `None`
|
404
|
-
container =
|
415
|
+
container = location.container_name
|
405
416
|
if isinstance(explicit, NotSet):
|
406
417
|
strategy = apply_to_all_dispatchers(operation, context, hooks, st.none(), container)
|
407
418
|
else:
|
@@ -1,9 +1,8 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import time
|
4
|
-
from contextlib import suppress
|
5
4
|
from dataclasses import dataclass
|
6
|
-
from typing import Any, Callable,
|
5
|
+
from typing import Any, Callable, Iterable, Optional, Union, cast
|
7
6
|
from urllib.parse import quote_plus
|
8
7
|
|
9
8
|
import jsonschema.protocols
|
@@ -16,12 +15,13 @@ from schemathesis.config import GenerationConfig
|
|
16
15
|
from schemathesis.core import NOT_SET, media_types
|
17
16
|
from schemathesis.core.control import SkipTest
|
18
17
|
from schemathesis.core.errors import SERIALIZERS_SUGGESTION_MESSAGE, SerializationNotPossible
|
18
|
+
from schemathesis.core.jsonschema.types import JsonSchema
|
19
|
+
from schemathesis.core.parameters import ParameterLocation
|
19
20
|
from schemathesis.core.transforms import deepclone
|
20
21
|
from schemathesis.core.transport import prepare_urlencoded
|
21
22
|
from schemathesis.generation.meta import (
|
22
23
|
CaseMetadata,
|
23
24
|
ComponentInfo,
|
24
|
-
ComponentKind,
|
25
25
|
ExamplesPhaseData,
|
26
26
|
FuzzingPhaseData,
|
27
27
|
GenerationInfo,
|
@@ -31,11 +31,11 @@ from schemathesis.generation.meta import (
|
|
31
31
|
)
|
32
32
|
from schemathesis.openapi.generation.filters import is_valid_header, is_valid_path, is_valid_query, is_valid_urlencoded
|
33
33
|
from schemathesis.schemas import APIOperation
|
34
|
+
from schemathesis.specs.openapi.adapter.parameters import OpenApiBody, OpenApiParameterSet
|
34
35
|
|
35
36
|
from ... import auths
|
36
37
|
from ...generation import GenerationMode
|
37
38
|
from ...hooks import HookContext, HookDispatcher, apply_to_all_dispatchers
|
38
|
-
from .constants import LOCATION_TO_CONTAINER
|
39
39
|
from .formats import (
|
40
40
|
DEFAULT_HEADER_EXCLUDE_CHARACTERS,
|
41
41
|
HEADER_FORMAT,
|
@@ -46,12 +46,11 @@ from .formats import (
|
|
46
46
|
from .media_types import MEDIA_TYPES
|
47
47
|
from .negative import negative_schema
|
48
48
|
from .negative.utils import can_negate
|
49
|
-
from .parameters import OpenAPIBody, OpenAPIParameter, parameters_to_json_schema
|
50
|
-
from .utils import is_header_location
|
51
49
|
|
52
50
|
SLASH = "/"
|
53
51
|
StrategyFactory = Callable[
|
54
|
-
[
|
52
|
+
[JsonSchema, str, ParameterLocation, Optional[str], GenerationConfig, type[jsonschema.protocols.Validator]],
|
53
|
+
st.SearchStrategy,
|
55
54
|
]
|
56
55
|
|
57
56
|
|
@@ -91,18 +90,24 @@ def openapi_cases(
|
|
91
90
|
ctx = HookContext(operation=operation)
|
92
91
|
|
93
92
|
path_parameters_ = generate_parameter(
|
94
|
-
|
93
|
+
ParameterLocation.PATH, path_parameters, operation, draw, ctx, hooks, generation_mode, generation_config
|
94
|
+
)
|
95
|
+
headers_ = generate_parameter(
|
96
|
+
ParameterLocation.HEADER, headers, operation, draw, ctx, hooks, generation_mode, generation_config
|
97
|
+
)
|
98
|
+
cookies_ = generate_parameter(
|
99
|
+
ParameterLocation.COOKIE, cookies, operation, draw, ctx, hooks, generation_mode, generation_config
|
100
|
+
)
|
101
|
+
query_ = generate_parameter(
|
102
|
+
ParameterLocation.QUERY, query, operation, draw, ctx, hooks, generation_mode, generation_config
|
95
103
|
)
|
96
|
-
headers_ = generate_parameter("header", headers, operation, draw, ctx, hooks, generation_mode, generation_config)
|
97
|
-
cookies_ = generate_parameter("cookie", cookies, operation, draw, ctx, hooks, generation_mode, generation_config)
|
98
|
-
query_ = generate_parameter("query", query, operation, draw, ctx, hooks, generation_mode, generation_config)
|
99
104
|
|
100
105
|
if body is NOT_SET:
|
101
106
|
if operation.body:
|
102
107
|
body_generator = generation_mode
|
103
108
|
if generation_mode.is_negative:
|
104
109
|
# Consider only schemas that are possible to negate
|
105
|
-
candidates = [item for item in operation.body.items if can_negate(item.
|
110
|
+
candidates = [item for item in operation.body.items if can_negate(item.optimized_schema)]
|
106
111
|
# Not possible to negate body, fallback to positive data generation
|
107
112
|
if not candidates:
|
108
113
|
candidates = operation.body.items
|
@@ -112,7 +117,7 @@ def openapi_cases(
|
|
112
117
|
candidates = operation.body.items
|
113
118
|
parameter = draw(st.sampled_from(candidates))
|
114
119
|
strategy = _get_body_strategy(parameter, strategy_factory, operation, generation_config)
|
115
|
-
strategy = apply_hooks(operation, ctx, hooks, strategy,
|
120
|
+
strategy = apply_hooks(operation, ctx, hooks, strategy, ParameterLocation.BODY)
|
116
121
|
# Parameter may have a wildcard media type. In this case, choose any supported one
|
117
122
|
possible_media_types = sorted(
|
118
123
|
operation.schema.transport.get_matching_media_types(parameter.media_type), key=lambda x: x[0]
|
@@ -177,11 +182,11 @@ def openapi_cases(
|
|
177
182
|
components={
|
178
183
|
kind: ComponentInfo(mode=value.generator)
|
179
184
|
for kind, value in [
|
180
|
-
(
|
181
|
-
(
|
182
|
-
(
|
183
|
-
(
|
184
|
-
(
|
185
|
+
(ParameterLocation.QUERY, query_),
|
186
|
+
(ParameterLocation.PATH, path_parameters_),
|
187
|
+
(ParameterLocation.HEADER, headers_),
|
188
|
+
(ParameterLocation.COOKIE, cookies_),
|
189
|
+
(ParameterLocation.BODY, body_),
|
185
190
|
]
|
186
191
|
if value.generator is not None
|
187
192
|
},
|
@@ -196,7 +201,7 @@ def openapi_cases(
|
|
196
201
|
|
197
202
|
|
198
203
|
def _get_body_strategy(
|
199
|
-
parameter:
|
204
|
+
parameter: OpenApiBody,
|
200
205
|
strategy_factory: StrategyFactory,
|
201
206
|
operation: APIOperation,
|
202
207
|
generation_config: GenerationConfig,
|
@@ -205,11 +210,15 @@ def _get_body_strategy(
|
|
205
210
|
|
206
211
|
if parameter.media_type in MEDIA_TYPES:
|
207
212
|
return MEDIA_TYPES[parameter.media_type]
|
208
|
-
schema = parameter.
|
209
|
-
schema = operation.schema.prepare_schema(schema)
|
213
|
+
schema = parameter.optimized_schema
|
210
214
|
assert isinstance(operation.schema, BaseOpenAPISchema)
|
211
215
|
strategy = strategy_factory(
|
212
|
-
schema,
|
216
|
+
schema,
|
217
|
+
operation.label,
|
218
|
+
ParameterLocation.BODY,
|
219
|
+
parameter.media_type,
|
220
|
+
generation_config,
|
221
|
+
operation.schema.adapter.jsonschema_validator_cls,
|
213
222
|
)
|
214
223
|
if not parameter.is_required:
|
215
224
|
strategy |= st.just(NOT_SET)
|
@@ -218,7 +227,7 @@ def _get_body_strategy(
|
|
218
227
|
|
219
228
|
def get_parameters_value(
|
220
229
|
value: dict[str, Any] | None,
|
221
|
-
location:
|
230
|
+
location: ParameterLocation,
|
222
231
|
draw: Callable,
|
223
232
|
operation: APIOperation,
|
224
233
|
ctx: HookContext,
|
@@ -239,7 +248,7 @@ def get_parameters_value(
|
|
239
248
|
strategy = apply_hooks(operation, ctx, hooks, strategy, location)
|
240
249
|
new = draw(strategy)
|
241
250
|
if new is not None:
|
242
|
-
copied =
|
251
|
+
copied = dict(value)
|
243
252
|
copied.update(new)
|
244
253
|
return copied
|
245
254
|
return value
|
@@ -267,7 +276,7 @@ def any_negated_values(values: list[ValueContainer]) -> bool:
|
|
267
276
|
|
268
277
|
|
269
278
|
def generate_parameter(
|
270
|
-
location:
|
279
|
+
location: ParameterLocation,
|
271
280
|
explicit: dict[str, Any] | None,
|
272
281
|
operation: APIOperation,
|
273
282
|
draw: Callable,
|
@@ -281,8 +290,8 @@ def generate_parameter(
|
|
281
290
|
Fallback to positive data generator if parameter can not be negated.
|
282
291
|
"""
|
283
292
|
if generator.is_negative and (
|
284
|
-
(location ==
|
285
|
-
or (
|
293
|
+
(location == ParameterLocation.PATH and not can_negate_path_parameters(operation))
|
294
|
+
or (location.is_in_header and not can_negate_headers(operation, location))
|
286
295
|
):
|
287
296
|
# If we can't negate any parameter, generate positive ones
|
288
297
|
# If nothing else will be negated, then skip the test completely
|
@@ -295,7 +304,7 @@ def generate_parameter(
|
|
295
304
|
if value == explicit:
|
296
305
|
# When we pass `explicit`, then its parts are excluded from generation of the final value
|
297
306
|
# If the final value is the same, then other parameters were generated at all
|
298
|
-
if value is not None and location ==
|
307
|
+
if value is not None and location == ParameterLocation.PATH:
|
299
308
|
value = quote_all(value)
|
300
309
|
used_generator = None
|
301
310
|
return ValueContainer(value=value, location=location, generator=used_generator)
|
@@ -303,51 +312,53 @@ def generate_parameter(
|
|
303
312
|
|
304
313
|
def can_negate_path_parameters(operation: APIOperation) -> bool:
|
305
314
|
"""Check if any path parameter can be negated."""
|
306
|
-
schema = parameters_to_json_schema(operation, operation.path_parameters)
|
307
315
|
# No path parameters to negate
|
308
|
-
parameters = schema["properties"]
|
316
|
+
parameters = cast(OpenApiParameterSet, operation.path_parameters).schema["properties"]
|
309
317
|
if not parameters:
|
310
318
|
return True
|
311
319
|
return any(can_negate(parameter) for parameter in parameters.values())
|
312
320
|
|
313
321
|
|
314
|
-
def can_negate_headers(operation: APIOperation, location:
|
322
|
+
def can_negate_headers(operation: APIOperation, location: ParameterLocation) -> bool:
|
315
323
|
"""Check if any header can be negated."""
|
316
|
-
|
317
|
-
schema = parameters_to_json_schema(operation, parameters)
|
324
|
+
container = getattr(operation, location.container_name)
|
318
325
|
# No headers to negate
|
319
|
-
headers = schema["properties"]
|
326
|
+
headers = container.schema["properties"]
|
320
327
|
if not headers:
|
321
328
|
return True
|
322
329
|
return any(header != {"type": "string"} for header in headers.values())
|
323
330
|
|
324
331
|
|
325
|
-
def get_schema_for_location(
|
326
|
-
|
327
|
-
|
328
|
-
schema = parameters_to_json_schema(operation, parameters)
|
329
|
-
if location == "path":
|
332
|
+
def get_schema_for_location(location: ParameterLocation, parameters: OpenApiParameterSet) -> dict[str, Any]:
|
333
|
+
schema = deepclone(parameters.schema)
|
334
|
+
if location == ParameterLocation.PATH:
|
330
335
|
schema["required"] = list(schema["properties"])
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
336
|
+
# Shallow copy properties dict itself and each modified property
|
337
|
+
properties = schema.get("properties", {})
|
338
|
+
if properties:
|
339
|
+
schema["properties"] = {
|
340
|
+
key: {**value, "minLength": value.get("minLength", 1)}
|
341
|
+
if value.get("type") == "string" and "minLength" not in value
|
342
|
+
else value
|
343
|
+
for key, value in properties.items()
|
344
|
+
}
|
345
|
+
return schema
|
335
346
|
|
336
347
|
|
337
348
|
def get_parameters_strategy(
|
338
349
|
operation: APIOperation,
|
339
350
|
strategy_factory: StrategyFactory,
|
340
|
-
location:
|
351
|
+
location: ParameterLocation,
|
341
352
|
generation_config: GenerationConfig,
|
342
353
|
exclude: Iterable[str] = (),
|
343
354
|
) -> st.SearchStrategy:
|
344
355
|
"""Create a new strategy for the case's component from the API operation parameters."""
|
345
356
|
from schemathesis.specs.openapi.schemas import BaseOpenAPISchema
|
346
357
|
|
347
|
-
|
348
|
-
if
|
349
|
-
schema = get_schema_for_location(
|
350
|
-
if location ==
|
358
|
+
container = getattr(operation, location.container_name)
|
359
|
+
if container:
|
360
|
+
schema = get_schema_for_location(location, container)
|
361
|
+
if location == ParameterLocation.HEADER and exclude:
|
351
362
|
# Remove excluded headers case-insensitively
|
352
363
|
exclude_lower = {name.lower() for name in exclude}
|
353
364
|
schema["properties"] = {
|
@@ -357,37 +368,42 @@ def get_parameters_strategy(
|
|
357
368
|
schema["required"] = [key for key in schema["required"] if key.lower() not in exclude_lower]
|
358
369
|
elif exclude:
|
359
370
|
# Non-header locations: remove by exact name
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
371
|
+
schema = dict(schema)
|
372
|
+
schema["properties"] = {key: value for key, value in schema["properties"].items() if key not in exclude}
|
373
|
+
if "required" in schema:
|
374
|
+
schema["required"] = [key for key in schema["required"] if key not in exclude]
|
364
375
|
if not schema["properties"] and strategy_factory is make_negative_strategy:
|
365
376
|
# Nothing to negate - all properties were excluded
|
366
377
|
strategy = st.none()
|
367
378
|
else:
|
368
379
|
assert isinstance(operation.schema, BaseOpenAPISchema)
|
369
380
|
strategy = strategy_factory(
|
370
|
-
schema,
|
381
|
+
schema,
|
382
|
+
operation.label,
|
383
|
+
location,
|
384
|
+
None,
|
385
|
+
generation_config,
|
386
|
+
operation.schema.adapter.jsonschema_validator_cls,
|
371
387
|
)
|
372
388
|
serialize = operation.get_parameter_serializer(location)
|
373
389
|
if serialize is not None:
|
374
390
|
strategy = strategy.map(serialize)
|
375
391
|
filter_func = {
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
392
|
+
ParameterLocation.PATH: is_valid_path,
|
393
|
+
ParameterLocation.HEADER: is_valid_header,
|
394
|
+
ParameterLocation.COOKIE: is_valid_header,
|
395
|
+
ParameterLocation.QUERY: is_valid_query,
|
380
396
|
}[location]
|
381
397
|
# Headers with special format do not need filtration
|
382
|
-
if not (
|
398
|
+
if not (location.is_in_header and _can_skip_header_filter(schema)):
|
383
399
|
strategy = strategy.filter(filter_func)
|
384
400
|
# Path & query parameters will be cast to string anyway, but having their JSON equivalents for
|
385
401
|
# `True` / `False` / `None` improves chances of them passing validation in apps
|
386
402
|
# that expect boolean / null types
|
387
403
|
# and not aware of Python-specific representation of those types
|
388
|
-
if location ==
|
404
|
+
if location == ParameterLocation.PATH:
|
389
405
|
strategy = strategy.map(quote_all).map(jsonify_python_specific_types)
|
390
|
-
elif location ==
|
406
|
+
elif location == ParameterLocation.QUERY:
|
391
407
|
strategy = strategy.map(jsonify_python_specific_types)
|
392
408
|
return strategy
|
393
409
|
# No parameters defined for this location
|
@@ -431,15 +447,15 @@ def _build_custom_formats(generation_config: GenerationConfig) -> dict[str, st.S
|
|
431
447
|
|
432
448
|
|
433
449
|
def make_positive_strategy(
|
434
|
-
schema:
|
450
|
+
schema: JsonSchema,
|
435
451
|
operation_name: str,
|
436
|
-
location:
|
452
|
+
location: ParameterLocation,
|
437
453
|
media_type: str | None,
|
438
454
|
generation_config: GenerationConfig,
|
439
455
|
validator_cls: type[jsonschema.protocols.Validator],
|
440
456
|
) -> st.SearchStrategy:
|
441
457
|
"""Strategy for generating values that fit the schema."""
|
442
|
-
if
|
458
|
+
if location.is_in_header and isinstance(schema, dict):
|
443
459
|
# We try to enforce the right header values via "format"
|
444
460
|
# This way, only allowed values will be used during data generation, which reduces the amount of filtering later
|
445
461
|
# If a property schema contains `pattern` it leads to heavy filtering and worse performance - therefore, skip it
|
@@ -461,9 +477,9 @@ def _can_skip_header_filter(schema: dict[str, Any]) -> bool:
|
|
461
477
|
|
462
478
|
|
463
479
|
def make_negative_strategy(
|
464
|
-
schema:
|
480
|
+
schema: JsonSchema,
|
465
481
|
operation_name: str,
|
466
|
-
location:
|
482
|
+
location: ParameterLocation,
|
467
483
|
media_type: str | None,
|
468
484
|
generation_config: GenerationConfig,
|
469
485
|
validator_cls: type[jsonschema.protocols.Validator],
|
@@ -510,8 +526,7 @@ def apply_hooks(
|
|
510
526
|
ctx: HookContext,
|
511
527
|
hooks: HookDispatcher | None,
|
512
528
|
strategy: st.SearchStrategy,
|
513
|
-
location:
|
529
|
+
location: ParameterLocation,
|
514
530
|
) -> st.SearchStrategy:
|
515
531
|
"""Apply all hooks related to the given location."""
|
516
|
-
|
517
|
-
return apply_to_all_dispatchers(operation, ctx, hooks, strategy, container)
|
532
|
+
return apply_to_all_dispatchers(operation, ctx, hooks, strategy, location.container_name)
|