schemathesis 3.33.2__py3-none-any.whl → 3.34.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/auths.py +71 -13
- schemathesis/checks.py +2 -0
- schemathesis/cli/__init__.py +10 -0
- schemathesis/cli/callbacks.py +3 -6
- schemathesis/cli/junitxml.py +20 -17
- schemathesis/cli/sanitization.py +5 -0
- schemathesis/exceptions.py +8 -0
- schemathesis/failures.py +20 -0
- schemathesis/generation/__init__.py +2 -0
- schemathesis/hooks.py +81 -8
- schemathesis/internal/transformation.py +10 -0
- schemathesis/models.py +12 -26
- schemathesis/runner/events.py +1 -0
- schemathesis/runner/impl/core.py +13 -1
- schemathesis/sanitization.py +1 -0
- schemathesis/schemas.py +12 -2
- schemathesis/service/serialization.py +1 -0
- schemathesis/specs/graphql/schemas.py +4 -0
- schemathesis/specs/openapi/checks.py +249 -12
- schemathesis/specs/openapi/examples.py +18 -1
- schemathesis/specs/openapi/links.py +45 -14
- schemathesis/specs/openapi/schemas.py +33 -17
- schemathesis/specs/openapi/stateful/__init__.py +18 -7
- schemathesis/stateful/__init__.py +20 -16
- schemathesis/stateful/config.py +16 -4
- schemathesis/stateful/runner.py +1 -1
- schemathesis/stateful/state_machine.py +20 -1
- schemathesis/transports/__init__.py +9 -1
- {schemathesis-3.33.2.dist-info → schemathesis-3.34.0.dist-info}/METADATA +11 -3
- {schemathesis-3.33.2.dist-info → schemathesis-3.34.0.dist-info}/RECORD +33 -33
- {schemathesis-3.33.2.dist-info → schemathesis-3.34.0.dist-info}/WHEEL +0 -0
- {schemathesis-3.33.2.dist-info → schemathesis-3.34.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.33.2.dist-info → schemathesis-3.34.0.dist-info}/licenses/LICENSE +0 -0
schemathesis/schemas.py
CHANGED
|
@@ -37,14 +37,21 @@ from .auths import AuthStorage
|
|
|
37
37
|
from .code_samples import CodeSampleStyle
|
|
38
38
|
from .constants import NOT_SET
|
|
39
39
|
from .exceptions import OperationSchemaError, UsageError
|
|
40
|
-
from .filters import
|
|
40
|
+
from .filters import (
|
|
41
|
+
FilterSet,
|
|
42
|
+
FilterValue,
|
|
43
|
+
MatcherFunc,
|
|
44
|
+
RegexValue,
|
|
45
|
+
filter_set_from_components,
|
|
46
|
+
is_deprecated,
|
|
47
|
+
)
|
|
41
48
|
from .generation import (
|
|
42
49
|
DEFAULT_DATA_GENERATION_METHODS,
|
|
43
50
|
DataGenerationMethod,
|
|
44
51
|
DataGenerationMethodInput,
|
|
45
52
|
GenerationConfig,
|
|
46
53
|
)
|
|
47
|
-
from .hooks import HookContext, HookDispatcher, HookScope, dispatch
|
|
54
|
+
from .hooks import HookContext, HookDispatcher, HookScope, dispatch, to_filterable_hook
|
|
48
55
|
from .internal.deprecation import warn_filtration_arguments
|
|
49
56
|
from .internal.output import OutputConfig
|
|
50
57
|
from .internal.result import Ok, Result
|
|
@@ -98,6 +105,9 @@ class BaseSchema(Mapping):
|
|
|
98
105
|
rate_limiter: Limiter | None = None
|
|
99
106
|
sanitize_output: bool = True
|
|
100
107
|
|
|
108
|
+
def __post_init__(self) -> None:
|
|
109
|
+
self.hook = to_filterable_hook(self.hooks) # type: ignore[method-assign]
|
|
110
|
+
|
|
101
111
|
def include(
|
|
102
112
|
self,
|
|
103
113
|
func: MatcherFunc | None = None,
|
|
@@ -104,6 +104,7 @@ def serialize_after_stateful_execution(event: events.AfterStatefulExecution) ->
|
|
|
104
104
|
"status": event.status,
|
|
105
105
|
"data_generation_method": event.data_generation_method,
|
|
106
106
|
"result": asdict(event.result),
|
|
107
|
+
"elapsed_time": event.elapsed_time,
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
|
|
@@ -297,6 +297,9 @@ class GraphQLSchema(BaseSchema):
|
|
|
297
297
|
def get_tags(self, operation: APIOperation) -> list[str] | None:
|
|
298
298
|
return None
|
|
299
299
|
|
|
300
|
+
def validate(self) -> None:
|
|
301
|
+
return None
|
|
302
|
+
|
|
300
303
|
|
|
301
304
|
@dataclass
|
|
302
305
|
class FieldMap(Mapping):
|
|
@@ -367,6 +370,7 @@ def get_case_strategy(
|
|
|
367
370
|
custom_scalars=custom_scalars,
|
|
368
371
|
print_ast=_noop, # type: ignore
|
|
369
372
|
allow_x00=generation_config.allow_x00,
|
|
373
|
+
allow_null=generation_config.graphql_allow_null,
|
|
370
374
|
codec=generation_config.codec,
|
|
371
375
|
)
|
|
372
376
|
strategy = apply_to_all_dispatchers(operation, hook_context, hooks, strategy, "body").map(graphql.print_ast)
|
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from
|
|
4
|
+
from http.cookies import SimpleCookie
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Dict, Generator, NoReturn, cast
|
|
6
|
+
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
|
5
7
|
|
|
6
8
|
from ... import failures
|
|
7
9
|
from ...exceptions import (
|
|
10
|
+
get_ensure_resource_availability_error,
|
|
8
11
|
get_headers_error,
|
|
12
|
+
get_ignored_auth_error,
|
|
9
13
|
get_malformed_media_type_error,
|
|
10
14
|
get_missing_content_type_error,
|
|
11
15
|
get_negative_rejection_error,
|
|
12
16
|
get_response_type_error,
|
|
17
|
+
get_schema_validation_error,
|
|
13
18
|
get_status_code_error,
|
|
14
19
|
get_use_after_free_error,
|
|
15
20
|
)
|
|
21
|
+
from ...internal.transformation import convert_boolean_string
|
|
16
22
|
from ...transports.content_types import parse_content_type
|
|
17
23
|
from .utils import expand_status_code
|
|
18
24
|
|
|
19
25
|
if TYPE_CHECKING:
|
|
20
|
-
from
|
|
26
|
+
from requests import PreparedRequest
|
|
27
|
+
|
|
28
|
+
from ...models import APIOperation, Case
|
|
21
29
|
from ...transports.responses import GenericResponse
|
|
22
30
|
|
|
23
31
|
|
|
@@ -108,11 +116,17 @@ def _reraise_malformed_media_type(case: Case, exc: ValueError, location: str, ac
|
|
|
108
116
|
|
|
109
117
|
|
|
110
118
|
def response_headers_conformance(response: GenericResponse, case: Case) -> bool | None:
|
|
111
|
-
|
|
119
|
+
import jsonschema
|
|
120
|
+
|
|
121
|
+
from .parameters import OpenAPI20Parameter, OpenAPI30Parameter
|
|
122
|
+
from .schemas import BaseOpenAPISchema, OpenApi30, _maybe_raise_one_or_more
|
|
112
123
|
|
|
113
124
|
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
|
114
125
|
return True
|
|
115
|
-
|
|
126
|
+
resolved = case.operation.schema.get_headers(case.operation, response)
|
|
127
|
+
if not resolved:
|
|
128
|
+
return None
|
|
129
|
+
scopes, defined_headers = resolved
|
|
116
130
|
if not defined_headers:
|
|
117
131
|
return None
|
|
118
132
|
|
|
@@ -121,15 +135,70 @@ def response_headers_conformance(response: GenericResponse, case: Case) -> bool
|
|
|
121
135
|
for header, definition in defined_headers.items()
|
|
122
136
|
if header not in response.headers and definition.get(case.operation.schema.header_required_field, False)
|
|
123
137
|
]
|
|
124
|
-
|
|
138
|
+
errors = []
|
|
139
|
+
if missing_headers:
|
|
140
|
+
formatted_headers = [f"\n- `{header}`" for header in missing_headers]
|
|
141
|
+
message = f"The following required headers are missing from the response:{''.join(formatted_headers)}"
|
|
142
|
+
exc_class = get_headers_error(case.operation.verbose_name, message)
|
|
143
|
+
try:
|
|
144
|
+
raise exc_class(
|
|
145
|
+
failures.MissingHeaders.title,
|
|
146
|
+
context=failures.MissingHeaders(message=message, missing_headers=missing_headers),
|
|
147
|
+
)
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
errors.append(exc)
|
|
150
|
+
for name, definition in defined_headers.items():
|
|
151
|
+
value = response.headers.get(name)
|
|
152
|
+
if value is not None:
|
|
153
|
+
parameter_definition = {"in": "header", **definition}
|
|
154
|
+
parameter: OpenAPI20Parameter | OpenAPI30Parameter
|
|
155
|
+
if isinstance(case.operation.schema, OpenApi30):
|
|
156
|
+
parameter = OpenAPI30Parameter(parameter_definition)
|
|
157
|
+
else:
|
|
158
|
+
parameter = OpenAPI20Parameter(parameter_definition)
|
|
159
|
+
schema = parameter.as_json_schema(case.operation)
|
|
160
|
+
coerced = _coerce_header_value(value, schema)
|
|
161
|
+
with case.operation.schema._validating_response(scopes) as resolver:
|
|
162
|
+
try:
|
|
163
|
+
jsonschema.validate(
|
|
164
|
+
coerced,
|
|
165
|
+
schema,
|
|
166
|
+
cls=case.operation.schema.validator_cls,
|
|
167
|
+
resolver=resolver,
|
|
168
|
+
format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER,
|
|
169
|
+
)
|
|
170
|
+
except jsonschema.ValidationError as exc:
|
|
171
|
+
exc_class = get_schema_validation_error(case.operation.verbose_name, exc)
|
|
172
|
+
ctx = failures.ValidationErrorContext.from_exception(
|
|
173
|
+
exc, output_config=case.operation.schema.output_config
|
|
174
|
+
)
|
|
175
|
+
try:
|
|
176
|
+
raise exc_class("Response header does not conform to the schema", context=ctx) from exc
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
errors.append(exc)
|
|
179
|
+
return _maybe_raise_one_or_more(errors) # type: ignore[func-returns-value]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _coerce_header_value(value: str, schema: dict[str, Any]) -> str | int | float | None | bool:
|
|
183
|
+
schema_type = schema.get("type")
|
|
184
|
+
|
|
185
|
+
if schema_type == "string":
|
|
186
|
+
return value
|
|
187
|
+
if schema_type == "integer":
|
|
188
|
+
try:
|
|
189
|
+
return int(value)
|
|
190
|
+
except ValueError:
|
|
191
|
+
return value
|
|
192
|
+
if schema_type == "number":
|
|
193
|
+
try:
|
|
194
|
+
return float(value)
|
|
195
|
+
except ValueError:
|
|
196
|
+
return value
|
|
197
|
+
if schema_type == "null" and value.lower() == "null":
|
|
125
198
|
return None
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
raise exc_class(
|
|
130
|
-
failures.MissingHeaders.title,
|
|
131
|
-
context=failures.MissingHeaders(message=message, missing_headers=missing_headers),
|
|
132
|
-
)
|
|
199
|
+
if schema_type == "boolean":
|
|
200
|
+
return convert_boolean_string(value)
|
|
201
|
+
return value
|
|
133
202
|
|
|
134
203
|
|
|
135
204
|
def response_schema_conformance(response: GenericResponse, case: Case) -> bool | None:
|
|
@@ -227,6 +296,174 @@ def use_after_free(response: GenericResponse, original: Case) -> bool | None:
|
|
|
227
296
|
return None
|
|
228
297
|
|
|
229
298
|
|
|
299
|
+
def ensure_resource_availability(response: GenericResponse, original: Case) -> bool | None:
|
|
300
|
+
from ...transports.responses import get_reason
|
|
301
|
+
from .schemas import BaseOpenAPISchema
|
|
302
|
+
|
|
303
|
+
if not isinstance(original.operation.schema, BaseOpenAPISchema):
|
|
304
|
+
return True
|
|
305
|
+
if (
|
|
306
|
+
# Response indicates a client error, even though all available parameters were taken from links
|
|
307
|
+
# and comes from a POST request. This case likely means that the POST request actually did not
|
|
308
|
+
# save the resource and it is not available for subsequent operations
|
|
309
|
+
400 <= response.status_code < 500
|
|
310
|
+
and original.source
|
|
311
|
+
and original.source.case.operation.method.upper() == "POST"
|
|
312
|
+
and 200 <= original.source.response.status_code < 400
|
|
313
|
+
and original.source.overrides_all_parameters
|
|
314
|
+
):
|
|
315
|
+
created_with = original.source.case.operation.verbose_name
|
|
316
|
+
not_available_with = original.operation.verbose_name
|
|
317
|
+
exc_class = get_ensure_resource_availability_error(created_with)
|
|
318
|
+
reason = get_reason(response.status_code)
|
|
319
|
+
message = (
|
|
320
|
+
f"The API returned `{response.status_code} {reason}` for a resource that was just created.\n\n"
|
|
321
|
+
f"Created with : `{created_with}`\n"
|
|
322
|
+
f"Not available with: `{not_available_with}`"
|
|
323
|
+
)
|
|
324
|
+
raise exc_class(
|
|
325
|
+
failures.EnsureResourceAvailability.title,
|
|
326
|
+
context=failures.EnsureResourceAvailability(
|
|
327
|
+
message=message, created_with=created_with, not_available_with=not_available_with
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def ignored_auth(response: GenericResponse, case: Case) -> bool | None:
|
|
334
|
+
"""Check if an operation declares authentication as a requirement but does not actually enforce it."""
|
|
335
|
+
from requests import Session
|
|
336
|
+
|
|
337
|
+
from .schemas import BaseOpenAPISchema
|
|
338
|
+
|
|
339
|
+
if not isinstance(case.operation.schema, BaseOpenAPISchema):
|
|
340
|
+
return True
|
|
341
|
+
security_parameters = _get_security_parameters(case.operation)
|
|
342
|
+
# Authentication is required for this API operation and response is successful
|
|
343
|
+
# Will it still be successful if there is no auth?
|
|
344
|
+
if security_parameters and 200 <= response.status_code < 300:
|
|
345
|
+
if _contains_auth(response.request, security_parameters):
|
|
346
|
+
# If there is auth in the request, then drop it and retry the call
|
|
347
|
+
request = _remove_auth_from_request(response.request, security_parameters)
|
|
348
|
+
response.request = request
|
|
349
|
+
new_response = Session().send(request)
|
|
350
|
+
if new_response.ok:
|
|
351
|
+
# Mutate the response object in place on the best effort basis
|
|
352
|
+
for attribute in new_response.__attrs__:
|
|
353
|
+
setattr(response, attribute, getattr(new_response, attribute))
|
|
354
|
+
_remove_auth_from_case(case, security_parameters)
|
|
355
|
+
_raise_auth_error(new_response, case.operation.verbose_name)
|
|
356
|
+
else:
|
|
357
|
+
# Successful response when there is no auth
|
|
358
|
+
_raise_auth_error(response, case.operation.verbose_name)
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _raise_auth_error(response: GenericResponse, operation: str) -> NoReturn:
|
|
363
|
+
from ...transports.responses import get_reason
|
|
364
|
+
|
|
365
|
+
exc_class = get_ignored_auth_error(operation)
|
|
366
|
+
reason = get_reason(response.status_code)
|
|
367
|
+
message = f"The API returned `{response.status_code} {reason}` for `{operation}` that requires authentication."
|
|
368
|
+
raise exc_class(
|
|
369
|
+
failures.IgnoredAuth.title,
|
|
370
|
+
context=failures.IgnoredAuth(message=message),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
SecurityParameter = Dict[str, Any]
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _get_security_parameters(operation: APIOperation) -> list[SecurityParameter]:
|
|
378
|
+
"""Extract security definitions that are active for the given operation and convert them into parameters."""
|
|
379
|
+
from .schemas import BaseOpenAPISchema
|
|
380
|
+
|
|
381
|
+
schema = cast(BaseOpenAPISchema, operation.schema)
|
|
382
|
+
return [
|
|
383
|
+
schema.security._to_parameter(parameter)
|
|
384
|
+
for parameter in schema.security._get_active_definitions(schema.raw_schema, operation, schema.resolver)
|
|
385
|
+
if parameter["type"] in ("apiKey", "basic", "http")
|
|
386
|
+
]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _contains_auth(request: PreparedRequest, security_parameters: list[SecurityParameter]) -> bool:
|
|
390
|
+
"""Whether a request has authentication declared in the schema."""
|
|
391
|
+
from requests.cookies import RequestsCookieJar
|
|
392
|
+
|
|
393
|
+
parsed = urlparse(request.url)
|
|
394
|
+
query = parse_qs(parsed.query) # type: ignore
|
|
395
|
+
# Load the `Cookie` header separately, because it is possible that `request._cookies` and the header are out of sync
|
|
396
|
+
header_cookies: SimpleCookie = SimpleCookie()
|
|
397
|
+
raw_cookie = request.headers.get("Cookie")
|
|
398
|
+
if raw_cookie is not None:
|
|
399
|
+
header_cookies.load(raw_cookie)
|
|
400
|
+
|
|
401
|
+
def has_header(p: dict[str, Any]) -> bool:
|
|
402
|
+
return p["in"] == "header" and p["name"] in request.headers
|
|
403
|
+
|
|
404
|
+
def has_query(p: dict[str, Any]) -> bool:
|
|
405
|
+
return p["in"] == "query" and p["name"] in query
|
|
406
|
+
|
|
407
|
+
def has_cookie(p: dict[str, Any]) -> bool:
|
|
408
|
+
cookies = cast(RequestsCookieJar, request._cookies) # type: ignore
|
|
409
|
+
return p["in"] == "cookie" and (p["name"] in cookies or p["name"] in header_cookies)
|
|
410
|
+
|
|
411
|
+
for parameter in security_parameters:
|
|
412
|
+
if has_header(parameter) or has_query(parameter) or has_cookie(parameter):
|
|
413
|
+
return True
|
|
414
|
+
|
|
415
|
+
return False
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _remove_auth_from_request(
|
|
419
|
+
request: PreparedRequest, security_parameters: list[SecurityParameter]
|
|
420
|
+
) -> PreparedRequest:
|
|
421
|
+
"""Remove security parameters from a request."""
|
|
422
|
+
from requests.cookies import get_cookie_header
|
|
423
|
+
|
|
424
|
+
request = request.copy()
|
|
425
|
+
parsed = urlparse(request.url)
|
|
426
|
+
query = parse_qs(parsed.query) # type: ignore
|
|
427
|
+
should_replace_url = False
|
|
428
|
+
|
|
429
|
+
for parameter in security_parameters:
|
|
430
|
+
name = parameter["name"]
|
|
431
|
+
if parameter["in"] == "header":
|
|
432
|
+
request.headers.pop(name, None)
|
|
433
|
+
if parameter["in"] == "query":
|
|
434
|
+
query.pop(name, None)
|
|
435
|
+
should_replace_url = True
|
|
436
|
+
if parameter["in"] == "cookie":
|
|
437
|
+
del request._cookies[name] # type: ignore
|
|
438
|
+
|
|
439
|
+
if should_replace_url:
|
|
440
|
+
components = [parsed.scheme, parsed.netloc, parsed.path, parsed.params, urlencode(query), parsed.fragment]
|
|
441
|
+
url = cast(str, urlunparse(components)) # type: ignore
|
|
442
|
+
request.url = url
|
|
443
|
+
# Re-generate the `Cookie` header if needed
|
|
444
|
+
raw_cookie = request.headers.pop("Cookie", None)
|
|
445
|
+
if raw_cookie is not None:
|
|
446
|
+
new_cookie_header = get_cookie_header(request._cookies, request) # type: ignore
|
|
447
|
+
if new_cookie_header:
|
|
448
|
+
request.headers["Cookie"] = new_cookie_header
|
|
449
|
+
return request
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _remove_auth_from_case(case: Case, security_parameters: list[SecurityParameter]) -> None:
|
|
453
|
+
"""Remove security parameters from a generated case.
|
|
454
|
+
|
|
455
|
+
It mutates `case` in place.
|
|
456
|
+
"""
|
|
457
|
+
for parameter in security_parameters:
|
|
458
|
+
name = parameter["name"]
|
|
459
|
+
if parameter["in"] == "header" and case.headers:
|
|
460
|
+
case.headers.pop(name, None)
|
|
461
|
+
if parameter["in"] == "query" and case.query:
|
|
462
|
+
case.query.pop(name, None)
|
|
463
|
+
if parameter["in"] == "cookie" and case.cookies:
|
|
464
|
+
case.cookies.pop(name, None)
|
|
465
|
+
|
|
466
|
+
|
|
230
467
|
@dataclass
|
|
231
468
|
class ResourcePath:
|
|
232
469
|
"""A path to a resource with variables."""
|
|
@@ -12,6 +12,7 @@ from hypothesis_jsonschema import from_schema
|
|
|
12
12
|
|
|
13
13
|
from ..._hypothesis import get_single_example
|
|
14
14
|
from ...constants import DEFAULT_RESPONSE_TIMEOUT
|
|
15
|
+
from ...internal.copy import fast_deepcopy
|
|
15
16
|
from ...models import APIOperation, Case
|
|
16
17
|
from ._hypothesis import get_case_strategy, get_default_format_strategies
|
|
17
18
|
from .constants import LOCATION_TO_CONTAINER
|
|
@@ -130,10 +131,26 @@ def extract_top_level(operation: APIOperation[OpenAPIParameter, Case]) -> Genera
|
|
|
130
131
|
def _expand_subschemas(schema: dict[str, Any] | bool) -> Generator[dict[str, Any] | bool, None, None]:
|
|
131
132
|
yield schema
|
|
132
133
|
if isinstance(schema, dict):
|
|
133
|
-
for key in ("anyOf", "oneOf"
|
|
134
|
+
for key in ("anyOf", "oneOf"):
|
|
134
135
|
if key in schema:
|
|
135
136
|
for subschema in schema[key]:
|
|
136
137
|
yield subschema
|
|
138
|
+
if "allOf" in schema:
|
|
139
|
+
subschema = fast_deepcopy(schema["allOf"][0])
|
|
140
|
+
for sub in schema["allOf"][1:]:
|
|
141
|
+
if isinstance(sub, dict):
|
|
142
|
+
for key, value in sub.items():
|
|
143
|
+
if key == "properties":
|
|
144
|
+
subschema.setdefault("properties", {}).update(value)
|
|
145
|
+
elif key == "required":
|
|
146
|
+
subschema.setdefault("required", []).extend(value)
|
|
147
|
+
elif key == "examples":
|
|
148
|
+
subschema.setdefault("examples", []).extend(value)
|
|
149
|
+
elif key == "example":
|
|
150
|
+
subschema.setdefault("examples", []).append(value)
|
|
151
|
+
else:
|
|
152
|
+
subschema[key] = value
|
|
153
|
+
yield subschema
|
|
137
154
|
|
|
138
155
|
|
|
139
156
|
def _find_parameter_examples_definition(
|
|
@@ -7,7 +7,8 @@ from __future__ import annotations
|
|
|
7
7
|
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
9
|
from difflib import get_close_matches
|
|
10
|
-
from
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Generator, Literal, NoReturn, Sequence, TypedDict, Union, cast
|
|
11
12
|
|
|
12
13
|
from jsonschema import RefResolver
|
|
13
14
|
|
|
@@ -77,6 +78,9 @@ class Link(StatefulTest):
|
|
|
77
78
|
body = merge_body(case.body, body)
|
|
78
79
|
return ParsedData(parameters=parameters, body=body)
|
|
79
80
|
|
|
81
|
+
def is_match(self) -> bool:
|
|
82
|
+
return self.operation.schema.filter_set.match(SimpleNamespace(operation=self.operation))
|
|
83
|
+
|
|
80
84
|
def make_operation(self, collected: list[ParsedData]) -> APIOperation:
|
|
81
85
|
"""Create a modified version of the original API operation with additional data merged in."""
|
|
82
86
|
# We split the gathered data among all locations & store the original parameter
|
|
@@ -190,7 +194,7 @@ class OpenAPILink(Direction):
|
|
|
190
194
|
status_code: str
|
|
191
195
|
definition: dict[str, Any]
|
|
192
196
|
operation: APIOperation
|
|
193
|
-
parameters: list[tuple[
|
|
197
|
+
parameters: list[tuple[Literal["path", "query", "header", "cookie", "body"] | None, str, str]] = field(init=False)
|
|
194
198
|
body: dict[str, Any] | NotSet = field(init=False)
|
|
195
199
|
merge_body: bool = True
|
|
196
200
|
|
|
@@ -212,13 +216,24 @@ class OpenAPILink(Direction):
|
|
|
212
216
|
def set_data(self, case: Case, elapsed: float, **kwargs: Any) -> None:
|
|
213
217
|
"""Assign all linked definitions to the new case instance."""
|
|
214
218
|
context = kwargs["context"]
|
|
215
|
-
self.set_parameters(case, context)
|
|
216
|
-
self.set_body(case, context)
|
|
217
|
-
|
|
219
|
+
overrides = self.set_parameters(case, context)
|
|
220
|
+
self.set_body(case, context, overrides)
|
|
221
|
+
overrides_all_parameters = True
|
|
222
|
+
if case.operation.body and "body" not in overrides.get("body", []):
|
|
223
|
+
overrides_all_parameters = False
|
|
224
|
+
if overrides_all_parameters:
|
|
225
|
+
for parameter in case.operation.iter_parameters():
|
|
226
|
+
if parameter.name not in overrides.get(parameter.location, []):
|
|
227
|
+
overrides_all_parameters = False
|
|
228
|
+
break
|
|
229
|
+
case.set_source(context.response, context.case, elapsed, overrides_all_parameters)
|
|
218
230
|
|
|
219
|
-
def set_parameters(
|
|
231
|
+
def set_parameters(
|
|
232
|
+
self, case: Case, context: expressions.ExpressionContext
|
|
233
|
+
) -> dict[Literal["path", "query", "header", "cookie", "body"], list[str]]:
|
|
234
|
+
overrides: dict[Literal["path", "query", "header", "cookie", "body"], list[str]] = {}
|
|
220
235
|
for location, name, expression in self.parameters:
|
|
221
|
-
container = get_container(case, location, name)
|
|
236
|
+
location, container = get_container(case, location, name)
|
|
222
237
|
# Might happen if there is directly specified container,
|
|
223
238
|
# but the schema has no parameters of such type at all.
|
|
224
239
|
# Therefore the container is empty, otherwise it will be at least an empty object
|
|
@@ -229,11 +244,21 @@ class OpenAPILink(Direction):
|
|
|
229
244
|
if matches:
|
|
230
245
|
message += f" Did you mean `{matches[0]}`?"
|
|
231
246
|
raise ValueError(message)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
247
|
+
value = expressions.evaluate(expression, context)
|
|
248
|
+
if value is not None:
|
|
249
|
+
container[name] = value
|
|
250
|
+
overrides.setdefault(location, []).append(name)
|
|
251
|
+
return overrides
|
|
252
|
+
|
|
253
|
+
def set_body(
|
|
254
|
+
self,
|
|
255
|
+
case: Case,
|
|
256
|
+
context: expressions.ExpressionContext,
|
|
257
|
+
overrides: dict[Literal["path", "query", "header", "cookie", "body"], list[str]],
|
|
258
|
+
) -> None:
|
|
235
259
|
if self.body is not NOT_SET:
|
|
236
260
|
evaluated = expressions.evaluate(self.body, context, evaluate_nested=True)
|
|
261
|
+
overrides["body"] = ["body"]
|
|
237
262
|
if self.merge_body:
|
|
238
263
|
case.body = merge_body(case.body, evaluated)
|
|
239
264
|
else:
|
|
@@ -251,21 +276,26 @@ def merge_body(old: Any, new: Any) -> Any:
|
|
|
251
276
|
return new
|
|
252
277
|
|
|
253
278
|
|
|
254
|
-
def get_container(
|
|
279
|
+
def get_container(
|
|
280
|
+
case: Case, location: Literal["path", "query", "header", "cookie", "body"] | None, name: str
|
|
281
|
+
) -> tuple[Literal["path", "query", "header", "cookie", "body"], dict[str, Any] | None]:
|
|
255
282
|
"""Get a container that suppose to store the given parameter."""
|
|
256
283
|
if location:
|
|
257
284
|
container_name = LOCATION_TO_CONTAINER[location]
|
|
258
285
|
else:
|
|
259
286
|
for param in case.operation.iter_parameters():
|
|
260
287
|
if param.name == name:
|
|
288
|
+
location = param.location
|
|
261
289
|
container_name = LOCATION_TO_CONTAINER[param.location]
|
|
262
290
|
break
|
|
263
291
|
else:
|
|
264
292
|
raise ValueError(f"Parameter `{name}` is not defined in API operation `{case.operation.verbose_name}`")
|
|
265
|
-
return getattr(case, container_name)
|
|
293
|
+
return location, getattr(case, container_name)
|
|
266
294
|
|
|
267
295
|
|
|
268
|
-
def normalize_parameter(
|
|
296
|
+
def normalize_parameter(
|
|
297
|
+
parameter: str, expression: str
|
|
298
|
+
) -> tuple[Literal["path", "query", "header", "cookie", "body"] | None, str, str]:
|
|
269
299
|
"""Normalize runtime expressions.
|
|
270
300
|
|
|
271
301
|
Runtime expressions may have parameter names prefixed with their location - `path.id`.
|
|
@@ -275,7 +305,8 @@ def normalize_parameter(parameter: str, expression: str) -> tuple[str | None, st
|
|
|
275
305
|
try:
|
|
276
306
|
# The parameter name is prefixed with its location. Example: `path.id`
|
|
277
307
|
location, name = tuple(parameter.split("."))
|
|
278
|
-
|
|
308
|
+
_location = cast(Literal["path", "query", "header", "cookie", "body"], location)
|
|
309
|
+
return _location, name, expression
|
|
279
310
|
except ValueError:
|
|
280
311
|
return None, parameter, expression
|
|
281
312
|
|
|
@@ -535,7 +535,9 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
535
535
|
def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
|
|
536
536
|
raise NotImplementedError
|
|
537
537
|
|
|
538
|
-
def _get_response_definitions(
|
|
538
|
+
def _get_response_definitions(
|
|
539
|
+
self, operation: APIOperation, response: GenericResponse
|
|
540
|
+
) -> tuple[list[str], dict[str, Any]] | None:
|
|
539
541
|
try:
|
|
540
542
|
responses = operation.definition.raw["responses"]
|
|
541
543
|
except KeyError as exc:
|
|
@@ -545,18 +547,19 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
545
547
|
self._raise_invalid_schema(exc, full_path, path, operation.method)
|
|
546
548
|
status_code = str(response.status_code)
|
|
547
549
|
if status_code in responses:
|
|
548
|
-
|
|
549
|
-
return response
|
|
550
|
+
return self.resolver.resolve_in_scope(responses[status_code], operation.definition.scope)
|
|
550
551
|
if "default" in responses:
|
|
551
|
-
|
|
552
|
-
return response
|
|
552
|
+
return self.resolver.resolve_in_scope(responses["default"], operation.definition.scope)
|
|
553
553
|
return None
|
|
554
554
|
|
|
555
|
-
def get_headers(
|
|
556
|
-
|
|
557
|
-
|
|
555
|
+
def get_headers(
|
|
556
|
+
self, operation: APIOperation, response: GenericResponse
|
|
557
|
+
) -> tuple[list[str], dict[str, dict[str, Any]] | None] | None:
|
|
558
|
+
resolved = self._get_response_definitions(operation, response)
|
|
559
|
+
if not resolved:
|
|
558
560
|
return None
|
|
559
|
-
|
|
561
|
+
scopes, definitions = resolved
|
|
562
|
+
return scopes, definitions.get("headers")
|
|
560
563
|
|
|
561
564
|
def as_state_machine(self) -> type[APIStateMachine]:
|
|
562
565
|
try:
|
|
@@ -668,12 +671,16 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
668
671
|
except Exception as exc:
|
|
669
672
|
errors.append(exc)
|
|
670
673
|
_maybe_raise_one_or_more(errors)
|
|
671
|
-
|
|
672
|
-
self.location or "", self.raw_schema, nullable_name=self.nullable_name, is_response_schema=True
|
|
673
|
-
)
|
|
674
|
-
with in_scopes(resolver, scopes):
|
|
674
|
+
with self._validating_response(scopes) as resolver:
|
|
675
675
|
try:
|
|
676
|
-
jsonschema.validate(
|
|
676
|
+
jsonschema.validate(
|
|
677
|
+
data,
|
|
678
|
+
schema,
|
|
679
|
+
cls=self.validator_cls,
|
|
680
|
+
resolver=resolver,
|
|
681
|
+
# Use a recent JSON Schema format checker to get most of formats checked for older drafts as well
|
|
682
|
+
format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER,
|
|
683
|
+
)
|
|
677
684
|
except jsonschema.ValidationError as exc:
|
|
678
685
|
exc_class = get_schema_validation_error(operation.verbose_name, exc)
|
|
679
686
|
ctx = failures.ValidationErrorContext.from_exception(exc, output_config=operation.schema.output_config)
|
|
@@ -684,6 +691,14 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
684
691
|
_maybe_raise_one_or_more(errors)
|
|
685
692
|
return None # explicitly return None for mypy
|
|
686
693
|
|
|
694
|
+
@contextmanager
|
|
695
|
+
def _validating_response(self, scopes: list[str]) -> Generator[ConvertingResolver, None, None]:
|
|
696
|
+
resolver = ConvertingResolver(
|
|
697
|
+
self.location or "", self.raw_schema, nullable_name=self.nullable_name, is_response_schema=True
|
|
698
|
+
)
|
|
699
|
+
with in_scopes(resolver, scopes):
|
|
700
|
+
yield resolver
|
|
701
|
+
|
|
687
702
|
@property
|
|
688
703
|
def rewritten_components(self) -> dict[str, Any]:
|
|
689
704
|
if not hasattr(self, "_rewritten_components"):
|
|
@@ -776,7 +791,7 @@ class BaseOpenAPISchema(BaseSchema):
|
|
|
776
791
|
|
|
777
792
|
def _maybe_raise_one_or_more(errors: list[Exception]) -> None:
|
|
778
793
|
if not errors:
|
|
779
|
-
return
|
|
794
|
+
return None
|
|
780
795
|
elif len(errors) == 1:
|
|
781
796
|
raise errors[0]
|
|
782
797
|
else:
|
|
@@ -1116,9 +1131,10 @@ class OpenApi30(SwaggerV20):
|
|
|
1116
1131
|
return get_strategies_from_examples(operation, as_strategy_kwargs=as_strategy_kwargs)
|
|
1117
1132
|
|
|
1118
1133
|
def get_content_types(self, operation: APIOperation, response: GenericResponse) -> list[str]:
|
|
1119
|
-
|
|
1120
|
-
if not
|
|
1134
|
+
resolved = self._get_response_definitions(operation, response)
|
|
1135
|
+
if not resolved:
|
|
1121
1136
|
return []
|
|
1137
|
+
_, definitions = resolved
|
|
1122
1138
|
return list(definitions.get("content", {}).keys())
|
|
1123
1139
|
|
|
1124
1140
|
def _get_parameter_serializer(self, definitions: list[dict[str, Any]]) -> Callable | None:
|