schemathesis 4.0.0a12__py3-none-any.whl → 4.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemathesis/__init__.py +9 -4
- schemathesis/auths.py +20 -30
- schemathesis/checks.py +5 -0
- schemathesis/cli/commands/run/__init__.py +9 -6
- schemathesis/cli/commands/run/handlers/output.py +13 -0
- schemathesis/cli/constants.py +1 -1
- schemathesis/config/_operations.py +16 -21
- schemathesis/config/_projects.py +5 -1
- schemathesis/core/errors.py +10 -17
- schemathesis/core/transport.py +81 -1
- schemathesis/engine/errors.py +1 -1
- schemathesis/generation/case.py +152 -28
- schemathesis/generation/hypothesis/builder.py +12 -12
- schemathesis/generation/overrides.py +11 -27
- schemathesis/generation/stateful/__init__.py +13 -0
- schemathesis/generation/stateful/state_machine.py +31 -108
- schemathesis/graphql/loaders.py +14 -4
- schemathesis/hooks.py +1 -4
- schemathesis/openapi/checks.py +82 -20
- schemathesis/openapi/generation/filters.py +9 -2
- schemathesis/openapi/loaders.py +14 -4
- schemathesis/pytest/lazy.py +4 -31
- schemathesis/pytest/plugin.py +21 -11
- schemathesis/schemas.py +153 -89
- schemathesis/specs/graphql/schemas.py +6 -6
- schemathesis/specs/openapi/_hypothesis.py +39 -14
- schemathesis/specs/openapi/checks.py +95 -34
- schemathesis/specs/openapi/expressions/nodes.py +1 -1
- schemathesis/specs/openapi/negative/__init__.py +5 -3
- schemathesis/specs/openapi/negative/mutations.py +2 -2
- schemathesis/specs/openapi/parameters.py +0 -3
- schemathesis/specs/openapi/schemas.py +6 -91
- schemathesis/specs/openapi/stateful/links.py +1 -63
- schemathesis/transport/requests.py +12 -1
- schemathesis/transport/serialization.py +0 -4
- schemathesis/transport/wsgi.py +7 -0
- {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/METADATA +8 -10
- {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/RECORD +41 -41
- {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/licenses/LICENSE +0 -0
@@ -21,10 +21,12 @@ from schemathesis.openapi.checks import (
|
|
21
21
|
JsonSchemaError,
|
22
22
|
MalformedMediaType,
|
23
23
|
MissingContentType,
|
24
|
+
MissingHeaderNotRejected,
|
24
25
|
MissingHeaders,
|
25
26
|
RejectedPositiveData,
|
26
27
|
UndefinedContentType,
|
27
28
|
UndefinedStatusCode,
|
29
|
+
UnsupportedMethodResponse,
|
28
30
|
UseAfterFree,
|
29
31
|
)
|
30
32
|
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
@@ -33,9 +35,7 @@ from schemathesis.transport.prepare import prepare_path
|
|
33
35
|
from .utils import expand_status_code, expand_status_codes
|
34
36
|
|
35
37
|
if TYPE_CHECKING:
|
36
|
-
from
|
37
|
-
|
38
|
-
from ...schemas import APIOperation
|
38
|
+
from schemathesis.schemas import APIOperation
|
39
39
|
|
40
40
|
|
41
41
|
def is_unexpected_http_status_case(case: Case) -> bool:
|
@@ -289,8 +289,14 @@ def missing_required_header(ctx: CheckContext, response: Response, case: Case) -
|
|
289
289
|
config = ctx.config.missing_required_header
|
290
290
|
expected_statuses = expand_status_codes(config.expected_statuses or [])
|
291
291
|
if response.status_code not in expected_statuses:
|
292
|
-
allowed =
|
293
|
-
raise
|
292
|
+
allowed = ", ".join(map(str, expected_statuses))
|
293
|
+
raise MissingHeaderNotRejected(
|
294
|
+
operation=f"{case.method} {case.path}",
|
295
|
+
header_name=data.parameter,
|
296
|
+
status_code=response.status_code,
|
297
|
+
expected_statuses=list(expected_statuses),
|
298
|
+
message=f"Missing header not rejected (got {response.status_code}, expected {allowed})",
|
299
|
+
)
|
294
300
|
return None
|
295
301
|
|
296
302
|
|
@@ -302,13 +308,24 @@ def unsupported_method(ctx: CheckContext, response: Response, case: Case) -> boo
|
|
302
308
|
data = meta.phase.data
|
303
309
|
if data.description and data.description.startswith("Unspecified HTTP method:"):
|
304
310
|
if response.status_code != 405:
|
305
|
-
raise
|
306
|
-
|
311
|
+
raise UnsupportedMethodResponse(
|
312
|
+
operation=case.operation.label,
|
313
|
+
method=cast(str, response.request.method),
|
314
|
+
status_code=response.status_code,
|
315
|
+
failure_reason="wrong_status",
|
316
|
+
message=f"Wrong status for unsupported method {response.request.method} (got {response.status_code}, expected 405)",
|
307
317
|
)
|
308
318
|
|
309
319
|
allow_header = response.headers.get("allow")
|
310
320
|
if not allow_header:
|
311
|
-
raise
|
321
|
+
raise UnsupportedMethodResponse(
|
322
|
+
operation=case.operation.label,
|
323
|
+
method=cast(str, response.request.method),
|
324
|
+
status_code=response.status_code,
|
325
|
+
allow_header_present=False,
|
326
|
+
failure_reason="missing_allow_header",
|
327
|
+
message=f"Missing Allow header for unsupported method {response.request.method}",
|
328
|
+
)
|
312
329
|
return None
|
313
330
|
|
314
331
|
|
@@ -458,6 +475,12 @@ def ensure_resource_availability(ctx: CheckContext, response: Response, case: Ca
|
|
458
475
|
)
|
459
476
|
|
460
477
|
|
478
|
+
class AuthScenario(str, enum.Enum):
|
479
|
+
NO_AUTH = "no_auth"
|
480
|
+
INVALID_AUTH = "invalid_auth"
|
481
|
+
GENERATED_AUTH = "generated_auth"
|
482
|
+
|
483
|
+
|
461
484
|
class AuthKind(str, enum.Enum):
|
462
485
|
EXPLICIT = "explicit"
|
463
486
|
GENERATED = "generated"
|
@@ -473,23 +496,28 @@ def ignored_auth(ctx: CheckContext, response: Response, case: Case) -> bool | No
|
|
473
496
|
security_parameters = _get_security_parameters(case.operation)
|
474
497
|
# Authentication is required for this API operation and response is successful
|
475
498
|
if security_parameters and 200 <= response.status_code < 300:
|
476
|
-
auth = _contains_auth(ctx, case, response
|
499
|
+
auth = _contains_auth(ctx, case, response, security_parameters)
|
477
500
|
if auth == AuthKind.EXPLICIT:
|
478
501
|
# Auth is explicitly set, it is expected to be valid
|
479
502
|
# Check if invalid auth will give an error
|
480
503
|
no_auth_case = remove_auth(case, security_parameters)
|
481
504
|
kwargs = ctx._transport_kwargs or {}
|
482
505
|
kwargs.copy()
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
506
|
+
for location, container_name in (
|
507
|
+
("header", "headers"),
|
508
|
+
("cookie", "cookies"),
|
509
|
+
("query", "query"),
|
510
|
+
):
|
511
|
+
if container_name in kwargs:
|
512
|
+
container = kwargs[container_name].copy()
|
513
|
+
_remove_auth_from_container(container, security_parameters, location=location)
|
514
|
+
kwargs[container_name] = container
|
487
515
|
kwargs.pop("session", None)
|
488
516
|
ctx._record_case(parent_id=case.id, case=no_auth_case)
|
489
517
|
no_auth_response = case.operation.schema.transport.send(no_auth_case, **kwargs)
|
490
518
|
ctx._record_response(case_id=no_auth_case.id, response=no_auth_response)
|
491
519
|
if no_auth_response.status_code != 401:
|
492
|
-
_raise_no_auth_error(no_auth_response, no_auth_case,
|
520
|
+
_raise_no_auth_error(no_auth_response, no_auth_case, AuthScenario.NO_AUTH)
|
493
521
|
# Try to set invalid auth and check if it succeeds
|
494
522
|
for parameter in security_parameters:
|
495
523
|
invalid_auth_case = remove_auth(case, security_parameters)
|
@@ -498,22 +526,38 @@ def ignored_auth(ctx: CheckContext, response: Response, case: Case) -> bool | No
|
|
498
526
|
invalid_auth_response = case.operation.schema.transport.send(invalid_auth_case, **kwargs)
|
499
527
|
ctx._record_response(case_id=invalid_auth_case.id, response=invalid_auth_response)
|
500
528
|
if invalid_auth_response.status_code != 401:
|
501
|
-
_raise_no_auth_error(invalid_auth_response, invalid_auth_case,
|
529
|
+
_raise_no_auth_error(invalid_auth_response, invalid_auth_case, AuthScenario.INVALID_AUTH)
|
502
530
|
elif auth == AuthKind.GENERATED:
|
503
531
|
# If this auth is generated which means it is likely invalid, then
|
504
532
|
# this request should have been an error
|
505
|
-
_raise_no_auth_error(response, case,
|
533
|
+
_raise_no_auth_error(response, case, AuthScenario.GENERATED_AUTH)
|
506
534
|
else:
|
507
535
|
# Successful response when there is no auth
|
508
|
-
_raise_no_auth_error(response, case,
|
536
|
+
_raise_no_auth_error(response, case, AuthScenario.NO_AUTH)
|
509
537
|
return None
|
510
538
|
|
511
539
|
|
512
|
-
def _raise_no_auth_error(response: Response, case: Case,
|
540
|
+
def _raise_no_auth_error(response: Response, case: Case, auth: AuthScenario) -> NoReturn:
|
513
541
|
reason = http.client.responses.get(response.status_code, "Unknown")
|
542
|
+
|
543
|
+
if auth == AuthScenario.NO_AUTH:
|
544
|
+
title = "API accepts requests without authentication"
|
545
|
+
detail = None
|
546
|
+
elif auth == AuthScenario.INVALID_AUTH:
|
547
|
+
title = "API accepts invalid authentication"
|
548
|
+
detail = "invalid credentials provided"
|
549
|
+
else:
|
550
|
+
title = "API accepts invalid authentication"
|
551
|
+
detail = "generated auth likely invalid"
|
552
|
+
|
553
|
+
message = f"Expected 401, got `{response.status_code} {reason}` for `{case.operation.label}`"
|
554
|
+
if detail is not None:
|
555
|
+
message = f"{message} ({detail})"
|
556
|
+
|
514
557
|
raise IgnoredAuth(
|
515
558
|
operation=case.operation.label,
|
516
|
-
message=
|
559
|
+
message=message,
|
560
|
+
title=title,
|
517
561
|
case_id=case.id,
|
518
562
|
)
|
519
563
|
|
@@ -534,7 +578,7 @@ def _get_security_parameters(operation: APIOperation) -> list[SecurityParameter]
|
|
534
578
|
|
535
579
|
|
536
580
|
def _contains_auth(
|
537
|
-
ctx: CheckContext, case: Case,
|
581
|
+
ctx: CheckContext, case: Case, response: Response, security_parameters: list[SecurityParameter]
|
538
582
|
) -> AuthKind | None:
|
539
583
|
"""Whether a request has authentication declared in the schema."""
|
540
584
|
from requests.cookies import RequestsCookieJar
|
@@ -542,6 +586,7 @@ def _contains_auth(
|
|
542
586
|
# If auth comes from explicit `auth` option or a custom auth, it is always explicit
|
543
587
|
if ctx._auth is not None or case._has_explicit_auth:
|
544
588
|
return AuthKind.EXPLICIT
|
589
|
+
request = response.request
|
545
590
|
parsed = urlparse(request.url)
|
546
591
|
query = parse_qs(parsed.query) # type: ignore
|
547
592
|
# Load the `Cookie` header separately, because it is possible that `request._cookies` and the header are out of sync
|
@@ -563,19 +608,35 @@ def _contains_auth(
|
|
563
608
|
for parameter in security_parameters:
|
564
609
|
name = parameter["name"]
|
565
610
|
if has_header(parameter):
|
566
|
-
if (
|
611
|
+
if (
|
612
|
+
# Explicit CLI headers
|
613
|
+
(ctx._headers is not None and name in ctx._headers)
|
614
|
+
# Other kinds of overrides
|
615
|
+
or (ctx._override and name in ctx._override.headers)
|
616
|
+
or (response._override and name in response._override.headers)
|
617
|
+
):
|
567
618
|
return AuthKind.EXPLICIT
|
568
619
|
return AuthKind.GENERATED
|
569
620
|
if has_cookie(parameter):
|
570
|
-
|
571
|
-
|
572
|
-
if
|
573
|
-
|
574
|
-
|
621
|
+
for headers in [
|
622
|
+
ctx._headers,
|
623
|
+
(ctx._override.headers if ctx._override else None),
|
624
|
+
(response._override.headers if response._override else None),
|
625
|
+
]:
|
626
|
+
if headers is not None and "Cookie" in headers:
|
627
|
+
jar = cast(RequestsCookieJar, headers["Cookie"])
|
628
|
+
if name in jar:
|
629
|
+
return AuthKind.EXPLICIT
|
630
|
+
|
631
|
+
if (ctx._override and name in ctx._override.cookies) or (
|
632
|
+
response._override and name in response._override.cookies
|
633
|
+
):
|
575
634
|
return AuthKind.EXPLICIT
|
576
635
|
return AuthKind.GENERATED
|
577
636
|
if has_query(parameter):
|
578
|
-
if ctx._override and name in ctx._override.query
|
637
|
+
if (ctx._override and name in ctx._override.query) or (
|
638
|
+
response._override and name in response._override.query
|
639
|
+
):
|
579
640
|
return AuthKind.EXPLICIT
|
580
641
|
return AuthKind.GENERATED
|
581
642
|
|
@@ -587,9 +648,9 @@ def remove_auth(case: Case, security_parameters: list[SecurityParameter]) -> Cas
|
|
587
648
|
|
588
649
|
It mutates `case` in place.
|
589
650
|
"""
|
590
|
-
headers = case.headers.copy()
|
591
|
-
query = case.query.copy()
|
592
|
-
cookies = case.cookies.copy()
|
651
|
+
headers = case.headers.copy()
|
652
|
+
query = case.query.copy()
|
653
|
+
cookies = case.cookies.copy()
|
593
654
|
for parameter in security_parameters:
|
594
655
|
name = parameter["name"]
|
595
656
|
if parameter["in"] == "header" and headers:
|
@@ -602,7 +663,7 @@ def remove_auth(case: Case, security_parameters: list[SecurityParameter]) -> Cas
|
|
602
663
|
operation=case.operation,
|
603
664
|
method=case.method,
|
604
665
|
path=case.path,
|
605
|
-
path_parameters=case.path_parameters.copy()
|
666
|
+
path_parameters=case.path_parameters.copy(),
|
606
667
|
headers=headers,
|
607
668
|
cookies=cookies,
|
608
669
|
query=query,
|
@@ -612,11 +673,11 @@ def remove_auth(case: Case, security_parameters: list[SecurityParameter]) -> Cas
|
|
612
673
|
)
|
613
674
|
|
614
675
|
|
615
|
-
def
|
676
|
+
def _remove_auth_from_container(container: dict, security_parameters: list[SecurityParameter], location: str) -> None:
|
616
677
|
for parameter in security_parameters:
|
617
678
|
name = parameter["name"]
|
618
|
-
if parameter["in"] ==
|
619
|
-
|
679
|
+
if parameter["in"] == location:
|
680
|
+
container.pop(name, None)
|
620
681
|
|
621
682
|
|
622
683
|
def _set_auth_for_case(case: Case, parameter: SecurityParameter) -> None:
|
@@ -87,7 +87,7 @@ class NonBodyRequest(Node):
|
|
87
87
|
extractor: Extractor | None = None
|
88
88
|
|
89
89
|
def evaluate(self, output: StepOutput) -> str | Unresolvable:
|
90
|
-
container
|
90
|
+
container = {
|
91
91
|
"query": output.case.query,
|
92
92
|
"path": output.case.path_parameters,
|
93
93
|
"header": output.case.headers,
|
@@ -28,16 +28,17 @@ class CacheKey:
|
|
28
28
|
operation_name: str
|
29
29
|
location: str
|
30
30
|
schema: Schema
|
31
|
+
validator_cls: type[jsonschema.Validator]
|
31
32
|
|
32
33
|
def __hash__(self) -> int:
|
33
34
|
return hash((self.operation_name, self.location))
|
34
35
|
|
35
36
|
|
36
37
|
@lru_cache
|
37
|
-
def get_validator(cache_key: CacheKey) -> jsonschema.
|
38
|
+
def get_validator(cache_key: CacheKey) -> jsonschema.Validator:
|
38
39
|
"""Get JSON Schema validator for the given schema."""
|
39
40
|
# Each operation / location combo has only a single schema, therefore could be cached
|
40
|
-
return
|
41
|
+
return cache_key.validator_cls(cache_key.schema)
|
41
42
|
|
42
43
|
|
43
44
|
@lru_cache
|
@@ -63,6 +64,7 @@ def negative_schema(
|
|
63
64
|
generation_config: GenerationConfig,
|
64
65
|
*,
|
65
66
|
custom_formats: dict[str, st.SearchStrategy[str]],
|
67
|
+
validator_cls: type[jsonschema.Validator],
|
66
68
|
) -> st.SearchStrategy:
|
67
69
|
"""A strategy for instances that DO NOT match the input schema.
|
68
70
|
|
@@ -70,7 +72,7 @@ def negative_schema(
|
|
70
72
|
"""
|
71
73
|
# The mutated schema is passed to `from_schema` and guarded against producing instances valid against
|
72
74
|
# the original schema.
|
73
|
-
cache_key = CacheKey(operation_name, location, schema)
|
75
|
+
cache_key = CacheKey(operation_name, location, schema, validator_cls)
|
74
76
|
validator = get_validator(cache_key)
|
75
77
|
keywords, non_keywords = split_schema(cache_key)
|
76
78
|
|
@@ -402,8 +402,8 @@ def negate_constraints(context: MutationContext, draw: Draw, schema: Schema) ->
|
|
402
402
|
if key in DEPENDENCIES:
|
403
403
|
# If this keyword has a dependency, then it should be also negated
|
404
404
|
dependency = DEPENDENCIES[key]
|
405
|
-
if dependency not in negated:
|
406
|
-
negated[dependency] = copied[dependency]
|
405
|
+
if dependency not in negated and dependency in copied:
|
406
|
+
negated[dependency] = copied[dependency]
|
407
407
|
else:
|
408
408
|
schema[key] = value
|
409
409
|
if is_negated:
|
@@ -314,9 +314,6 @@ def parameters_to_json_schema(
|
|
314
314
|
) -> dict[str, Any]:
|
315
315
|
"""Create an "object" JSON schema from a list of Open API parameters.
|
316
316
|
|
317
|
-
:param List[OpenAPIParameter] parameters: A list of Open API parameters related to the same location. All of
|
318
|
-
them are expected to have the same "in" value.
|
319
|
-
|
320
317
|
For each input parameter, there will be a property in the output schema.
|
321
318
|
|
322
319
|
This:
|
@@ -39,7 +39,6 @@ from schemathesis.core.transport import Response
|
|
39
39
|
from schemathesis.core.validation import INVALID_HEADER_RE
|
40
40
|
from schemathesis.generation.case import Case
|
41
41
|
from schemathesis.generation.meta import CaseMetadata
|
42
|
-
from schemathesis.generation.overrides import Override, OverrideMark, check_no_override_mark
|
43
42
|
from schemathesis.openapi.checks import JsonSchemaError, MissingContentType
|
44
43
|
from schemathesis.specs.openapi.stateful import links
|
45
44
|
|
@@ -259,26 +258,6 @@ class BaseOpenAPISchema(BaseSchema):
|
|
259
258
|
# Ignore errors
|
260
259
|
continue
|
261
260
|
|
262
|
-
def override(
|
263
|
-
self,
|
264
|
-
*,
|
265
|
-
query: dict[str, str] | None = None,
|
266
|
-
headers: dict[str, str] | None = None,
|
267
|
-
cookies: dict[str, str] | None = None,
|
268
|
-
path_parameters: dict[str, str] | None = None,
|
269
|
-
) -> Callable[[Callable], Callable]:
|
270
|
-
"""Override Open API parameters with fixed values."""
|
271
|
-
|
272
|
-
def _add_override(test: Callable) -> Callable:
|
273
|
-
check_no_override_mark(test)
|
274
|
-
override = Override(
|
275
|
-
query=query or {}, headers=headers or {}, cookies=cookies or {}, path_parameters=path_parameters or {}
|
276
|
-
)
|
277
|
-
OverrideMark.set(test, override)
|
278
|
-
return test
|
279
|
-
|
280
|
-
return _add_override
|
281
|
-
|
282
261
|
def _resolve_until_no_references(self, value: dict[str, Any]) -> dict[str, Any]:
|
283
262
|
while "$ref" in value:
|
284
263
|
_, value = self.resolver.resolve(value["$ref"])
|
@@ -596,52 +575,6 @@ class BaseOpenAPISchema(BaseSchema):
|
|
596
575
|
def as_state_machine(self) -> type[APIStateMachine]:
|
597
576
|
return create_state_machine(self)
|
598
577
|
|
599
|
-
def add_link(
|
600
|
-
self,
|
601
|
-
source: APIOperation,
|
602
|
-
target: str | APIOperation,
|
603
|
-
status_code: str | int,
|
604
|
-
parameters: dict[str, str] | None = None,
|
605
|
-
request_body: Any = None,
|
606
|
-
name: str | None = None,
|
607
|
-
) -> None:
|
608
|
-
"""Add a new Open API link to the schema definition.
|
609
|
-
|
610
|
-
:param APIOperation source: This operation is the source of data
|
611
|
-
:param target: This operation will receive the data from this link.
|
612
|
-
Can be an ``APIOperation`` instance or a reference like this - ``#/paths/~1users~1{userId}/get``
|
613
|
-
:param str status_code: The link is triggered when the source API operation responds with this status code.
|
614
|
-
:param parameters: A dictionary that describes how parameters should be extracted from the matched response.
|
615
|
-
The key represents the parameter name in the target API operation, and the value is a runtime
|
616
|
-
expression string.
|
617
|
-
:param request_body: A literal value or runtime expression to use as a request body when
|
618
|
-
calling the target operation.
|
619
|
-
:param str name: Explicit link name.
|
620
|
-
|
621
|
-
.. code-block:: python
|
622
|
-
|
623
|
-
schema = schemathesis.openapi.from_url("http://0.0.0.0/schema.yaml")
|
624
|
-
|
625
|
-
schema.add_link(
|
626
|
-
source=schema["/users/"]["POST"],
|
627
|
-
target=schema["/users/{userId}"]["GET"],
|
628
|
-
status_code="201",
|
629
|
-
parameters={"userId": "$response.body#/id"},
|
630
|
-
)
|
631
|
-
"""
|
632
|
-
if parameters is None and request_body is None:
|
633
|
-
raise ValueError("You need to provide `parameters` or `request_body`.")
|
634
|
-
links.add_link(
|
635
|
-
resolver=self.resolver,
|
636
|
-
responses=self[source.path][source.method].definition.raw["responses"],
|
637
|
-
links_field=self.links_field,
|
638
|
-
parameters=parameters,
|
639
|
-
request_body=request_body,
|
640
|
-
status_code=status_code,
|
641
|
-
target=target,
|
642
|
-
name=name,
|
643
|
-
)
|
644
|
-
|
645
578
|
def get_links(self, operation: APIOperation) -> dict[str, dict[str, Any]]:
|
646
579
|
result: dict[str, dict[str, Any]] = defaultdict(dict)
|
647
580
|
for status_code, link in links.get_all_links(operation):
|
@@ -909,7 +842,7 @@ class MethodMap(Mapping):
|
|
909
842
|
try:
|
910
843
|
return self._init_operation(item)
|
911
844
|
except LookupError as exc:
|
912
|
-
available_methods = ", ".join(
|
845
|
+
available_methods = ", ".join(key.upper() for key in self if key in HTTP_METHODS)
|
913
846
|
message = f"Method `{item.upper()}` not found."
|
914
847
|
if available_methods:
|
915
848
|
message += f" Available methods: {available_methods}"
|
@@ -1007,12 +940,6 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
1007
940
|
def prepare_multipart(
|
1008
941
|
self, form_data: dict[str, Any], operation: APIOperation
|
1009
942
|
) -> tuple[list | None, dict[str, Any] | None]:
|
1010
|
-
"""Prepare form data for sending with `requests`.
|
1011
|
-
|
1012
|
-
:param form_data: Raw generated data as a dictionary.
|
1013
|
-
:param operation: The tested API operation for which the data was generated.
|
1014
|
-
:return: `files` and `data` values for `requests.request`.
|
1015
|
-
"""
|
1016
943
|
files, data = [], {}
|
1017
944
|
# If there is no content types specified for the request or "application/x-www-form-urlencoded" is specified
|
1018
945
|
# explicitly, then use it., but if "multipart/form-data" is specified, then use it
|
@@ -1056,7 +983,7 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
1056
983
|
method: str | None = None,
|
1057
984
|
path: str | None = None,
|
1058
985
|
path_parameters: dict[str, Any] | None = None,
|
1059
|
-
headers: dict[str, Any] | None = None,
|
986
|
+
headers: dict[str, Any] | CaseInsensitiveDict | None = None,
|
1060
987
|
cookies: dict[str, Any] | None = None,
|
1061
988
|
query: dict[str, Any] | None = None,
|
1062
989
|
body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet = NOT_SET,
|
@@ -1069,22 +996,16 @@ class SwaggerV20(BaseOpenAPISchema):
|
|
1069
996
|
operation=operation,
|
1070
997
|
method=method or operation.method.upper(),
|
1071
998
|
path=path or operation.path,
|
1072
|
-
path_parameters=path_parameters,
|
1073
|
-
headers=CaseInsensitiveDict(
|
1074
|
-
cookies=cookies,
|
1075
|
-
query=query,
|
999
|
+
path_parameters=path_parameters or {},
|
1000
|
+
headers=CaseInsensitiveDict() if headers is None else CaseInsensitiveDict(headers),
|
1001
|
+
cookies=cookies or {},
|
1002
|
+
query=query or {},
|
1076
1003
|
body=body,
|
1077
1004
|
media_type=media_type,
|
1078
1005
|
meta=meta,
|
1079
1006
|
)
|
1080
1007
|
|
1081
1008
|
def _get_consumes_for_operation(self, definition: dict[str, Any]) -> list[str]:
|
1082
|
-
"""Get the `consumes` value for the given API operation.
|
1083
|
-
|
1084
|
-
:param definition: Raw API operation definition.
|
1085
|
-
:return: A list of media-types for this operation.
|
1086
|
-
:rtype: List[str]
|
1087
|
-
"""
|
1088
1009
|
global_consumes = self.raw_schema.get("consumes", [])
|
1089
1010
|
consumes = definition.get("consumes", [])
|
1090
1011
|
if not consumes:
|
@@ -1183,12 +1104,6 @@ class OpenApi30(SwaggerV20):
|
|
1183
1104
|
def prepare_multipart(
|
1184
1105
|
self, form_data: dict[str, Any], operation: APIOperation
|
1185
1106
|
) -> tuple[list | None, dict[str, Any] | None]:
|
1186
|
-
"""Prepare form data for sending with `requests`.
|
1187
|
-
|
1188
|
-
:param form_data: Raw generated data as a dictionary.
|
1189
|
-
:param operation: The tested API operation for which the data was generated.
|
1190
|
-
:return: `files` and `data` values for `requests.request`.
|
1191
|
-
"""
|
1192
1107
|
files = []
|
1193
1108
|
definition = operation.definition.raw
|
1194
1109
|
if "$ref" in definition["requestBody"]:
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
from dataclasses import dataclass
|
4
4
|
from functools import lru_cache
|
5
|
-
from typing import
|
5
|
+
from typing import Any, Callable, Generator, Literal, cast
|
6
6
|
|
7
7
|
from schemathesis.core import NOT_SET, NotSet
|
8
8
|
from schemathesis.core.errors import InvalidTransition, OperationNotFound, TransitionValidationError
|
@@ -13,10 +13,6 @@ from schemathesis.specs.openapi import expressions
|
|
13
13
|
from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
|
14
14
|
from schemathesis.specs.openapi.references import RECURSION_DEPTH_LIMIT
|
15
15
|
|
16
|
-
if TYPE_CHECKING:
|
17
|
-
from jsonschema import RefResolver
|
18
|
-
|
19
|
-
|
20
16
|
SCHEMATHESIS_LINK_EXTENSION = "x-schemathesis"
|
21
17
|
ParameterLocation = Literal["path", "query", "header", "cookie", "body"]
|
22
18
|
|
@@ -211,61 +207,3 @@ def get_all_links(
|
|
211
207
|
yield status_code, Ok(link)
|
212
208
|
except InvalidTransition as exc:
|
213
209
|
yield status_code, Err(exc)
|
214
|
-
|
215
|
-
|
216
|
-
StatusCode = Union[str, int]
|
217
|
-
|
218
|
-
|
219
|
-
def _get_response_by_status_code(responses: dict[StatusCode, dict[str, Any]], status_code: str | int) -> dict:
|
220
|
-
if isinstance(status_code, int):
|
221
|
-
# Invalid schemas may contain status codes as integers
|
222
|
-
if status_code in responses:
|
223
|
-
return responses[status_code]
|
224
|
-
# Passed here as an integer, but there is no such status code as int
|
225
|
-
# We cast it to a string because it is either there already and we'll get relevant responses, otherwise
|
226
|
-
# a new dict will be created because there is no such status code in the schema (as an int or a string)
|
227
|
-
return responses.setdefault(str(status_code), {})
|
228
|
-
if status_code.isnumeric():
|
229
|
-
# Invalid schema but the status code is passed as a string
|
230
|
-
numeric_status_code = int(status_code)
|
231
|
-
if numeric_status_code in responses:
|
232
|
-
return responses[numeric_status_code]
|
233
|
-
# All status codes as strings, including `default` and patterned values like `5XX`
|
234
|
-
return responses.setdefault(status_code, {})
|
235
|
-
|
236
|
-
|
237
|
-
def add_link(
|
238
|
-
resolver: RefResolver,
|
239
|
-
responses: dict[StatusCode, dict[str, Any]],
|
240
|
-
links_field: str,
|
241
|
-
parameters: dict[str, str] | None,
|
242
|
-
request_body: Any,
|
243
|
-
status_code: StatusCode,
|
244
|
-
target: str | APIOperation,
|
245
|
-
name: str | None = None,
|
246
|
-
) -> None:
|
247
|
-
response = _get_response_by_status_code(responses, status_code)
|
248
|
-
if "$ref" in response:
|
249
|
-
_, response = resolver.resolve(response["$ref"])
|
250
|
-
links_definition = response.setdefault(links_field, {})
|
251
|
-
new_link: dict[str, str | dict[str, str]] = {}
|
252
|
-
if parameters is not None:
|
253
|
-
new_link["parameters"] = parameters
|
254
|
-
if request_body is not None:
|
255
|
-
new_link["requestBody"] = request_body
|
256
|
-
if isinstance(target, str):
|
257
|
-
name = name or target
|
258
|
-
new_link["operationRef"] = target
|
259
|
-
else:
|
260
|
-
name = name or f"{target.method.upper()} {target.path}"
|
261
|
-
# operationId is a dict lookup which is more efficient than using `operationRef`, since it
|
262
|
-
# doesn't involve reference resolving when we will look up for this target during testing.
|
263
|
-
if "operationId" in target.definition.raw:
|
264
|
-
new_link["operationId"] = target.definition.raw["operationId"]
|
265
|
-
else:
|
266
|
-
new_link["operationRef"] = target.operation_reference
|
267
|
-
# The name is arbitrary, so we don't really case what it is,
|
268
|
-
# but it should not override existing links
|
269
|
-
while name in links_definition:
|
270
|
-
name += "_new"
|
271
|
-
links_definition[name] = new_link
|
@@ -11,6 +11,7 @@ from schemathesis.core import NotSet
|
|
11
11
|
from schemathesis.core.rate_limit import ratelimit
|
12
12
|
from schemathesis.core.transforms import deepclone, merge_at
|
13
13
|
from schemathesis.core.transport import DEFAULT_RESPONSE_TIMEOUT, Response
|
14
|
+
from schemathesis.generation.overrides import Override
|
14
15
|
from schemathesis.transport import BaseTransport, SerializationContext
|
15
16
|
from schemathesis.transport.prepare import prepare_body, prepare_headers, prepare_url
|
16
17
|
from schemathesis.transport.serialization import Binary, serialize_binary, serialize_json, serialize_xml, serialize_yaml
|
@@ -104,7 +105,17 @@ class RequestsTransport(BaseTransport["requests.Session"]):
|
|
104
105
|
rate_limit = config.rate_limit_for(operation=case.operation)
|
105
106
|
with ratelimit(rate_limit, config.base_url):
|
106
107
|
response = session.request(**data) # type: ignore
|
107
|
-
return Response.from_requests(
|
108
|
+
return Response.from_requests(
|
109
|
+
response,
|
110
|
+
verify=verify,
|
111
|
+
_override=Override(
|
112
|
+
query=kwargs.get("params") or {},
|
113
|
+
headers=kwargs.get("headers") or {},
|
114
|
+
cookies=kwargs.get("cookies") or {},
|
115
|
+
path_parameters={},
|
116
|
+
),
|
117
|
+
)
|
118
|
+
|
108
119
|
finally:
|
109
120
|
if close_session:
|
110
121
|
session.close()
|
@@ -82,10 +82,6 @@ def serialize_xml(
|
|
82
82
|
"""Serialize a generated Python object as an XML string.
|
83
83
|
|
84
84
|
Schemas may contain additional information for fine-tuned XML serialization.
|
85
|
-
|
86
|
-
:param value: Generated value
|
87
|
-
:param raw_schema: The payload definition with not resolved references.
|
88
|
-
:param resolved_schema: The payload definition with all references resolved.
|
89
85
|
"""
|
90
86
|
if isinstance(value, (bytes, str)):
|
91
87
|
return {"data": value}
|
schemathesis/transport/wsgi.py
CHANGED
@@ -9,6 +9,7 @@ from schemathesis.core.rate_limit import ratelimit
|
|
9
9
|
from schemathesis.core.transforms import merge_at
|
10
10
|
from schemathesis.core.transport import Response
|
11
11
|
from schemathesis.generation.case import Case
|
12
|
+
from schemathesis.generation.overrides import Override
|
12
13
|
from schemathesis.python import wsgi
|
13
14
|
from schemathesis.transport import BaseTransport, SerializationContext
|
14
15
|
from schemathesis.transport.prepare import normalize_base_url, prepare_body, prepare_headers, prepare_path
|
@@ -99,6 +100,12 @@ class WSGITransport(BaseTransport["werkzeug.Client"]):
|
|
99
100
|
request=requests.Request(**requests_kwargs).prepare(),
|
100
101
|
elapsed=elapsed,
|
101
102
|
verify=False,
|
103
|
+
_override=Override(
|
104
|
+
query=kwargs.get("params") or {},
|
105
|
+
headers=kwargs.get("headers") or {},
|
106
|
+
cookies=kwargs.get("cookies") or {},
|
107
|
+
path_parameters={},
|
108
|
+
),
|
102
109
|
)
|
103
110
|
|
104
111
|
|