schemathesis 3.33.3__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/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 FilterSet, FilterValue, MatcherFunc, RegexValue, filter_set_from_components, is_deprecated
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 typing import TYPE_CHECKING, Any, Generator, NoReturn
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 ...models import Case
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
- from .schemas import BaseOpenAPISchema
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
- defined_headers = case.operation.schema.get_headers(case.operation, response)
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
- if not missing_headers:
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
- formatted_headers = [f"\n- `{header}`" for header in missing_headers]
127
- message = f"The following required headers are missing from the response:{''.join(formatted_headers)}"
128
- exc_class = get_headers_error(case.operation.verbose_name, message)
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."""
@@ -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 typing import TYPE_CHECKING, Any, Generator, NoReturn, Sequence, TypedDict, Union
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[str | None, str, str]] = field(init=False)
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
- case.set_source(context.response, context.case, elapsed)
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(self, case: Case, context: expressions.ExpressionContext) -> None:
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
- container[name] = expressions.evaluate(expression, context)
233
-
234
- def set_body(self, case: Case, context: expressions.ExpressionContext) -> None:
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(case: Case, location: str | None, name: str) -> dict[str, Any] | None:
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(parameter: str, expression: str) -> tuple[str | None, str, str]:
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
- return location, name, expression
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(self, operation: APIOperation, response: GenericResponse) -> dict[str, Any] | None:
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
- _, response = self.resolver.resolve_in_scope(responses[status_code], operation.definition.scope)
549
- return response
550
+ return self.resolver.resolve_in_scope(responses[status_code], operation.definition.scope)
550
551
  if "default" in responses:
551
- _, response = self.resolver.resolve_in_scope(responses["default"], operation.definition.scope)
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(self, operation: APIOperation, response: GenericResponse) -> dict[str, dict[str, Any]] | None:
556
- definitions = self._get_response_definitions(operation, response)
557
- if not definitions:
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
- return definitions.get("headers")
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
- resolver = ConvertingResolver(
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(data, schema, cls=self.validator_cls, resolver=resolver)
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
- definitions = self._get_response_definitions(operation, response)
1120
- if not definitions:
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:
@@ -5,7 +5,7 @@ from functools import lru_cache
5
5
  from typing import TYPE_CHECKING, Any, Callable, ClassVar, Iterator
6
6
 
7
7
  from hypothesis import strategies as st
8
- from hypothesis.stateful import Bundle, Rule, rule
8
+ from hypothesis.stateful import Bundle, Rule, precondition, rule
9
9
 
10
10
  from ....constants import NOT_SET
11
11
  from ....internal.result import Ok
@@ -93,10 +93,11 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
93
93
  for data_generation_method in schema.data_generation_methods
94
94
  ]
95
95
  )
96
+ bundle = bundles[bundle_name]
96
97
  rules[name] = transition(
97
98
  name=name,
98
99
  target=catch_all,
99
- previous=bundles[bundle_name],
100
+ previous=bundle,
100
101
  case=case_strategy,
101
102
  link=st.just(link),
102
103
  )
@@ -116,11 +117,13 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
116
117
  for data_generation_method in schema.data_generation_methods
117
118
  ]
118
119
  )
119
- rules[name] = transition(
120
- name=name,
121
- target=catch_all,
122
- previous=st.none(),
123
- case=case_strategy,
120
+ rules[name] = precondition(ensure_links_followed)(
121
+ transition(
122
+ name=name,
123
+ target=catch_all,
124
+ previous=st.none(),
125
+ case=case_strategy,
126
+ )
124
127
  )
125
128
 
126
129
  return type(
@@ -136,6 +139,14 @@ def create_state_machine(schema: BaseOpenAPISchema) -> type[APIStateMachine]:
136
139
  )
137
140
 
138
141
 
142
+ def ensure_links_followed(machine: APIStateMachine) -> bool:
143
+ # If there are responses that have links to follow, reject any rule without incoming transitions
144
+ for bundle in machine.bundles.values():
145
+ if bundle:
146
+ return False
147
+ return True
148
+
149
+
139
150
  def transition(
140
151
  *,
141
152
  name: str,