schemathesis 4.0.0a11__py3-none-any.whl → 4.0.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- schemathesis/__init__.py +35 -27
- schemathesis/auths.py +85 -54
- schemathesis/checks.py +65 -36
- schemathesis/cli/commands/run/__init__.py +32 -27
- schemathesis/cli/commands/run/context.py +6 -1
- schemathesis/cli/commands/run/events.py +7 -1
- schemathesis/cli/commands/run/executor.py +12 -7
- schemathesis/cli/commands/run/handlers/output.py +188 -80
- schemathesis/cli/commands/run/validation.py +21 -6
- schemathesis/cli/constants.py +1 -1
- schemathesis/config/__init__.py +2 -1
- schemathesis/config/_generation.py +12 -13
- schemathesis/config/_operations.py +14 -0
- schemathesis/config/_phases.py +41 -5
- schemathesis/config/_projects.py +33 -1
- schemathesis/config/_report.py +6 -2
- schemathesis/config/_warnings.py +25 -0
- schemathesis/config/schema.json +49 -1
- schemathesis/core/errors.py +15 -19
- schemathesis/core/transport.py +117 -2
- schemathesis/engine/context.py +1 -0
- schemathesis/engine/errors.py +61 -2
- schemathesis/engine/events.py +10 -2
- schemathesis/engine/phases/probes.py +3 -0
- schemathesis/engine/phases/stateful/__init__.py +2 -1
- schemathesis/engine/phases/stateful/_executor.py +38 -5
- schemathesis/engine/phases/stateful/context.py +2 -2
- schemathesis/engine/phases/unit/_executor.py +36 -7
- schemathesis/generation/__init__.py +0 -3
- schemathesis/generation/case.py +153 -28
- schemathesis/generation/coverage.py +1 -1
- schemathesis/generation/hypothesis/builder.py +43 -19
- schemathesis/generation/metrics.py +93 -0
- schemathesis/generation/modes.py +0 -8
- schemathesis/generation/overrides.py +11 -27
- schemathesis/generation/stateful/__init__.py +17 -0
- schemathesis/generation/stateful/state_machine.py +32 -108
- schemathesis/graphql/loaders.py +152 -8
- schemathesis/hooks.py +63 -39
- schemathesis/openapi/checks.py +82 -20
- schemathesis/openapi/generation/filters.py +9 -2
- schemathesis/openapi/loaders.py +134 -8
- schemathesis/pytest/lazy.py +4 -31
- schemathesis/pytest/loaders.py +24 -0
- schemathesis/pytest/plugin.py +38 -6
- schemathesis/schemas.py +161 -94
- schemathesis/specs/graphql/scalars.py +37 -3
- schemathesis/specs/graphql/schemas.py +18 -9
- schemathesis/specs/openapi/_hypothesis.py +53 -34
- schemathesis/specs/openapi/checks.py +111 -47
- schemathesis/specs/openapi/expressions/nodes.py +1 -1
- schemathesis/specs/openapi/formats.py +30 -3
- schemathesis/specs/openapi/media_types.py +44 -1
- schemathesis/specs/openapi/negative/__init__.py +5 -3
- schemathesis/specs/openapi/negative/mutations.py +2 -2
- schemathesis/specs/openapi/parameters.py +0 -3
- schemathesis/specs/openapi/schemas.py +14 -93
- schemathesis/specs/openapi/stateful/__init__.py +2 -1
- schemathesis/specs/openapi/stateful/links.py +1 -63
- schemathesis/transport/__init__.py +54 -16
- schemathesis/transport/prepare.py +31 -7
- schemathesis/transport/requests.py +21 -9
- schemathesis/transport/serialization.py +0 -4
- schemathesis/transport/wsgi.py +15 -8
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/METADATA +45 -87
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/RECORD +69 -71
- schemathesis/contrib/__init__.py +0 -9
- schemathesis/contrib/openapi/__init__.py +0 -9
- schemathesis/contrib/openapi/fill_missing_examples.py +0 -20
- schemathesis/generation/targets.py +0 -69
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a11.dist-info → schemathesis-4.0.0b1.dist-info}/licenses/LICENSE +0 -0
schemathesis/schemas.py
CHANGED
@@ -40,8 +40,11 @@ from .filters import (
|
|
40
40
|
from .hooks import GLOBAL_HOOK_DISPATCHER, HookContext, HookDispatcher, HookScope, dispatch, to_filterable_hook
|
41
41
|
|
42
42
|
if TYPE_CHECKING:
|
43
|
+
import httpx
|
44
|
+
import requests
|
43
45
|
from hypothesis.strategies import SearchStrategy
|
44
|
-
from
|
46
|
+
from requests.structures import CaseInsensitiveDict
|
47
|
+
from werkzeug.test import TestResponse
|
45
48
|
|
46
49
|
from schemathesis.core import Specification
|
47
50
|
from schemathesis.generation.stateful.state_machine import APIStateMachine
|
@@ -136,7 +139,25 @@ class BaseSchema(Mapping):
|
|
136
139
|
operation_id: FilterValue | None = None,
|
137
140
|
operation_id_regex: RegexValue | None = None,
|
138
141
|
) -> BaseSchema:
|
139
|
-
"""
|
142
|
+
"""Return a new schema containing only operations matching the specified criteria.
|
143
|
+
|
144
|
+
Args:
|
145
|
+
func: Custom filter function that accepts operation context.
|
146
|
+
name: Operation name(s) to include.
|
147
|
+
name_regex: Regex pattern for operation names.
|
148
|
+
method: HTTP method(s) to include.
|
149
|
+
method_regex: Regex pattern for HTTP methods.
|
150
|
+
path: API path(s) to include.
|
151
|
+
path_regex: Regex pattern for API paths.
|
152
|
+
tag: OpenAPI tag(s) to include.
|
153
|
+
tag_regex: Regex pattern for OpenAPI tags.
|
154
|
+
operation_id: Operation ID(s) to include.
|
155
|
+
operation_id_regex: Regex pattern for operation IDs.
|
156
|
+
|
157
|
+
Returns:
|
158
|
+
New schema instance with applied include filters.
|
159
|
+
|
160
|
+
"""
|
140
161
|
filter_set = self.filter_set.clone()
|
141
162
|
filter_set.include(
|
142
163
|
func,
|
@@ -169,7 +190,26 @@ class BaseSchema(Mapping):
|
|
169
190
|
operation_id_regex: RegexValue | None = None,
|
170
191
|
deprecated: bool = False,
|
171
192
|
) -> BaseSchema:
|
172
|
-
"""
|
193
|
+
"""Return a new schema excluding operations matching the specified criteria.
|
194
|
+
|
195
|
+
Args:
|
196
|
+
func: Custom filter function that accepts operation context.
|
197
|
+
name: Operation name(s) to exclude.
|
198
|
+
name_regex: Regex pattern for operation names.
|
199
|
+
method: HTTP method(s) to exclude.
|
200
|
+
method_regex: Regex pattern for HTTP methods.
|
201
|
+
path: API path(s) to exclude.
|
202
|
+
path_regex: Regex pattern for API paths.
|
203
|
+
tag: OpenAPI tag(s) to exclude.
|
204
|
+
tag_regex: Regex pattern for OpenAPI tags.
|
205
|
+
operation_id: Operation ID(s) to exclude.
|
206
|
+
operation_id_regex: Regex pattern for operation IDs.
|
207
|
+
deprecated: Whether to exclude deprecated operations.
|
208
|
+
|
209
|
+
Returns:
|
210
|
+
New schema instance with applied exclude filters.
|
211
|
+
|
212
|
+
"""
|
173
213
|
filter_set = self.filter_set.clone()
|
174
214
|
if deprecated:
|
175
215
|
if func is None:
|
@@ -211,10 +251,15 @@ class BaseSchema(Mapping):
|
|
211
251
|
return self.statistic.operations.total
|
212
252
|
|
213
253
|
def hook(self, hook: str | Callable) -> Callable:
|
214
|
-
|
254
|
+
"""Register a hook function for this schema only.
|
255
|
+
|
256
|
+
Args:
|
257
|
+
hook: Hook name string or hook function to register.
|
258
|
+
|
259
|
+
"""
|
260
|
+
return self.hooks.hook(hook)
|
215
261
|
|
216
262
|
def get_full_path(self, path: str) -> str:
|
217
|
-
"""Compute full path for the given path."""
|
218
263
|
return get_full_path(self.base_path, path)
|
219
264
|
|
220
265
|
@property
|
@@ -258,19 +303,27 @@ class BaseSchema(Mapping):
|
|
258
303
|
raise NotImplementedError
|
259
304
|
|
260
305
|
def get_strategies_from_examples(self, operation: APIOperation, **kwargs: Any) -> list[SearchStrategy[Case]]:
|
261
|
-
"""Get examples from the API operation."""
|
262
306
|
raise NotImplementedError
|
263
307
|
|
264
308
|
def get_security_requirements(self, operation: APIOperation) -> list[str]:
|
265
|
-
"""Get applied security requirements for the given API operation."""
|
266
309
|
raise NotImplementedError
|
267
310
|
|
268
311
|
def get_parameter_serializer(self, operation: APIOperation, location: str) -> Callable | None:
|
269
|
-
"""Get a function that serializes parameters for the given location."""
|
270
312
|
raise NotImplementedError
|
271
313
|
|
272
314
|
def parametrize(self) -> Callable:
|
273
|
-
"""
|
315
|
+
"""Return a decorator that marks a test function for `pytest` parametrization.
|
316
|
+
|
317
|
+
The decorated test function will be parametrized with test cases generated
|
318
|
+
from the schema's API operations.
|
319
|
+
|
320
|
+
Returns:
|
321
|
+
Decorator function for test parametrization.
|
322
|
+
|
323
|
+
Raises:
|
324
|
+
IncorrectUsage: If applied to the same function multiple times.
|
325
|
+
|
326
|
+
"""
|
274
327
|
|
275
328
|
def wrapper(func: Callable) -> Callable:
|
276
329
|
from schemathesis.pytest.plugin import SchemaHandleMark
|
@@ -293,7 +346,13 @@ class BaseSchema(Mapping):
|
|
293
346
|
return wrapper
|
294
347
|
|
295
348
|
def given(self, *args: GivenInput, **kwargs: GivenInput) -> Callable:
|
296
|
-
"""Proxy Hypothesis
|
349
|
+
"""Proxy to Hypothesis's `given` decorator for adding custom strategies.
|
350
|
+
|
351
|
+
Args:
|
352
|
+
*args: Positional arguments passed to `hypothesis.given`.
|
353
|
+
**kwargs: Keyword arguments passed to `hypothesis.given`.
|
354
|
+
|
355
|
+
"""
|
297
356
|
return given_proxy(*args, **kwargs)
|
298
357
|
|
299
358
|
def clone(
|
@@ -320,7 +379,6 @@ class BaseSchema(Mapping):
|
|
320
379
|
)
|
321
380
|
|
322
381
|
def get_local_hook_dispatcher(self) -> HookDispatcher | None:
|
323
|
-
"""Get a HookDispatcher instance bound to the test if present."""
|
324
382
|
# It might be not present when it is used without pytest via `APIOperation.as_strategy()`
|
325
383
|
if self.test_function is not None:
|
326
384
|
# Might be missing it in case of `LazySchema` usage
|
@@ -328,7 +386,6 @@ class BaseSchema(Mapping):
|
|
328
386
|
return None
|
329
387
|
|
330
388
|
def dispatch_hook(self, name: str, context: HookContext, *args: Any, **kwargs: Any) -> None:
|
331
|
-
"""Dispatch a hook via all available dispatchers."""
|
332
389
|
dispatch(name, context, *args, **kwargs)
|
333
390
|
self.hooks.dispatch(name, context, *args, **kwargs)
|
334
391
|
local_dispatcher = self.get_local_hook_dispatcher()
|
@@ -338,10 +395,6 @@ class BaseSchema(Mapping):
|
|
338
395
|
def prepare_multipart(
|
339
396
|
self, form_data: dict[str, Any], operation: APIOperation
|
340
397
|
) -> tuple[list | None, dict[str, Any] | None]:
|
341
|
-
"""Split content of `form_data` into files & data.
|
342
|
-
|
343
|
-
Forms may contain file fields, that we should send via `files` argument in `requests`.
|
344
|
-
"""
|
345
398
|
raise NotImplementedError
|
346
399
|
|
347
400
|
def get_request_payload_content_types(self, operation: APIOperation) -> list[str]:
|
@@ -354,7 +407,7 @@ class BaseSchema(Mapping):
|
|
354
407
|
method: str | None = None,
|
355
408
|
path: str | None = None,
|
356
409
|
path_parameters: dict[str, Any] | None = None,
|
357
|
-
headers: dict[str, Any] | None = None,
|
410
|
+
headers: dict[str, Any] | CaseInsensitiveDict | None = None,
|
358
411
|
cookies: dict[str, Any] | None = None,
|
359
412
|
query: dict[str, Any] | None = None,
|
360
413
|
body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet = NOT_SET,
|
@@ -368,13 +421,18 @@ class BaseSchema(Mapping):
|
|
368
421
|
operation: APIOperation,
|
369
422
|
hooks: HookDispatcher | None = None,
|
370
423
|
auth_storage: AuthStorage | None = None,
|
371
|
-
generation_mode: GenerationMode = GenerationMode.
|
424
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
372
425
|
**kwargs: Any,
|
373
426
|
) -> SearchStrategy:
|
374
427
|
raise NotImplementedError
|
375
428
|
|
376
429
|
def as_state_machine(self) -> type[APIStateMachine]:
|
377
|
-
"""Create a state machine class.
|
430
|
+
"""Create a state machine class for stateful testing of linked API operations.
|
431
|
+
|
432
|
+
Returns:
|
433
|
+
APIStateMachine subclass configured for this schema.
|
434
|
+
|
435
|
+
"""
|
378
436
|
raise NotImplementedError
|
379
437
|
|
380
438
|
def get_links(self, operation: APIOperation) -> dict[str, dict[str, Any]]:
|
@@ -394,35 +452,30 @@ class BaseSchema(Mapping):
|
|
394
452
|
|
395
453
|
def as_strategy(
|
396
454
|
self,
|
397
|
-
|
398
|
-
auth_storage: AuthStorage | None = None,
|
399
|
-
generation_mode: GenerationMode = GenerationMode.default(),
|
455
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
400
456
|
**kwargs: Any,
|
401
457
|
) -> SearchStrategy:
|
402
|
-
"""
|
458
|
+
"""Create a Hypothesis strategy that generates test cases for all schema operations.
|
459
|
+
|
460
|
+
Use with `@given` in non-Schemathesis tests.
|
461
|
+
|
462
|
+
Args:
|
463
|
+
generation_mode: Whether to generate positive or negative test data.
|
464
|
+
**kwargs: Additional keywords for each strategy.
|
465
|
+
|
466
|
+
Returns:
|
467
|
+
Combined Hypothesis strategy for all valid operations in the schema.
|
468
|
+
|
469
|
+
"""
|
403
470
|
_strategies = [
|
404
|
-
operation.ok().as_strategy(
|
405
|
-
hooks=hooks,
|
406
|
-
auth_storage=auth_storage,
|
407
|
-
generation_mode=generation_mode,
|
408
|
-
**kwargs,
|
409
|
-
)
|
471
|
+
operation.ok().as_strategy(generation_mode=generation_mode, **kwargs)
|
410
472
|
for operation in self.get_all_operations()
|
411
473
|
if isinstance(operation, Ok)
|
412
474
|
]
|
413
475
|
return strategies.combine(_strategies)
|
414
476
|
|
415
|
-
def
|
416
|
-
|
417
|
-
*,
|
418
|
-
location: str | None | NotSet = NOT_SET,
|
419
|
-
app: Any | NotSet = NOT_SET,
|
420
|
-
) -> Self:
|
421
|
-
if not isinstance(location, NotSet):
|
422
|
-
self.location = location
|
423
|
-
if not isinstance(app, NotSet):
|
424
|
-
self.app = app
|
425
|
-
return self
|
477
|
+
def find_operation_by_label(self, label: str) -> APIOperation | None:
|
478
|
+
raise NotImplementedError
|
426
479
|
|
427
480
|
|
428
481
|
@dataclass
|
@@ -441,20 +494,23 @@ class APIOperationMap(Mapping):
|
|
441
494
|
|
442
495
|
def as_strategy(
|
443
496
|
self,
|
444
|
-
|
445
|
-
auth_storage: AuthStorage | None = None,
|
446
|
-
generation_mode: GenerationMode = GenerationMode.default(),
|
497
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
447
498
|
**kwargs: Any,
|
448
499
|
) -> SearchStrategy:
|
449
|
-
"""
|
500
|
+
"""Create a Hypothesis strategy that generates test cases for all schema operations in this subset.
|
501
|
+
|
502
|
+
Use with `@given` in non-Schemathesis tests.
|
503
|
+
|
504
|
+
Args:
|
505
|
+
generation_mode: Whether to generate positive or negative test data.
|
506
|
+
**kwargs: Additional keywords for each strategy.
|
507
|
+
|
508
|
+
Returns:
|
509
|
+
Combined Hypothesis strategy for all valid operations in the schema.
|
510
|
+
|
511
|
+
"""
|
450
512
|
_strategies = [
|
451
|
-
operation.as_strategy(
|
452
|
-
hooks=hooks,
|
453
|
-
auth_storage=auth_storage,
|
454
|
-
generation_mode=generation_mode,
|
455
|
-
**kwargs,
|
456
|
-
)
|
457
|
-
for operation in self._data.values()
|
513
|
+
operation.as_strategy(generation_mode=generation_mode, **kwargs) for operation in self._data.values()
|
458
514
|
]
|
459
515
|
return strategies.combine(_strategies)
|
460
516
|
|
@@ -560,16 +616,7 @@ class OperationDefinition(Generic[D]):
|
|
560
616
|
|
561
617
|
@dataclass(eq=False)
|
562
618
|
class APIOperation(Generic[P]):
|
563
|
-
"""
|
564
|
-
|
565
|
-
You can get one via a ``schema`` instance.
|
566
|
-
|
567
|
-
.. code-block:: python
|
568
|
-
|
569
|
-
# Get the POST /items operation
|
570
|
-
operation = schema["/items"]["POST"]
|
571
|
-
|
572
|
-
"""
|
619
|
+
"""An API operation (e.g., `GET /users`)."""
|
573
620
|
|
574
621
|
# `path` does not contain `basePath`
|
575
622
|
# Example <scheme>://<host>/<basePath>/users - "/users" is path
|
@@ -607,7 +654,6 @@ class APIOperation(Generic[P]):
|
|
607
654
|
return self.schema.get_tags(self)
|
608
655
|
|
609
656
|
def iter_parameters(self) -> Iterator[P]:
|
610
|
-
"""Iterate over all operation's parameters."""
|
611
657
|
return chain(self.path_parameters, self.headers, self.cookies, self.query)
|
612
658
|
|
613
659
|
def _lookup_container(self, location: str) -> ParameterSet[P] | PayloadAlternatives[P] | None:
|
@@ -620,11 +666,6 @@ class APIOperation(Generic[P]):
|
|
620
666
|
}.get(location)
|
621
667
|
|
622
668
|
def add_parameter(self, parameter: P) -> None:
|
623
|
-
"""Add a new processed parameter to an API operation.
|
624
|
-
|
625
|
-
:param parameter: A parameter that will be used with this operation.
|
626
|
-
:rtype: None
|
627
|
-
"""
|
628
669
|
# If the parameter has a typo, then by default, there will be an error from `jsonschema` earlier.
|
629
670
|
# But if the user wants to skip schema validation, we choose to ignore a malformed parameter.
|
630
671
|
# In this case, we still might generate some tests for an API operation, but without this parameter,
|
@@ -641,16 +682,22 @@ class APIOperation(Generic[P]):
|
|
641
682
|
|
642
683
|
def as_strategy(
|
643
684
|
self,
|
644
|
-
|
645
|
-
auth_storage: AuthStorage | None = None,
|
646
|
-
generation_mode: GenerationMode = GenerationMode.default(),
|
685
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
647
686
|
**kwargs: Any,
|
648
687
|
) -> SearchStrategy[Case]:
|
649
|
-
"""
|
650
|
-
|
688
|
+
"""Create a Hypothesis strategy that generates test cases for this API operation.
|
689
|
+
|
690
|
+
Use with `@given` in non-Schemathesis tests.
|
691
|
+
|
692
|
+
Args:
|
693
|
+
generation_mode: Whether to generate positive or negative test data.
|
694
|
+
**kwargs: Extra arguments to the underlying strategy function.
|
695
|
+
|
696
|
+
"""
|
697
|
+
strategy = self.schema.get_case_strategy(self, generation_mode=generation_mode, **kwargs)
|
651
698
|
|
652
699
|
def _apply_hooks(dispatcher: HookDispatcher, _strategy: SearchStrategy[Case]) -> SearchStrategy[Case]:
|
653
|
-
context = HookContext(self)
|
700
|
+
context = HookContext(operation=self)
|
654
701
|
for hook in dispatcher.get_all_by_name("before_generate_case"):
|
655
702
|
_strategy = hook(context, _strategy)
|
656
703
|
for hook in dispatcher.get_all_by_name("filter_case"):
|
@@ -666,6 +713,7 @@ class APIOperation(Generic[P]):
|
|
666
713
|
|
667
714
|
strategy = _apply_hooks(GLOBAL_HOOK_DISPATCHER, strategy)
|
668
715
|
strategy = _apply_hooks(self.schema.hooks, strategy)
|
716
|
+
hooks = kwargs.get("hooks")
|
669
717
|
if hooks is not None:
|
670
718
|
strategy = _apply_hooks(hooks, strategy)
|
671
719
|
return strategy
|
@@ -674,15 +722,9 @@ class APIOperation(Generic[P]):
|
|
674
722
|
return self.schema.get_security_requirements(self)
|
675
723
|
|
676
724
|
def get_strategies_from_examples(self, **kwargs: Any) -> list[SearchStrategy[Case]]:
|
677
|
-
"""Get examples from the API operation."""
|
678
725
|
return self.schema.get_strategies_from_examples(self, **kwargs)
|
679
726
|
|
680
727
|
def get_parameter_serializer(self, location: str) -> Callable | None:
|
681
|
-
"""Get a function that serializes parameters for the given location.
|
682
|
-
|
683
|
-
It handles serializing data into various `collectionFormat` options and similar.
|
684
|
-
Note that payload is handled by this function - it is handled by serializers.
|
685
|
-
"""
|
686
728
|
return self.schema.get_parameter_serializer(self, location)
|
687
729
|
|
688
730
|
def prepare_multipart(self, form_data: dict[str, Any]) -> tuple[list | None, dict[str, Any] | None]:
|
@@ -709,27 +751,37 @@ class APIOperation(Generic[P]):
|
|
709
751
|
*,
|
710
752
|
method: str | None = None,
|
711
753
|
path_parameters: dict[str, Any] | None = None,
|
712
|
-
headers: dict[str, Any] | None = None,
|
754
|
+
headers: dict[str, Any] | CaseInsensitiveDict | None = None,
|
713
755
|
cookies: dict[str, Any] | None = None,
|
714
756
|
query: dict[str, Any] | None = None,
|
715
757
|
body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet = NOT_SET,
|
716
758
|
media_type: str | None = None,
|
717
|
-
|
759
|
+
_meta: CaseMetadata | None = None,
|
718
760
|
) -> Case:
|
719
|
-
"""Create a
|
761
|
+
"""Create a test case with specific data instead of generated values.
|
762
|
+
|
763
|
+
Args:
|
764
|
+
method: Override HTTP method.
|
765
|
+
path_parameters: Override path variables.
|
766
|
+
headers: Override HTTP headers.
|
767
|
+
cookies: Override cookies.
|
768
|
+
query: Override query parameters.
|
769
|
+
body: Override request body.
|
770
|
+
media_type: Override media type.
|
720
771
|
|
721
|
-
The main use case is constructing Case instances completely manually, without data generation.
|
722
772
|
"""
|
773
|
+
from requests.structures import CaseInsensitiveDict
|
774
|
+
|
723
775
|
return self.schema.make_case(
|
724
776
|
operation=self,
|
725
777
|
method=method,
|
726
|
-
path_parameters=path_parameters,
|
727
|
-
headers=headers,
|
728
|
-
cookies=cookies,
|
729
|
-
query=query,
|
778
|
+
path_parameters=path_parameters or {},
|
779
|
+
headers=CaseInsensitiveDict() if headers is None else CaseInsensitiveDict(headers),
|
780
|
+
cookies=cookies or {},
|
781
|
+
query=query or {},
|
730
782
|
body=body,
|
731
783
|
media_type=media_type,
|
732
|
-
meta=
|
784
|
+
meta=_meta,
|
733
785
|
)
|
734
786
|
|
735
787
|
@property
|
@@ -737,15 +789,30 @@ class APIOperation(Generic[P]):
|
|
737
789
|
path = self.path.replace("~", "~0").replace("/", "~1")
|
738
790
|
return f"#/paths/{path}/{self.method}"
|
739
791
|
|
740
|
-
def validate_response(self, response: Response) -> bool | None:
|
741
|
-
"""Validate
|
792
|
+
def validate_response(self, response: Response | httpx.Response | requests.Response | TestResponse) -> bool | None:
|
793
|
+
"""Validate a response against the API schema.
|
794
|
+
|
795
|
+
Args:
|
796
|
+
response: The HTTP response to validate. Can be a `requests.Response`,
|
797
|
+
`httpx.Response`, `werkzeug.test.TestResponse`, or `schemathesis.Response`.
|
798
|
+
|
799
|
+
Raises:
|
800
|
+
FailureGroup: If the response does not conform to the schema.
|
742
801
|
|
743
|
-
:raises FailureGroup: If the response does not conform to the API schema.
|
744
802
|
"""
|
745
|
-
return self.schema.validate_response(self, response)
|
803
|
+
return self.schema.validate_response(self, Response.from_any(response))
|
804
|
+
|
805
|
+
def is_valid_response(self, response: Response | httpx.Response | requests.Response | TestResponse) -> bool:
|
806
|
+
"""Check if the provided response is valid against the API schema.
|
746
807
|
|
747
|
-
|
748
|
-
|
808
|
+
Args:
|
809
|
+
response: The HTTP response to validate. Can be a `requests.Response`,
|
810
|
+
`httpx.Response`, `werkzeug.test.TestResponse`, or `schemathesis.Response`.
|
811
|
+
|
812
|
+
Returns:
|
813
|
+
`True` if response is valid, `False` otherwise.
|
814
|
+
|
815
|
+
"""
|
749
816
|
try:
|
750
817
|
self.validate_response(response)
|
751
818
|
return True
|
@@ -13,10 +13,44 @@ CUSTOM_SCALARS: dict[str, st.SearchStrategy[graphql.ValueNode]] = {}
|
|
13
13
|
|
14
14
|
|
15
15
|
def scalar(name: str, strategy: st.SearchStrategy[graphql.ValueNode]) -> None:
|
16
|
-
"""Register a
|
16
|
+
r"""Register a custom Hypothesis strategy for generating GraphQL scalar values.
|
17
|
+
|
18
|
+
Args:
|
19
|
+
name: Scalar name that matches your GraphQL schema scalar definition
|
20
|
+
strategy: Hypothesis strategy that generates GraphQL AST ValueNode objects
|
21
|
+
|
22
|
+
Example:
|
23
|
+
```python
|
24
|
+
import schemathesis
|
25
|
+
from hypothesis import strategies as st
|
26
|
+
from schemathesis.graphql import nodes
|
27
|
+
|
28
|
+
# Register email scalar
|
29
|
+
schemathesis.graphql.scalar("Email", st.emails().map(nodes.String))
|
30
|
+
|
31
|
+
# Register positive integer scalar
|
32
|
+
schemathesis.graphql.scalar(
|
33
|
+
"PositiveInt",
|
34
|
+
st.integers(min_value=1).map(nodes.Int)
|
35
|
+
)
|
36
|
+
|
37
|
+
# Register phone number scalar
|
38
|
+
schemathesis.graphql.scalar(
|
39
|
+
"Phone",
|
40
|
+
st.from_regex(r"\+1-\d{3}-\d{3}-\d{4}").map(nodes.String)
|
41
|
+
)
|
42
|
+
```
|
43
|
+
|
44
|
+
Schema usage:
|
45
|
+
```graphql
|
46
|
+
scalar Email
|
47
|
+
scalar PositiveInt
|
48
|
+
|
49
|
+
type Query {
|
50
|
+
getUser(email: Email!, rating: PositiveInt!): User
|
51
|
+
}
|
52
|
+
```
|
17
53
|
|
18
|
-
:param str name: Scalar name. It should correspond the one used in the schema.
|
19
|
-
:param strategy: Hypothesis strategy you'd like to use to generate values for this scalar.
|
20
54
|
"""
|
21
55
|
from hypothesis.strategies import SearchStrategy
|
22
56
|
|
@@ -115,6 +115,15 @@ class GraphQLSchema(BaseSchema):
|
|
115
115
|
return map
|
116
116
|
raise KeyError(key)
|
117
117
|
|
118
|
+
def find_operation_by_label(self, label: str) -> APIOperation | None:
|
119
|
+
if label.startswith(("Query.", "Mutation.")):
|
120
|
+
ty, field = label.split(".", maxsplit=1)
|
121
|
+
try:
|
122
|
+
return self[ty][field]
|
123
|
+
except KeyError:
|
124
|
+
return None
|
125
|
+
return None
|
126
|
+
|
118
127
|
def on_missing_operation(self, item: str, exc: KeyError) -> NoReturn:
|
119
128
|
raw_schema = self.raw_schema["__schema"]
|
120
129
|
type_names = [type_def["name"] for type_def in raw_schema.get("types", [])]
|
@@ -223,7 +232,7 @@ class GraphQLSchema(BaseSchema):
|
|
223
232
|
operation: APIOperation,
|
224
233
|
hooks: HookDispatcher | None = None,
|
225
234
|
auth_storage: AuthStorage | None = None,
|
226
|
-
generation_mode: GenerationMode = GenerationMode.
|
235
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
227
236
|
**kwargs: Any,
|
228
237
|
) -> SearchStrategy:
|
229
238
|
return graphql_cases(
|
@@ -244,7 +253,7 @@ class GraphQLSchema(BaseSchema):
|
|
244
253
|
method: str | None = None,
|
245
254
|
path: str | None = None,
|
246
255
|
path_parameters: dict[str, Any] | None = None,
|
247
|
-
headers: dict[str, Any] | None = None,
|
256
|
+
headers: dict[str, Any] | CaseInsensitiveDict | None = None,
|
248
257
|
cookies: dict[str, Any] | None = None,
|
249
258
|
query: dict[str, Any] | None = None,
|
250
259
|
body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet = NOT_SET,
|
@@ -255,10 +264,10 @@ class GraphQLSchema(BaseSchema):
|
|
255
264
|
operation=operation,
|
256
265
|
method=method or operation.method.upper(),
|
257
266
|
path=path or operation.path,
|
258
|
-
path_parameters=path_parameters,
|
259
|
-
headers=CaseInsensitiveDict(
|
260
|
-
cookies=cookies,
|
261
|
-
query=query,
|
267
|
+
path_parameters=path_parameters or {},
|
268
|
+
headers=CaseInsensitiveDict() if headers is None else CaseInsensitiveDict(headers),
|
269
|
+
cookies=cookies or {},
|
270
|
+
query=query or {},
|
262
271
|
body=body,
|
263
272
|
media_type=media_type or "application/json",
|
264
273
|
meta=meta,
|
@@ -321,7 +330,7 @@ def graphql_cases(
|
|
321
330
|
operation: APIOperation,
|
322
331
|
hooks: HookDispatcher | None = None,
|
323
332
|
auth_storage: auths.AuthStorage | None = None,
|
324
|
-
generation_mode: GenerationMode = GenerationMode.
|
333
|
+
generation_mode: GenerationMode = GenerationMode.POSITIVE,
|
325
334
|
path_parameters: NotSet | dict[str, Any] = NOT_SET,
|
326
335
|
headers: NotSet | dict[str, Any] = NOT_SET,
|
327
336
|
cookies: NotSet | dict[str, Any] = NOT_SET,
|
@@ -336,7 +345,7 @@ def graphql_cases(
|
|
336
345
|
RootType.QUERY: gql_st.queries,
|
337
346
|
RootType.MUTATION: gql_st.mutations,
|
338
347
|
}[definition.root_type]
|
339
|
-
hook_context = HookContext(operation)
|
348
|
+
hook_context = HookContext(operation=operation)
|
340
349
|
custom_scalars = {**get_extra_scalar_strategies(), **CUSTOM_SCALARS}
|
341
350
|
generation = operation.schema.config.generation_for(operation=operation, phase="fuzzing")
|
342
351
|
strategy = strategy_factory(
|
@@ -367,7 +376,7 @@ def graphql_cases(
|
|
367
376
|
cookies=cookies_,
|
368
377
|
query=query_,
|
369
378
|
body=body,
|
370
|
-
|
379
|
+
_meta=CaseMetadata(
|
371
380
|
generation=GenerationInfo(
|
372
381
|
time=time.monotonic() - start,
|
373
382
|
mode=generation_mode,
|