schemathesis 3.25.5__py3-none-any.whl → 4.0.0a1__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.
Files changed (221) hide show
  1. schemathesis/__init__.py +27 -65
  2. schemathesis/auths.py +102 -82
  3. schemathesis/checks.py +126 -46
  4. schemathesis/cli/__init__.py +11 -1766
  5. schemathesis/cli/__main__.py +4 -0
  6. schemathesis/cli/commands/__init__.py +37 -0
  7. schemathesis/cli/commands/run/__init__.py +662 -0
  8. schemathesis/cli/commands/run/checks.py +80 -0
  9. schemathesis/cli/commands/run/context.py +117 -0
  10. schemathesis/cli/commands/run/events.py +35 -0
  11. schemathesis/cli/commands/run/executor.py +138 -0
  12. schemathesis/cli/commands/run/filters.py +194 -0
  13. schemathesis/cli/commands/run/handlers/__init__.py +46 -0
  14. schemathesis/cli/commands/run/handlers/base.py +18 -0
  15. schemathesis/cli/commands/run/handlers/cassettes.py +494 -0
  16. schemathesis/cli/commands/run/handlers/junitxml.py +54 -0
  17. schemathesis/cli/commands/run/handlers/output.py +746 -0
  18. schemathesis/cli/commands/run/hypothesis.py +105 -0
  19. schemathesis/cli/commands/run/loaders.py +129 -0
  20. schemathesis/cli/{callbacks.py → commands/run/validation.py} +103 -174
  21. schemathesis/cli/constants.py +5 -52
  22. schemathesis/cli/core.py +17 -0
  23. schemathesis/cli/ext/fs.py +14 -0
  24. schemathesis/cli/ext/groups.py +55 -0
  25. schemathesis/cli/{options.py → ext/options.py} +39 -10
  26. schemathesis/cli/hooks.py +36 -0
  27. schemathesis/contrib/__init__.py +1 -3
  28. schemathesis/contrib/openapi/__init__.py +1 -3
  29. schemathesis/contrib/openapi/fill_missing_examples.py +3 -5
  30. schemathesis/core/__init__.py +58 -0
  31. schemathesis/core/compat.py +25 -0
  32. schemathesis/core/control.py +2 -0
  33. schemathesis/core/curl.py +58 -0
  34. schemathesis/core/deserialization.py +65 -0
  35. schemathesis/core/errors.py +370 -0
  36. schemathesis/core/failures.py +285 -0
  37. schemathesis/core/fs.py +19 -0
  38. schemathesis/{_lazy_import.py → core/lazy_import.py} +1 -0
  39. schemathesis/core/loaders.py +104 -0
  40. schemathesis/core/marks.py +66 -0
  41. schemathesis/{transports/content_types.py → core/media_types.py} +17 -13
  42. schemathesis/core/output/__init__.py +69 -0
  43. schemathesis/core/output/sanitization.py +197 -0
  44. schemathesis/core/rate_limit.py +60 -0
  45. schemathesis/core/registries.py +31 -0
  46. schemathesis/{internal → core}/result.py +1 -1
  47. schemathesis/core/transforms.py +113 -0
  48. schemathesis/core/transport.py +108 -0
  49. schemathesis/core/validation.py +38 -0
  50. schemathesis/core/version.py +7 -0
  51. schemathesis/engine/__init__.py +30 -0
  52. schemathesis/engine/config.py +59 -0
  53. schemathesis/engine/context.py +119 -0
  54. schemathesis/engine/control.py +36 -0
  55. schemathesis/engine/core.py +157 -0
  56. schemathesis/engine/errors.py +394 -0
  57. schemathesis/engine/events.py +337 -0
  58. schemathesis/engine/phases/__init__.py +66 -0
  59. schemathesis/{cli → engine/phases}/probes.py +63 -70
  60. schemathesis/engine/phases/stateful/__init__.py +65 -0
  61. schemathesis/engine/phases/stateful/_executor.py +326 -0
  62. schemathesis/engine/phases/stateful/context.py +85 -0
  63. schemathesis/engine/phases/unit/__init__.py +174 -0
  64. schemathesis/engine/phases/unit/_executor.py +321 -0
  65. schemathesis/engine/phases/unit/_pool.py +74 -0
  66. schemathesis/engine/recorder.py +241 -0
  67. schemathesis/errors.py +31 -0
  68. schemathesis/experimental/__init__.py +18 -14
  69. schemathesis/filters.py +103 -14
  70. schemathesis/generation/__init__.py +21 -37
  71. schemathesis/generation/case.py +190 -0
  72. schemathesis/generation/coverage.py +931 -0
  73. schemathesis/generation/hypothesis/__init__.py +30 -0
  74. schemathesis/generation/hypothesis/builder.py +585 -0
  75. schemathesis/generation/hypothesis/examples.py +50 -0
  76. schemathesis/generation/hypothesis/given.py +66 -0
  77. schemathesis/generation/hypothesis/reporting.py +14 -0
  78. schemathesis/generation/hypothesis/strategies.py +16 -0
  79. schemathesis/generation/meta.py +115 -0
  80. schemathesis/generation/modes.py +28 -0
  81. schemathesis/generation/overrides.py +96 -0
  82. schemathesis/generation/stateful/__init__.py +20 -0
  83. schemathesis/{stateful → generation/stateful}/state_machine.py +68 -81
  84. schemathesis/generation/targets.py +69 -0
  85. schemathesis/graphql/__init__.py +15 -0
  86. schemathesis/graphql/checks.py +115 -0
  87. schemathesis/graphql/loaders.py +131 -0
  88. schemathesis/hooks.py +99 -67
  89. schemathesis/openapi/__init__.py +13 -0
  90. schemathesis/openapi/checks.py +412 -0
  91. schemathesis/openapi/generation/__init__.py +0 -0
  92. schemathesis/openapi/generation/filters.py +63 -0
  93. schemathesis/openapi/loaders.py +178 -0
  94. schemathesis/pytest/__init__.py +5 -0
  95. schemathesis/pytest/control_flow.py +7 -0
  96. schemathesis/pytest/lazy.py +273 -0
  97. schemathesis/pytest/loaders.py +12 -0
  98. schemathesis/{extra/pytest_plugin.py → pytest/plugin.py} +106 -127
  99. schemathesis/python/__init__.py +0 -0
  100. schemathesis/python/asgi.py +12 -0
  101. schemathesis/python/wsgi.py +12 -0
  102. schemathesis/schemas.py +537 -261
  103. schemathesis/specs/graphql/__init__.py +0 -1
  104. schemathesis/specs/graphql/_cache.py +25 -0
  105. schemathesis/specs/graphql/nodes.py +1 -0
  106. schemathesis/specs/graphql/scalars.py +7 -5
  107. schemathesis/specs/graphql/schemas.py +215 -187
  108. schemathesis/specs/graphql/validation.py +11 -18
  109. schemathesis/specs/openapi/__init__.py +7 -1
  110. schemathesis/specs/openapi/_cache.py +122 -0
  111. schemathesis/specs/openapi/_hypothesis.py +146 -165
  112. schemathesis/specs/openapi/checks.py +565 -67
  113. schemathesis/specs/openapi/converter.py +33 -6
  114. schemathesis/specs/openapi/definitions.py +11 -18
  115. schemathesis/specs/openapi/examples.py +153 -39
  116. schemathesis/specs/openapi/expressions/__init__.py +37 -2
  117. schemathesis/specs/openapi/expressions/context.py +4 -6
  118. schemathesis/specs/openapi/expressions/extractors.py +23 -0
  119. schemathesis/specs/openapi/expressions/lexer.py +20 -18
  120. schemathesis/specs/openapi/expressions/nodes.py +38 -14
  121. schemathesis/specs/openapi/expressions/parser.py +26 -5
  122. schemathesis/specs/openapi/formats.py +45 -0
  123. schemathesis/specs/openapi/links.py +65 -165
  124. schemathesis/specs/openapi/media_types.py +32 -0
  125. schemathesis/specs/openapi/negative/__init__.py +7 -3
  126. schemathesis/specs/openapi/negative/mutations.py +24 -8
  127. schemathesis/specs/openapi/parameters.py +46 -30
  128. schemathesis/specs/openapi/patterns.py +137 -0
  129. schemathesis/specs/openapi/references.py +47 -57
  130. schemathesis/specs/openapi/schemas.py +483 -367
  131. schemathesis/specs/openapi/security.py +25 -7
  132. schemathesis/specs/openapi/serialization.py +11 -6
  133. schemathesis/specs/openapi/stateful/__init__.py +185 -73
  134. schemathesis/specs/openapi/utils.py +6 -1
  135. schemathesis/transport/__init__.py +104 -0
  136. schemathesis/transport/asgi.py +26 -0
  137. schemathesis/transport/prepare.py +99 -0
  138. schemathesis/transport/requests.py +221 -0
  139. schemathesis/{_xml.py → transport/serialization.py} +143 -28
  140. schemathesis/transport/wsgi.py +165 -0
  141. schemathesis-4.0.0a1.dist-info/METADATA +297 -0
  142. schemathesis-4.0.0a1.dist-info/RECORD +152 -0
  143. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/WHEEL +1 -1
  144. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/entry_points.txt +1 -1
  145. schemathesis/_compat.py +0 -74
  146. schemathesis/_dependency_versions.py +0 -17
  147. schemathesis/_hypothesis.py +0 -246
  148. schemathesis/_override.py +0 -49
  149. schemathesis/cli/cassettes.py +0 -375
  150. schemathesis/cli/context.py +0 -55
  151. schemathesis/cli/debug.py +0 -26
  152. schemathesis/cli/handlers.py +0 -16
  153. schemathesis/cli/junitxml.py +0 -43
  154. schemathesis/cli/output/__init__.py +0 -1
  155. schemathesis/cli/output/default.py +0 -765
  156. schemathesis/cli/output/short.py +0 -40
  157. schemathesis/cli/sanitization.py +0 -20
  158. schemathesis/code_samples.py +0 -149
  159. schemathesis/constants.py +0 -55
  160. schemathesis/contrib/openapi/formats/__init__.py +0 -9
  161. schemathesis/contrib/openapi/formats/uuid.py +0 -15
  162. schemathesis/contrib/unique_data.py +0 -41
  163. schemathesis/exceptions.py +0 -560
  164. schemathesis/extra/_aiohttp.py +0 -27
  165. schemathesis/extra/_flask.py +0 -10
  166. schemathesis/extra/_server.py +0 -17
  167. schemathesis/failures.py +0 -209
  168. schemathesis/fixups/__init__.py +0 -36
  169. schemathesis/fixups/fast_api.py +0 -41
  170. schemathesis/fixups/utf8_bom.py +0 -29
  171. schemathesis/graphql.py +0 -4
  172. schemathesis/internal/__init__.py +0 -7
  173. schemathesis/internal/copy.py +0 -13
  174. schemathesis/internal/datetime.py +0 -5
  175. schemathesis/internal/deprecation.py +0 -34
  176. schemathesis/internal/jsonschema.py +0 -35
  177. schemathesis/internal/transformation.py +0 -15
  178. schemathesis/internal/validation.py +0 -34
  179. schemathesis/lazy.py +0 -361
  180. schemathesis/loaders.py +0 -120
  181. schemathesis/models.py +0 -1231
  182. schemathesis/parameters.py +0 -86
  183. schemathesis/runner/__init__.py +0 -555
  184. schemathesis/runner/events.py +0 -309
  185. schemathesis/runner/impl/__init__.py +0 -3
  186. schemathesis/runner/impl/core.py +0 -986
  187. schemathesis/runner/impl/solo.py +0 -90
  188. schemathesis/runner/impl/threadpool.py +0 -400
  189. schemathesis/runner/serialization.py +0 -411
  190. schemathesis/sanitization.py +0 -248
  191. schemathesis/serializers.py +0 -315
  192. schemathesis/service/__init__.py +0 -18
  193. schemathesis/service/auth.py +0 -11
  194. schemathesis/service/ci.py +0 -201
  195. schemathesis/service/client.py +0 -100
  196. schemathesis/service/constants.py +0 -38
  197. schemathesis/service/events.py +0 -57
  198. schemathesis/service/hosts.py +0 -107
  199. schemathesis/service/metadata.py +0 -46
  200. schemathesis/service/models.py +0 -49
  201. schemathesis/service/report.py +0 -255
  202. schemathesis/service/serialization.py +0 -184
  203. schemathesis/service/usage.py +0 -65
  204. schemathesis/specs/graphql/loaders.py +0 -344
  205. schemathesis/specs/openapi/filters.py +0 -49
  206. schemathesis/specs/openapi/loaders.py +0 -667
  207. schemathesis/specs/openapi/stateful/links.py +0 -92
  208. schemathesis/specs/openapi/validation.py +0 -25
  209. schemathesis/stateful/__init__.py +0 -133
  210. schemathesis/targets.py +0 -45
  211. schemathesis/throttling.py +0 -41
  212. schemathesis/transports/__init__.py +0 -5
  213. schemathesis/transports/auth.py +0 -15
  214. schemathesis/transports/headers.py +0 -35
  215. schemathesis/transports/responses.py +0 -52
  216. schemathesis/types.py +0 -35
  217. schemathesis/utils.py +0 -169
  218. schemathesis-3.25.5.dist-info/METADATA +0 -356
  219. schemathesis-3.25.5.dist-info/RECORD +0 -134
  220. /schemathesis/{extra → cli/ext}/__init__.py +0 -0
  221. {schemathesis-3.25.5.dist-info → schemathesis-4.0.0a1.dist-info}/licenses/LICENSE +0 -0
@@ -1,23 +1,48 @@
1
1
  from __future__ import annotations
2
- from typing import TYPE_CHECKING, Any, Generator, NoReturn
3
-
4
- from ... import failures
5
- from ...exceptions import (
6
- get_headers_error,
7
- get_malformed_media_type_error,
8
- get_missing_content_type_error,
9
- get_response_type_error,
10
- get_status_code_error,
2
+
3
+ import enum
4
+ import http.client
5
+ from dataclasses import dataclass
6
+ from http.cookies import SimpleCookie
7
+ from typing import TYPE_CHECKING, Any, Dict, Generator, NoReturn, cast
8
+ from urllib.parse import parse_qs, urlparse
9
+
10
+ import schemathesis
11
+ from schemathesis.checks import CheckContext
12
+ from schemathesis.core import media_types, string_to_boolean
13
+ from schemathesis.core.failures import Failure
14
+ from schemathesis.core.transport import Response
15
+ from schemathesis.generation.case import Case
16
+ from schemathesis.generation.meta import ComponentKind, CoveragePhaseData
17
+ from schemathesis.openapi.checks import (
18
+ AcceptedNegativeData,
19
+ EnsureResourceAvailability,
20
+ IgnoredAuth,
21
+ JsonSchemaError,
22
+ MalformedMediaType,
23
+ MissingContentType,
24
+ MissingHeaders,
25
+ MissingRequiredHeaderConfig,
26
+ NegativeDataRejectionConfig,
27
+ PositiveDataAcceptanceConfig,
28
+ RejectedPositiveData,
29
+ UndefinedContentType,
30
+ UndefinedStatusCode,
31
+ UseAfterFree,
11
32
  )
12
- from ...transports.content_types import parse_content_type
13
- from .utils import expand_status_code
33
+ from schemathesis.specs.openapi.constants import LOCATION_TO_CONTAINER
34
+ from schemathesis.transport.prepare import prepare_path
35
+
36
+ from .utils import expand_status_code, expand_status_codes
14
37
 
15
38
  if TYPE_CHECKING:
16
- from ...transports.responses import GenericResponse
17
- from ...models import Case
39
+ from requests import PreparedRequest
40
+
41
+ from ...schemas import APIOperation
18
42
 
19
43
 
20
- def status_code_conformance(response: GenericResponse, case: Case) -> bool | None:
44
+ @schemathesis.check
45
+ def status_code_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
21
46
  from .schemas import BaseOpenAPISchema
22
47
 
23
48
  if not isinstance(case.operation.schema, BaseOpenAPISchema):
@@ -30,15 +55,12 @@ def status_code_conformance(response: GenericResponse, case: Case) -> bool | Non
30
55
  if response.status_code not in allowed_status_codes:
31
56
  defined_status_codes = list(map(str, responses))
32
57
  responses_list = ", ".join(defined_status_codes)
33
- exc_class = get_status_code_error(response.status_code)
34
- raise exc_class(
35
- failures.UndefinedStatusCode.title,
36
- context=failures.UndefinedStatusCode(
37
- message=f"Received: {response.status_code}\nDocumented: {responses_list}",
38
- status_code=response.status_code,
39
- defined_status_codes=defined_status_codes,
40
- allowed_status_codes=allowed_status_codes,
41
- ),
58
+ raise UndefinedStatusCode(
59
+ operation=case.operation.label,
60
+ status_code=response.status_code,
61
+ defined_status_codes=defined_status_codes,
62
+ allowed_status_codes=allowed_status_codes,
63
+ message=f"Received: {response.status_code}\nDocumented: {responses_list}",
42
64
  )
43
65
  return None # explicitly return None for mypy
44
66
 
@@ -48,7 +70,8 @@ def _expand_responses(responses: dict[str | int, Any]) -> Generator[int, None, N
48
70
  yield from expand_status_code(code)
49
71
 
50
72
 
51
- def content_type_conformance(response: GenericResponse, case: Case) -> bool | None:
73
+ @schemathesis.check
74
+ def content_type_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
52
75
  from .schemas import BaseOpenAPISchema
53
76
 
54
77
  if not isinstance(case.operation.schema, BaseOpenAPISchema):
@@ -56,74 +79,549 @@ def content_type_conformance(response: GenericResponse, case: Case) -> bool | No
56
79
  documented_content_types = case.operation.schema.get_content_types(case.operation, response)
57
80
  if not documented_content_types:
58
81
  return None
59
- content_type = response.headers.get("Content-Type")
60
- if not content_type:
61
- formatted_content_types = [f"\n- `{content_type}`" for content_type in documented_content_types]
62
- raise get_missing_content_type_error()(
63
- failures.MissingContentType.title,
64
- context=failures.MissingContentType(
65
- message=f"The following media types are documented in the schema:{''.join(formatted_content_types)}",
66
- media_types=documented_content_types,
67
- ),
82
+ content_types = response.headers.get("content-type")
83
+ if not content_types:
84
+ all_media_types = [f"\n- `{content_type}`" for content_type in documented_content_types]
85
+ raise MissingContentType(
86
+ operation=case.operation.label,
87
+ message=f"The following media types are documented in the schema:{''.join(all_media_types)}",
88
+ media_types=documented_content_types,
68
89
  )
90
+ content_type = content_types[0]
69
91
  for option in documented_content_types:
70
92
  try:
71
- expected_main, expected_sub = parse_content_type(option)
72
- except ValueError as exc:
73
- _reraise_malformed_media_type(exc, "Schema", option, option)
93
+ expected_main, expected_sub = media_types.parse(option)
94
+ except ValueError:
95
+ _reraise_malformed_media_type(case, "Schema", option, option)
74
96
  try:
75
- received_main, received_sub = parse_content_type(content_type)
76
- except ValueError as exc:
77
- _reraise_malformed_media_type(exc, "Response", content_type, option)
78
- if (expected_main, expected_sub) == (received_main, received_sub):
97
+ received_main, received_sub = media_types.parse(content_type)
98
+ except ValueError:
99
+ _reraise_malformed_media_type(case, "Response", content_type, option)
100
+ if (
101
+ (expected_main == "*" and expected_sub == "*")
102
+ or (expected_main == received_main and expected_sub == "*")
103
+ or (expected_main == "*" and expected_sub == received_sub)
104
+ or (expected_main == received_main and expected_sub == received_sub)
105
+ ):
79
106
  return None
80
- exc_class = get_response_type_error(f"{expected_main}_{expected_sub}", f"{received_main}_{received_sub}")
81
- raise exc_class(
82
- failures.UndefinedContentType.title,
83
- context=failures.UndefinedContentType(
84
- message=f"Received: {content_type}\nDocumented: {', '.join(documented_content_types)}",
85
- content_type=content_type,
86
- defined_content_types=documented_content_types,
87
- ),
107
+ raise UndefinedContentType(
108
+ operation=case.operation.label,
109
+ message=f"Received: {content_type}\nDocumented: {', '.join(documented_content_types)}",
110
+ content_type=content_type,
111
+ defined_content_types=documented_content_types,
88
112
  )
89
113
 
90
114
 
91
- def _reraise_malformed_media_type(exc: ValueError, location: str, actual: str, defined: str) -> NoReturn:
92
- message = f"Media type for {location} is incorrect\n\nReceived: {actual}\nDocumented: {defined}"
93
- raise get_malformed_media_type_error(message)(
94
- failures.MalformedMediaType.title,
95
- context=failures.MalformedMediaType(message=message, actual=actual, defined=defined),
96
- ) from exc
115
+ def _reraise_malformed_media_type(case: Case, location: str, actual: str, defined: str) -> NoReturn:
116
+ raise MalformedMediaType(
117
+ operation=case.operation.label,
118
+ message=f"Media type for {location} is incorrect\n\nReceived: {actual}\nDocumented: {defined}",
119
+ actual=actual,
120
+ defined=defined,
121
+ )
122
+
97
123
 
124
+ @schemathesis.check
125
+ def response_headers_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
126
+ import jsonschema
98
127
 
99
- def response_headers_conformance(response: GenericResponse, case: Case) -> bool | None:
100
- from .schemas import BaseOpenAPISchema
128
+ from .parameters import OpenAPI20Parameter, OpenAPI30Parameter
129
+ from .schemas import BaseOpenAPISchema, OpenApi30, _maybe_raise_one_or_more
101
130
 
102
131
  if not isinstance(case.operation.schema, BaseOpenAPISchema):
103
132
  return True
104
- defined_headers = case.operation.schema.get_headers(case.operation, response)
133
+ resolved = case.operation.schema.get_headers(case.operation, response)
134
+ if not resolved:
135
+ return None
136
+ scopes, defined_headers = resolved
105
137
  if not defined_headers:
106
138
  return None
107
139
 
108
140
  missing_headers = [
109
141
  header
110
142
  for header, definition in defined_headers.items()
111
- if header not in response.headers and definition.get(case.operation.schema.header_required_field, False)
143
+ if header.lower() not in response.headers and definition.get(case.operation.schema.header_required_field, False)
112
144
  ]
113
- if not missing_headers:
145
+ errors: list[Failure] = []
146
+ if missing_headers:
147
+ formatted_headers = [f"\n- `{header}`" for header in missing_headers]
148
+ message = f"The following required headers are missing from the response:{''.join(formatted_headers)}"
149
+ errors.append(MissingHeaders(operation=case.operation.label, message=message, missing_headers=missing_headers))
150
+ for name, definition in defined_headers.items():
151
+ values = response.headers.get(name.lower())
152
+ if values is not None:
153
+ value = values[0]
154
+ with case.operation.schema._validating_response(scopes) as resolver:
155
+ if "$ref" in definition:
156
+ _, definition = resolver.resolve(definition["$ref"])
157
+ parameter_definition = {"in": "header", **definition}
158
+ parameter: OpenAPI20Parameter | OpenAPI30Parameter
159
+ if isinstance(case.operation.schema, OpenApi30):
160
+ parameter = OpenAPI30Parameter(parameter_definition)
161
+ else:
162
+ parameter = OpenAPI20Parameter(parameter_definition)
163
+ schema = parameter.as_json_schema(case.operation)
164
+ coerced = _coerce_header_value(value, schema)
165
+ try:
166
+ jsonschema.validate(
167
+ coerced,
168
+ schema,
169
+ cls=case.operation.schema.validator_cls,
170
+ resolver=resolver,
171
+ format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER,
172
+ )
173
+ except jsonschema.ValidationError as exc:
174
+ errors.append(
175
+ JsonSchemaError.from_exception(
176
+ title="Response header does not conform to the schema",
177
+ operation=case.operation.label,
178
+ exc=exc,
179
+ output_config=case.operation.schema.output_config,
180
+ )
181
+ )
182
+ return _maybe_raise_one_or_more(errors) # type: ignore[func-returns-value]
183
+
184
+
185
+ def _coerce_header_value(value: str, schema: dict[str, Any]) -> str | int | float | None | bool:
186
+ schema_type = schema.get("type")
187
+
188
+ if schema_type == "string":
189
+ return value
190
+ if schema_type == "integer":
191
+ try:
192
+ return int(value)
193
+ except ValueError:
194
+ return value
195
+ if schema_type == "number":
196
+ try:
197
+ return float(value)
198
+ except ValueError:
199
+ return value
200
+ if schema_type == "null" and value.lower() == "null":
114
201
  return None
115
- formatted_headers = [f"\n- `{header}`" for header in missing_headers]
116
- message = f"The following required headers are missing from the response:{''.join(formatted_headers)}"
117
- exc_class = get_headers_error(message)
118
- raise exc_class(
119
- failures.MissingHeaders.title,
120
- context=failures.MissingHeaders(message=message, missing_headers=missing_headers),
121
- )
202
+ if schema_type == "boolean":
203
+ return string_to_boolean(value)
204
+ return value
122
205
 
123
206
 
124
- def response_schema_conformance(response: GenericResponse, case: Case) -> bool | None:
207
+ @schemathesis.check
208
+ def response_schema_conformance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
125
209
  from .schemas import BaseOpenAPISchema
126
210
 
127
211
  if not isinstance(case.operation.schema, BaseOpenAPISchema):
128
212
  return True
129
213
  return case.operation.validate_response(response)
214
+
215
+
216
+ @schemathesis.check
217
+ def negative_data_rejection(ctx: CheckContext, response: Response, case: Case) -> bool | None:
218
+ from .schemas import BaseOpenAPISchema
219
+
220
+ if not isinstance(case.operation.schema, BaseOpenAPISchema) or case.meta is None:
221
+ return True
222
+
223
+ config = ctx.config.get(negative_data_rejection, NegativeDataRejectionConfig())
224
+ allowed_statuses = expand_status_codes(config.allowed_statuses or [])
225
+
226
+ if (
227
+ case.meta.generation.mode.is_negative
228
+ and response.status_code not in allowed_statuses
229
+ and not has_only_additional_properties_in_non_body_parameters(case)
230
+ ):
231
+ raise AcceptedNegativeData(
232
+ operation=case.operation.label,
233
+ message=f"Allowed statuses: {', '.join(config.allowed_statuses)}",
234
+ status_code=response.status_code,
235
+ allowed_statuses=config.allowed_statuses,
236
+ )
237
+ return None
238
+
239
+
240
+ @schemathesis.check
241
+ def positive_data_acceptance(ctx: CheckContext, response: Response, case: Case) -> bool | None:
242
+ from .schemas import BaseOpenAPISchema
243
+
244
+ if not isinstance(case.operation.schema, BaseOpenAPISchema) or case.meta is None:
245
+ return True
246
+
247
+ config = ctx.config.get(positive_data_acceptance, PositiveDataAcceptanceConfig())
248
+ allowed_statuses = expand_status_codes(config.allowed_statuses or [])
249
+
250
+ if case.meta.generation.mode.is_positive and response.status_code not in allowed_statuses:
251
+ raise RejectedPositiveData(
252
+ operation=case.operation.label,
253
+ message=f"Allowed statuses: {', '.join(config.allowed_statuses)}",
254
+ status_code=response.status_code,
255
+ allowed_statuses=config.allowed_statuses,
256
+ )
257
+ return None
258
+
259
+
260
+ def missing_required_header(ctx: CheckContext, response: Response, case: Case) -> bool | None:
261
+ # NOTE: This check is intentionally not registered with `@schemathesis.check` because it is experimental
262
+ meta = case.meta
263
+ if meta is None or not isinstance(meta.phase.data, CoveragePhaseData):
264
+ return None
265
+ data = meta.phase.data
266
+ if (
267
+ data.parameter
268
+ and data.parameter_location == "header"
269
+ and data.description
270
+ and data.description.startswith("Missing ")
271
+ ):
272
+ if data.parameter.lower() == "authorization":
273
+ allowed_statuses = {401}
274
+ else:
275
+ config = ctx.config.get(missing_required_header, MissingRequiredHeaderConfig())
276
+ allowed_statuses = expand_status_codes(config.allowed_statuses or [])
277
+ if response.status_code not in allowed_statuses:
278
+ allowed = f"Allowed statuses: {', '.join(map(str, allowed_statuses))}"
279
+ raise AssertionError(f"Unexpected response status for a missing header: {response.status_code}\n{allowed}")
280
+ return None
281
+
282
+
283
+ def unsupported_method(ctx: CheckContext, response: Response, case: Case) -> bool | None:
284
+ meta = case.meta
285
+ if meta is None or not isinstance(meta.phase.data, CoveragePhaseData):
286
+ return None
287
+ data = meta.phase.data
288
+ if data.description and data.description.startswith("Unspecified HTTP method:"):
289
+ if response.status_code != 405:
290
+ raise AssertionError(
291
+ f"Unexpected response status for unspecified HTTP method: {response.status_code}\nExpected: 405"
292
+ )
293
+
294
+ allow_header = response.headers.get("allow")
295
+ if not allow_header:
296
+ raise AssertionError("Missing 'Allow' header in 405 Method Not Allowed response")
297
+ return None
298
+
299
+
300
+ def has_only_additional_properties_in_non_body_parameters(case: Case) -> bool:
301
+ # Check if the case contains only additional properties in query, headers, or cookies.
302
+ # This function is used to determine if negation is solely in the form of extra properties,
303
+ # which are often ignored for backward-compatibility by the tested apps
304
+ from ._hypothesis import get_schema_for_location
305
+
306
+ meta = case.meta
307
+ if meta is None:
308
+ # Ignore manually created cases
309
+ return False
310
+ if (ComponentKind.BODY in meta.components and meta.components[ComponentKind.BODY].mode.is_negative) or (
311
+ ComponentKind.PATH_PARAMETERS in meta.components
312
+ and meta.components[ComponentKind.PATH_PARAMETERS].mode.is_negative
313
+ ):
314
+ # Body or path negations always imply other negations
315
+ return False
316
+ validator_cls = case.operation.schema.validator_cls # type: ignore[attr-defined]
317
+ for container in (ComponentKind.QUERY, ComponentKind.HEADERS, ComponentKind.COOKIES):
318
+ meta_for_location = meta.components.get(container)
319
+ value = getattr(case, container.value)
320
+ if value is not None and meta_for_location is not None and meta_for_location.mode.is_negative:
321
+ parameters = getattr(case.operation, container)
322
+ value_without_additional_properties = {k: v for k, v in value.items() if k in parameters}
323
+ schema = get_schema_for_location(case.operation, container, parameters)
324
+ if not validator_cls(schema).is_valid(value_without_additional_properties):
325
+ # Other types of negation found
326
+ return False
327
+ # Only additional properties are added
328
+ return True
329
+
330
+
331
+ @schemathesis.check
332
+ def use_after_free(ctx: CheckContext, response: Response, case: Case) -> bool | None:
333
+ from .schemas import BaseOpenAPISchema
334
+
335
+ if not isinstance(case.operation.schema, BaseOpenAPISchema):
336
+ return True
337
+ if response.status_code == 404 or response.status_code >= 500:
338
+ return None
339
+
340
+ for related_case in ctx.find_related(case_id=case.id):
341
+ parent = ctx.find_parent(case_id=related_case.id)
342
+ if not parent:
343
+ continue
344
+
345
+ parent_response = ctx.find_response(case_id=parent.id)
346
+
347
+ if (
348
+ related_case.operation.method.lower() == "delete"
349
+ and parent_response is not None
350
+ and 200 <= parent_response.status_code < 300
351
+ ):
352
+ if _is_prefix_operation(
353
+ ResourcePath(related_case.path, related_case.path_parameters or {}),
354
+ ResourcePath(case.path, case.path_parameters or {}),
355
+ ):
356
+ free = f"{related_case.operation.method.upper()} {prepare_path(related_case.path, related_case.path_parameters)}"
357
+ usage = f"{case.operation.method.upper()} {prepare_path(case.path, case.path_parameters)}"
358
+ reason = http.client.responses.get(response.status_code, "Unknown")
359
+ raise UseAfterFree(
360
+ operation=related_case.operation.label,
361
+ message=(
362
+ "The API did not return a `HTTP 404 Not Found` response "
363
+ f"(got `HTTP {response.status_code} {reason}`) for a resource that was previously deleted.\n\nThe resource was deleted with `{free}`"
364
+ ),
365
+ free=free,
366
+ usage=usage,
367
+ )
368
+
369
+ return None
370
+
371
+
372
+ @schemathesis.check
373
+ def ensure_resource_availability(ctx: CheckContext, response: Response, case: Case) -> bool | None:
374
+ from .schemas import BaseOpenAPISchema
375
+
376
+ if not isinstance(case.operation.schema, BaseOpenAPISchema):
377
+ return True
378
+
379
+ parent = ctx.find_parent(case_id=case.id)
380
+ if parent is None:
381
+ return None
382
+ parent_response = ctx.find_response(case_id=parent.id)
383
+ if parent_response is None:
384
+ return None
385
+
386
+ overrides = case._override
387
+ overrides_all_parameters = True
388
+ for parameter in case.operation.iter_parameters():
389
+ container = LOCATION_TO_CONTAINER[parameter.location]
390
+ if parameter.name not in getattr(overrides, container, {}):
391
+ overrides_all_parameters = False
392
+ break
393
+
394
+ if (
395
+ # Response indicates a client error, even though all available parameters were taken from links
396
+ # and comes from a POST request. This case likely means that the POST request actually did not
397
+ # save the resource and it is not available for subsequent operations
398
+ 400 <= response.status_code < 500
399
+ and parent.operation.method.upper() == "POST"
400
+ and 200 <= parent_response.status_code < 400
401
+ and overrides_all_parameters
402
+ and _is_prefix_operation(
403
+ ResourcePath(parent.path, parent.path_parameters or {}),
404
+ ResourcePath(case.path, case.path_parameters or {}),
405
+ )
406
+ ):
407
+ created_with = parent.operation.label
408
+ not_available_with = case.operation.label
409
+ reason = http.client.responses.get(response.status_code, "Unknown")
410
+ raise EnsureResourceAvailability(
411
+ operation=created_with,
412
+ message=(
413
+ f"The API returned `{response.status_code} {reason}` for a resource that was just created.\n\n"
414
+ f"Created with : `{created_with}`\n"
415
+ f"Not available with: `{not_available_with}`"
416
+ ),
417
+ created_with=created_with,
418
+ not_available_with=not_available_with,
419
+ )
420
+ return None
421
+
422
+
423
+ class AuthKind(enum.Enum):
424
+ EXPLICIT = "explicit"
425
+ GENERATED = "generated"
426
+
427
+
428
+ @schemathesis.check
429
+ def ignored_auth(ctx: CheckContext, response: Response, case: Case) -> bool | None:
430
+ """Check if an operation declares authentication as a requirement but does not actually enforce it."""
431
+ from .schemas import BaseOpenAPISchema
432
+
433
+ if not isinstance(case.operation.schema, BaseOpenAPISchema):
434
+ return True
435
+ security_parameters = _get_security_parameters(case.operation)
436
+ # Authentication is required for this API operation and response is successful
437
+ if security_parameters and 200 <= response.status_code < 300:
438
+ auth = _contains_auth(ctx, case, response.request, security_parameters)
439
+ if auth == AuthKind.EXPLICIT:
440
+ # Auth is explicitly set, it is expected to be valid
441
+ # Check if invalid auth will give an error
442
+ no_auth_case = remove_auth(case, security_parameters)
443
+ kwargs = ctx.transport_kwargs or {}
444
+ kwargs.copy()
445
+ if "headers" in kwargs:
446
+ headers = kwargs["headers"].copy()
447
+ _remove_auth_from_explicit_headers(headers, security_parameters)
448
+ kwargs["headers"] = headers
449
+ kwargs.pop("session", None)
450
+ ctx.record_case(parent_id=case.id, case=no_auth_case)
451
+ no_auth_response = case.operation.schema.transport.send(no_auth_case, **kwargs)
452
+ ctx.record_response(case_id=no_auth_case.id, response=no_auth_response)
453
+ if no_auth_response.status_code != 401:
454
+ _raise_no_auth_error(no_auth_response, no_auth_case, "that requires authentication")
455
+ # Try to set invalid auth and check if it succeeds
456
+ for parameter in security_parameters:
457
+ invalid_auth_case = remove_auth(case, security_parameters)
458
+ _set_auth_for_case(invalid_auth_case, parameter)
459
+ ctx.record_case(parent_id=case.id, case=invalid_auth_case)
460
+ invalid_auth_response = case.operation.schema.transport.send(invalid_auth_case, **kwargs)
461
+ ctx.record_response(case_id=invalid_auth_case.id, response=invalid_auth_response)
462
+ if invalid_auth_response.status_code != 401:
463
+ _raise_no_auth_error(invalid_auth_response, invalid_auth_case, "with any auth")
464
+ elif auth == AuthKind.GENERATED:
465
+ # If this auth is generated which means it is likely invalid, then
466
+ # this request should have been an error
467
+ _raise_no_auth_error(response, case, "with invalid auth")
468
+ else:
469
+ # Successful response when there is no auth
470
+ _raise_no_auth_error(response, case, "that requires authentication")
471
+ return None
472
+
473
+
474
+ def _raise_no_auth_error(response: Response, case: Case, suffix: str) -> NoReturn:
475
+ reason = http.client.responses.get(response.status_code, "Unknown")
476
+ raise IgnoredAuth(
477
+ operation=case.operation.label,
478
+ message=f"The API returned `{response.status_code} {reason}` for `{case.operation.label}` {suffix}.",
479
+ case_id=case.id,
480
+ )
481
+
482
+
483
+ SecurityParameter = Dict[str, Any]
484
+
485
+
486
+ def _get_security_parameters(operation: APIOperation) -> list[SecurityParameter]:
487
+ """Extract security definitions that are active for the given operation and convert them into parameters."""
488
+ from .schemas import BaseOpenAPISchema
489
+
490
+ schema = cast(BaseOpenAPISchema, operation.schema)
491
+ return [
492
+ schema.security._to_parameter(parameter)
493
+ for parameter in schema.security._get_active_definitions(schema.raw_schema, operation, schema.resolver)
494
+ if parameter["type"] in ("apiKey", "basic", "http")
495
+ ]
496
+
497
+
498
+ def _contains_auth(
499
+ ctx: CheckContext, case: Case, request: PreparedRequest, security_parameters: list[SecurityParameter]
500
+ ) -> AuthKind | None:
501
+ """Whether a request has authentication declared in the schema."""
502
+ from requests.cookies import RequestsCookieJar
503
+
504
+ # If auth comes from explicit `auth` option or a custom auth, it is always explicit
505
+ if ctx.auth is not None or case._has_explicit_auth:
506
+ return AuthKind.EXPLICIT
507
+ parsed = urlparse(request.url)
508
+ query = parse_qs(parsed.query) # type: ignore
509
+ # Load the `Cookie` header separately, because it is possible that `request._cookies` and the header are out of sync
510
+ header_cookies: SimpleCookie = SimpleCookie()
511
+ raw_cookie = request.headers.get("Cookie")
512
+ if raw_cookie is not None:
513
+ header_cookies.load(raw_cookie)
514
+
515
+ def has_header(p: dict[str, Any]) -> bool:
516
+ return p["in"] == "header" and p["name"] in request.headers
517
+
518
+ def has_query(p: dict[str, Any]) -> bool:
519
+ return p["in"] == "query" and p["name"] in query
520
+
521
+ def has_cookie(p: dict[str, Any]) -> bool:
522
+ cookies = cast(RequestsCookieJar, request._cookies) # type: ignore
523
+ return p["in"] == "cookie" and (p["name"] in cookies or p["name"] in header_cookies)
524
+
525
+ for parameter in security_parameters:
526
+ name = parameter["name"]
527
+ if has_header(parameter):
528
+ if (ctx.headers is not None and name in ctx.headers) or (ctx.override and name in ctx.override.headers):
529
+ return AuthKind.EXPLICIT
530
+ return AuthKind.GENERATED
531
+ if has_cookie(parameter):
532
+ if ctx.headers is not None and "Cookie" in ctx.headers:
533
+ cookies = cast(RequestsCookieJar, ctx.headers["Cookie"]) # type: ignore
534
+ if name in cookies:
535
+ return AuthKind.EXPLICIT
536
+ if ctx.override and name in ctx.override.cookies:
537
+ return AuthKind.EXPLICIT
538
+ return AuthKind.GENERATED
539
+ if has_query(parameter):
540
+ if ctx.override and name in ctx.override.query:
541
+ return AuthKind.EXPLICIT
542
+ return AuthKind.GENERATED
543
+
544
+ return None
545
+
546
+
547
+ def remove_auth(case: Case, security_parameters: list[SecurityParameter]) -> Case:
548
+ """Remove security parameters from a generated case.
549
+
550
+ It mutates `case` in place.
551
+ """
552
+ headers = case.headers.copy() if case.headers else None
553
+ query = case.query.copy() if case.query else None
554
+ cookies = case.cookies.copy() if case.cookies else None
555
+ for parameter in security_parameters:
556
+ name = parameter["name"]
557
+ if parameter["in"] == "header" and headers:
558
+ headers.pop(name, None)
559
+ if parameter["in"] == "query" and query:
560
+ query.pop(name, None)
561
+ if parameter["in"] == "cookie" and cookies:
562
+ cookies.pop(name, None)
563
+ return Case(
564
+ operation=case.operation,
565
+ method=case.method,
566
+ path=case.path,
567
+ path_parameters=case.path_parameters.copy() if case.path_parameters else None,
568
+ headers=headers,
569
+ cookies=cookies,
570
+ query=query,
571
+ body=case.body.copy() if isinstance(case.body, (list, dict)) else case.body,
572
+ media_type=case.media_type,
573
+ meta=case.meta,
574
+ )
575
+
576
+
577
+ def _remove_auth_from_explicit_headers(headers: dict, security_parameters: list[SecurityParameter]) -> None:
578
+ for parameter in security_parameters:
579
+ name = parameter["name"]
580
+ if parameter["in"] == "header":
581
+ headers.pop(name, None)
582
+
583
+
584
+ def _set_auth_for_case(case: Case, parameter: SecurityParameter) -> None:
585
+ name = parameter["name"]
586
+ for location, attr_name in (
587
+ ("header", "headers"),
588
+ ("query", "query"),
589
+ ("cookie", "cookies"),
590
+ ):
591
+ if parameter["in"] == location:
592
+ container = getattr(case, attr_name, {})
593
+ container[name] = "SCHEMATHESIS-INVALID-VALUE"
594
+ setattr(case, attr_name, container)
595
+
596
+
597
+ @dataclass
598
+ class ResourcePath:
599
+ """A path to a resource with variables."""
600
+
601
+ value: str
602
+ variables: dict[str, str]
603
+
604
+ __slots__ = ("value", "variables")
605
+
606
+ def get(self, key: str) -> str:
607
+ return self.variables[key.lstrip("{").rstrip("}")]
608
+
609
+
610
+ def _is_prefix_operation(lhs: ResourcePath, rhs: ResourcePath) -> bool:
611
+ lhs_parts = lhs.value.rstrip("/").split("/")
612
+ rhs_parts = rhs.value.rstrip("/").split("/")
613
+
614
+ # Left has more parts, can't be a prefix
615
+ if len(lhs_parts) > len(rhs_parts):
616
+ return False
617
+
618
+ for left, right in zip(lhs_parts, rhs_parts):
619
+ if left.startswith("{") and right.startswith("{"):
620
+ if str(lhs.get(left)) != str(rhs.get(right)):
621
+ return False
622
+ elif left != right and left.rstrip("s") != right.rstrip("s"):
623
+ # Parts don't match, not a prefix
624
+ return False
625
+
626
+ # If we've reached this point, the LHS path is a prefix of the RHS path
627
+ return True