schemathesis 3.28.0__py3-none-any.whl → 3.29.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/exceptions.py +4 -3
- schemathesis/models.py +2 -10
- schemathesis/runner/impl/core.py +1 -1
- schemathesis/schemas.py +19 -33
- schemathesis/specs/graphql/_cache.py +26 -0
- schemathesis/specs/graphql/schemas.py +79 -46
- schemathesis/specs/openapi/_cache.py +123 -0
- schemathesis/specs/openapi/_hypothesis.py +2 -0
- schemathesis/specs/openapi/links.py +36 -26
- schemathesis/specs/openapi/references.py +17 -6
- schemathesis/specs/openapi/schemas.py +229 -158
- schemathesis/specs/openapi/security.py +3 -5
- schemathesis/specs/openapi/stateful/__init__.py +1 -2
- schemathesis/specs/openapi/stateful/links.py +5 -5
- schemathesis/stateful/__init__.py +10 -2
- schemathesis/transports/content_types.py +2 -0
- {schemathesis-3.28.0.dist-info → schemathesis-3.29.0.dist-info}/METADATA +6 -2
- {schemathesis-3.28.0.dist-info → schemathesis-3.29.0.dist-info}/RECORD +21 -19
- {schemathesis-3.28.0.dist-info → schemathesis-3.29.0.dist-info}/WHEEL +0 -0
- {schemathesis-3.28.0.dist-info → schemathesis-3.29.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.28.0.dist-info → schemathesis-3.29.0.dist-info}/licenses/LICENSE +0 -0
schemathesis/exceptions.py
CHANGED
|
@@ -552,10 +552,11 @@ def extract_requests_exception_details(exc: RequestException) -> tuple[str, list
|
|
|
552
552
|
message = "Connection failed"
|
|
553
553
|
inner = exc.args[0]
|
|
554
554
|
if isinstance(inner, MaxRetryError) and inner.reason is not None:
|
|
555
|
-
|
|
556
|
-
|
|
555
|
+
arg = str(inner.reason.args[0])
|
|
556
|
+
if ":" not in arg:
|
|
557
|
+
reason = arg
|
|
557
558
|
else:
|
|
558
|
-
_, reason =
|
|
559
|
+
_, reason = arg.split(":", maxsplit=1)
|
|
559
560
|
extra = [reason.strip()]
|
|
560
561
|
else:
|
|
561
562
|
extra = [" ".join(map(str, inner.args))]
|
schemathesis/models.py
CHANGED
|
@@ -539,7 +539,7 @@ D = TypeVar("D", bound=dict)
|
|
|
539
539
|
|
|
540
540
|
|
|
541
541
|
@dataclass
|
|
542
|
-
class OperationDefinition(Generic[
|
|
542
|
+
class OperationDefinition(Generic[D]):
|
|
543
543
|
"""A wrapper to store not resolved API operation definitions.
|
|
544
544
|
|
|
545
545
|
To prevent recursion errors we need to store definitions without resolving references. But operation definitions
|
|
@@ -550,16 +550,8 @@ class OperationDefinition(Generic[P, D]):
|
|
|
550
550
|
raw: D
|
|
551
551
|
resolved: D
|
|
552
552
|
scope: str
|
|
553
|
-
parameters: Sequence[P]
|
|
554
553
|
|
|
555
|
-
|
|
556
|
-
return item in self.resolved
|
|
557
|
-
|
|
558
|
-
def __getitem__(self, item: str | int) -> None | bool | float | str | list | dict[str, Any]:
|
|
559
|
-
return self.resolved[item]
|
|
560
|
-
|
|
561
|
-
def get(self, item: str | int, default: Any = None) -> None | bool | float | str | list | dict[str, Any]:
|
|
562
|
-
return self.resolved.get(item, default)
|
|
554
|
+
__slots__ = ("raw", "resolved", "scope")
|
|
563
555
|
|
|
564
556
|
|
|
565
557
|
C = TypeVar("C", bound=Case)
|
schemathesis/runner/impl/core.py
CHANGED
|
@@ -615,7 +615,7 @@ def setup_hypothesis_database_key(test: Callable, operation: APIOperation) -> No
|
|
|
615
615
|
# Hypothesis's function digest depends on the test function signature. To reflect it for the web API case,
|
|
616
616
|
# we use all API operation parameters in the digest.
|
|
617
617
|
extra = operation.verbose_name.encode("utf8")
|
|
618
|
-
for parameter in operation.
|
|
618
|
+
for parameter in operation.iter_parameters():
|
|
619
619
|
extra += parameter.serialize(operation).encode("utf8")
|
|
620
620
|
test.hypothesis.inner_test._hypothesis_internal_add_digest = extra # type: ignore
|
|
621
621
|
|
schemathesis/schemas.py
CHANGED
|
@@ -9,7 +9,7 @@ They give only static definitions of paths.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
from collections.abc import Mapping
|
|
12
|
+
from collections.abc import Mapping
|
|
13
13
|
from contextlib import nullcontext
|
|
14
14
|
from dataclasses import dataclass, field
|
|
15
15
|
from functools import lru_cache
|
|
@@ -99,20 +99,23 @@ class BaseSchema(Mapping):
|
|
|
99
99
|
sanitize_output: bool = True
|
|
100
100
|
|
|
101
101
|
def __iter__(self) -> Iterator[str]:
|
|
102
|
-
|
|
102
|
+
raise NotImplementedError
|
|
103
103
|
|
|
104
104
|
def __getitem__(self, item: str) -> APIOperationMap:
|
|
105
105
|
__tracebackhide__ = True
|
|
106
106
|
try:
|
|
107
|
-
return self.
|
|
107
|
+
return self._get_operation_map(item)
|
|
108
108
|
except KeyError as exc:
|
|
109
109
|
self.on_missing_operation(item, exc)
|
|
110
110
|
|
|
111
|
+
def _get_operation_map(self, key: str) -> APIOperationMap:
|
|
112
|
+
raise NotImplementedError
|
|
113
|
+
|
|
111
114
|
def on_missing_operation(self, item: str, exc: KeyError) -> NoReturn:
|
|
112
115
|
raise NotImplementedError
|
|
113
116
|
|
|
114
117
|
def __len__(self) -> int:
|
|
115
|
-
return
|
|
118
|
+
return self.operations_count
|
|
116
119
|
|
|
117
120
|
def hook(self, hook: str | Callable) -> Callable:
|
|
118
121
|
return self.hooks.register(hook)
|
|
@@ -155,18 +158,6 @@ class BaseSchema(Mapping):
|
|
|
155
158
|
def validate(self) -> None:
|
|
156
159
|
raise NotImplementedError
|
|
157
160
|
|
|
158
|
-
@property
|
|
159
|
-
def operations(self) -> dict[str, APIOperationMap]:
|
|
160
|
-
if not hasattr(self, "_operations"):
|
|
161
|
-
operations = self.get_all_operations()
|
|
162
|
-
self._operations = self._store_operations(operations)
|
|
163
|
-
return self._operations
|
|
164
|
-
|
|
165
|
-
def _store_operations(
|
|
166
|
-
self, operations: Generator[Result[APIOperation, OperationSchemaError], None, None]
|
|
167
|
-
) -> dict[str, APIOperationMap]:
|
|
168
|
-
raise NotImplementedError
|
|
169
|
-
|
|
170
161
|
@property
|
|
171
162
|
def operations_count(self) -> int:
|
|
172
163
|
raise NotImplementedError
|
|
@@ -446,39 +437,34 @@ class BaseSchema(Mapping):
|
|
|
446
437
|
**kwargs: Any,
|
|
447
438
|
) -> SearchStrategy:
|
|
448
439
|
"""Build a strategy for generating test cases for all defined API operations."""
|
|
449
|
-
assert len(self
|
|
440
|
+
assert len(self) > 0, "No API operations found"
|
|
450
441
|
strategies = [
|
|
451
|
-
operation.as_strategy(
|
|
442
|
+
operation.ok().as_strategy(
|
|
452
443
|
hooks=hooks,
|
|
453
444
|
auth_storage=auth_storage,
|
|
454
445
|
data_generation_method=data_generation_method,
|
|
455
446
|
generation_config=generation_config,
|
|
456
447
|
**kwargs,
|
|
457
448
|
)
|
|
458
|
-
for
|
|
459
|
-
|
|
449
|
+
for operation in self.get_all_operations(hooks=hooks)
|
|
450
|
+
if isinstance(operation, Ok)
|
|
460
451
|
]
|
|
461
452
|
return combine_strategies(strategies)
|
|
462
453
|
|
|
463
454
|
|
|
464
455
|
@dataclass
|
|
465
|
-
class APIOperationMap(
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
def __setitem__(self, key: str, value: APIOperation) -> None:
|
|
469
|
-
self.data[key] = value
|
|
456
|
+
class APIOperationMap(Mapping):
|
|
457
|
+
_schema: BaseSchema
|
|
458
|
+
_data: Mapping
|
|
470
459
|
|
|
471
460
|
def __getitem__(self, item: str) -> APIOperation:
|
|
472
|
-
return self.
|
|
473
|
-
|
|
474
|
-
def __delitem__(self, key: str) -> None:
|
|
475
|
-
del self.data[key]
|
|
461
|
+
return self._data[item]
|
|
476
462
|
|
|
477
463
|
def __len__(self) -> int:
|
|
478
|
-
return len(self.
|
|
464
|
+
return len(self._data)
|
|
479
465
|
|
|
480
466
|
def __iter__(self) -> Iterator[str]:
|
|
481
|
-
return iter(self.
|
|
467
|
+
return iter(self._data)
|
|
482
468
|
|
|
483
469
|
def as_strategy(
|
|
484
470
|
self,
|
|
@@ -489,7 +475,7 @@ class APIOperationMap(MutableMapping):
|
|
|
489
475
|
**kwargs: Any,
|
|
490
476
|
) -> SearchStrategy:
|
|
491
477
|
"""Build a strategy for generating test cases for all API operations defined in this subset."""
|
|
492
|
-
assert len(self.
|
|
478
|
+
assert len(self._data) > 0, "No API operations found"
|
|
493
479
|
strategies = [
|
|
494
480
|
operation.as_strategy(
|
|
495
481
|
hooks=hooks,
|
|
@@ -498,6 +484,6 @@ class APIOperationMap(MutableMapping):
|
|
|
498
484
|
generation_config=generation_config,
|
|
499
485
|
**kwargs,
|
|
500
486
|
)
|
|
501
|
-
for operation in self.
|
|
487
|
+
for operation in self._data.values()
|
|
502
488
|
]
|
|
503
489
|
return combine_strategies(strategies)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from ...schemas import APIOperationMap
|
|
8
|
+
from ...models import APIOperation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class OperationCache:
|
|
13
|
+
_maps: dict[str, APIOperationMap] = field(default_factory=dict)
|
|
14
|
+
_operations: dict[str, APIOperation] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
def get_map(self, key: str) -> APIOperationMap | None:
|
|
17
|
+
return self._maps.get(key)
|
|
18
|
+
|
|
19
|
+
def insert_map(self, key: str, value: APIOperationMap) -> None:
|
|
20
|
+
self._maps[key] = value
|
|
21
|
+
|
|
22
|
+
def get_operation(self, key: str) -> APIOperation | None:
|
|
23
|
+
return self._operations.get(key)
|
|
24
|
+
|
|
25
|
+
def insert_operation(self, key: str, value: APIOperation) -> None:
|
|
26
|
+
self._operations[key] = value
|
|
@@ -14,13 +14,12 @@ from typing import (
|
|
|
14
14
|
cast,
|
|
15
15
|
TYPE_CHECKING,
|
|
16
16
|
NoReturn,
|
|
17
|
-
|
|
17
|
+
Mapping,
|
|
18
18
|
Iterator,
|
|
19
19
|
)
|
|
20
20
|
from urllib.parse import urlsplit, urlunsplit
|
|
21
21
|
|
|
22
22
|
import graphql
|
|
23
|
-
from graphql import GraphQLNamedType
|
|
24
23
|
from hypothesis import strategies as st
|
|
25
24
|
from hypothesis.strategies import SearchStrategy
|
|
26
25
|
from hypothesis_graphql import strategies as gql_st
|
|
@@ -45,6 +44,7 @@ from ...models import APIOperation, Case, CheckFunction, OperationDefinition
|
|
|
45
44
|
from ...schemas import BaseSchema, APIOperationMap
|
|
46
45
|
from ...stateful import Stateful, StatefulTest
|
|
47
46
|
from ...types import Body, Cookies, Headers, NotSet, PathParameters, Query
|
|
47
|
+
from ._cache import OperationCache
|
|
48
48
|
from .scalars import CUSTOM_SCALARS, get_extra_scalar_strategies
|
|
49
49
|
|
|
50
50
|
if TYPE_CHECKING:
|
|
@@ -103,9 +103,37 @@ class GraphQLOperationDefinition(OperationDefinition):
|
|
|
103
103
|
|
|
104
104
|
@dataclass
|
|
105
105
|
class GraphQLSchema(BaseSchema):
|
|
106
|
+
_operation_cache: OperationCache = field(default_factory=OperationCache)
|
|
107
|
+
|
|
106
108
|
def __repr__(self) -> str:
|
|
107
109
|
return f"<{self.__class__.__name__}>"
|
|
108
110
|
|
|
111
|
+
def __iter__(self) -> Iterator[str]:
|
|
112
|
+
schema = self.client_schema
|
|
113
|
+
for operation_type in (
|
|
114
|
+
schema.query_type,
|
|
115
|
+
schema.mutation_type,
|
|
116
|
+
):
|
|
117
|
+
if operation_type is not None:
|
|
118
|
+
yield operation_type.name
|
|
119
|
+
|
|
120
|
+
def _get_operation_map(self, key: str) -> APIOperationMap:
|
|
121
|
+
cache = self._operation_cache
|
|
122
|
+
map = cache.get_map(key)
|
|
123
|
+
if map is not None:
|
|
124
|
+
return map
|
|
125
|
+
schema = self.client_schema
|
|
126
|
+
for root_type, operation_type in (
|
|
127
|
+
(RootType.QUERY, schema.query_type),
|
|
128
|
+
(RootType.MUTATION, schema.mutation_type),
|
|
129
|
+
):
|
|
130
|
+
if operation_type and operation_type.name == key:
|
|
131
|
+
map = APIOperationMap(self, {})
|
|
132
|
+
map._data = FieldMap(map, root_type, operation_type)
|
|
133
|
+
cache.insert_map(key, map)
|
|
134
|
+
return map
|
|
135
|
+
raise KeyError(key)
|
|
136
|
+
|
|
109
137
|
def on_missing_operation(self, item: str, exc: KeyError) -> NoReturn:
|
|
110
138
|
raw_schema = self.raw_schema["__schema"]
|
|
111
139
|
type_names = [type_def["name"] for type_def in raw_schema.get("types", [])]
|
|
@@ -115,19 +143,6 @@ class GraphQLSchema(BaseSchema):
|
|
|
115
143
|
message += f". Did you mean `{matches[0]}`?"
|
|
116
144
|
raise OperationNotFound(message=message, item=item) from exc
|
|
117
145
|
|
|
118
|
-
def _store_operations(
|
|
119
|
-
self, operations: Generator[Result[APIOperation, OperationSchemaError], None, None]
|
|
120
|
-
) -> dict[str, APIOperationMap]:
|
|
121
|
-
output: dict[str, APIOperationMap] = {}
|
|
122
|
-
for result in operations:
|
|
123
|
-
if isinstance(result, Ok):
|
|
124
|
-
operation = result.ok()
|
|
125
|
-
definition = cast(GraphQLOperationDefinition, operation.definition)
|
|
126
|
-
type_name = definition.type_.name if isinstance(definition.type_, GraphQLNamedType) else "Unknown"
|
|
127
|
-
for_type = output.setdefault(type_name, APIOperationMap(FieldMap()))
|
|
128
|
-
for_type[definition.field_name] = operation
|
|
129
|
-
return output
|
|
130
|
-
|
|
131
146
|
def get_full_path(self, path: str) -> str:
|
|
132
147
|
return self.base_path
|
|
133
148
|
|
|
@@ -178,26 +193,8 @@ class GraphQLSchema(BaseSchema):
|
|
|
178
193
|
):
|
|
179
194
|
if operation_type is None:
|
|
180
195
|
continue
|
|
181
|
-
for field_name,
|
|
182
|
-
operation
|
|
183
|
-
base_url=self.get_base_url(),
|
|
184
|
-
path=self.base_path,
|
|
185
|
-
verbose_name=f"{operation_type.name}.{field_name}",
|
|
186
|
-
method="POST",
|
|
187
|
-
app=self.app,
|
|
188
|
-
schema=self,
|
|
189
|
-
# Parameters are not yet supported
|
|
190
|
-
definition=GraphQLOperationDefinition(
|
|
191
|
-
raw=definition,
|
|
192
|
-
resolved=definition,
|
|
193
|
-
scope="",
|
|
194
|
-
parameters=[],
|
|
195
|
-
type_=operation_type,
|
|
196
|
-
field_name=field_name,
|
|
197
|
-
root_type=root_type,
|
|
198
|
-
),
|
|
199
|
-
case_cls=GraphQLCase,
|
|
200
|
-
)
|
|
196
|
+
for field_name, field_ in operation_type.fields.items():
|
|
197
|
+
operation = self._build_operation(root_type, operation_type, field_name, field_)
|
|
201
198
|
context = HookContext(operation=operation)
|
|
202
199
|
if (
|
|
203
200
|
should_skip_operation(GLOBAL_HOOK_DISPATCHER, context)
|
|
@@ -207,6 +204,32 @@ class GraphQLSchema(BaseSchema):
|
|
|
207
204
|
continue
|
|
208
205
|
yield Ok(operation)
|
|
209
206
|
|
|
207
|
+
def _build_operation(
|
|
208
|
+
self,
|
|
209
|
+
root_type: RootType,
|
|
210
|
+
operation_type: graphql.GraphQLObjectType,
|
|
211
|
+
field_name: str,
|
|
212
|
+
field: graphql.GraphQlField,
|
|
213
|
+
) -> APIOperation:
|
|
214
|
+
return APIOperation(
|
|
215
|
+
base_url=self.get_base_url(),
|
|
216
|
+
path=self.base_path,
|
|
217
|
+
verbose_name=f"{operation_type.name}.{field_name}",
|
|
218
|
+
method="POST",
|
|
219
|
+
app=self.app,
|
|
220
|
+
schema=self,
|
|
221
|
+
# Parameters are not yet supported
|
|
222
|
+
definition=GraphQLOperationDefinition(
|
|
223
|
+
raw=field,
|
|
224
|
+
resolved=field,
|
|
225
|
+
scope="",
|
|
226
|
+
type_=operation_type,
|
|
227
|
+
field_name=field_name,
|
|
228
|
+
root_type=root_type,
|
|
229
|
+
),
|
|
230
|
+
case_cls=GraphQLCase,
|
|
231
|
+
)
|
|
232
|
+
|
|
210
233
|
def get_case_strategy(
|
|
211
234
|
self,
|
|
212
235
|
operation: APIOperation,
|
|
@@ -262,31 +285,41 @@ class GraphQLSchema(BaseSchema):
|
|
|
262
285
|
|
|
263
286
|
|
|
264
287
|
@dataclass
|
|
265
|
-
class FieldMap(
|
|
288
|
+
class FieldMap(Mapping):
|
|
266
289
|
"""Container for accessing API operations.
|
|
267
290
|
|
|
268
291
|
Provides a more specific error message if API operation is not found.
|
|
269
292
|
"""
|
|
270
293
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
self.data[key] = value
|
|
294
|
+
_parent: APIOperationMap
|
|
295
|
+
_root_type: RootType
|
|
296
|
+
_operation_type: graphql.GraphQLObjectType
|
|
275
297
|
|
|
276
|
-
|
|
277
|
-
del self.data[key]
|
|
298
|
+
__slots__ = ("_parent", "_root_type", "_operation_type")
|
|
278
299
|
|
|
279
300
|
def __len__(self) -> int:
|
|
280
|
-
return len(self.
|
|
301
|
+
return len(self._operation_type.fields)
|
|
281
302
|
|
|
282
303
|
def __iter__(self) -> Iterator[str]:
|
|
283
|
-
return iter(self.
|
|
304
|
+
return iter(self._operation_type.fields)
|
|
305
|
+
|
|
306
|
+
def _init_operation(self, field_name: str) -> APIOperation:
|
|
307
|
+
schema = cast(GraphQLSchema, self._parent._schema)
|
|
308
|
+
cache = schema._operation_cache
|
|
309
|
+
operation = cache.get_operation(field_name)
|
|
310
|
+
if operation is not None:
|
|
311
|
+
return operation
|
|
312
|
+
operation_type = self._operation_type
|
|
313
|
+
field_ = operation_type.fields[field_name]
|
|
314
|
+
operation = schema._build_operation(self._root_type, operation_type, field_name, field_)
|
|
315
|
+
cache.insert_operation(field_name, operation)
|
|
316
|
+
return operation
|
|
284
317
|
|
|
285
318
|
def __getitem__(self, item: str) -> APIOperation:
|
|
286
319
|
try:
|
|
287
|
-
return self.
|
|
320
|
+
return self._init_operation(item)
|
|
288
321
|
except KeyError as exc:
|
|
289
|
-
field_names =
|
|
322
|
+
field_names = list(self._operation_type.fields)
|
|
290
323
|
matches = get_close_matches(item, field_names)
|
|
291
324
|
message = f"`{item}` field not found"
|
|
292
325
|
if matches:
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Tuple
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from ...models import APIOperation
|
|
8
|
+
from ...schemas import APIOperationMap
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class OperationCacheEntry:
|
|
13
|
+
path: str
|
|
14
|
+
method: str
|
|
15
|
+
# The resolution scope of the operation
|
|
16
|
+
scope: str
|
|
17
|
+
# Parent path item
|
|
18
|
+
path_item: dict[str, Any]
|
|
19
|
+
# Unresolved operation definition
|
|
20
|
+
operation: dict[str, Any]
|
|
21
|
+
__slots__ = ("path", "method", "scope", "path_item", "operation")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# During traversal, we need to keep track of the scope, path, and method
|
|
25
|
+
TraversalKey = Tuple[str, str, str]
|
|
26
|
+
OperationId = str
|
|
27
|
+
Reference = str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class OperationCache:
|
|
32
|
+
"""Cache for Open API operations.
|
|
33
|
+
|
|
34
|
+
This cache contains multiple levels to avoid unnecessary parsing of the schema.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# Cache to avoid schema traversal on every access
|
|
38
|
+
_id_to_definition: dict[OperationId, OperationCacheEntry] = field(default_factory=dict)
|
|
39
|
+
# Map map between 1st & 2nd level cache keys
|
|
40
|
+
# Even though 1st level keys could be directly mapped to Python objects in memory, we need to keep them separate
|
|
41
|
+
# to ensure a single owner of the operation instance.
|
|
42
|
+
_id_to_operation: dict[OperationId, int] = field(default_factory=dict)
|
|
43
|
+
_traversal_key_to_operation: dict[TraversalKey, int] = field(default_factory=dict)
|
|
44
|
+
_reference_to_operation: dict[Reference, int] = field(default_factory=dict)
|
|
45
|
+
# The actual operations
|
|
46
|
+
_operations: list[APIOperation] = field(default_factory=list)
|
|
47
|
+
# Cache for operation maps
|
|
48
|
+
_maps: dict[str, APIOperationMap] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def known_operation_ids(self) -> list[str]:
|
|
52
|
+
return list(self._id_to_definition)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def has_ids_to_definitions(self) -> bool:
|
|
56
|
+
return bool(self._id_to_definition)
|
|
57
|
+
|
|
58
|
+
def _append_operation(self, operation: APIOperation) -> int:
|
|
59
|
+
idx = len(self._operations)
|
|
60
|
+
self._operations.append(operation)
|
|
61
|
+
return idx
|
|
62
|
+
|
|
63
|
+
def insert_definition_by_id(
|
|
64
|
+
self,
|
|
65
|
+
operation_id: str,
|
|
66
|
+
path: str,
|
|
67
|
+
method: str,
|
|
68
|
+
scope: str,
|
|
69
|
+
path_item: dict[str, Any],
|
|
70
|
+
operation: dict[str, Any],
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Insert a new operation definition into cache."""
|
|
73
|
+
self._id_to_definition[operation_id] = OperationCacheEntry(
|
|
74
|
+
path=path, method=method, scope=scope, path_item=path_item, operation=operation
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def get_definition_by_id(self, operation_id: str) -> OperationCacheEntry:
|
|
78
|
+
"""Get an operation definition by its ID."""
|
|
79
|
+
# TODO: Avoid KeyError in the future
|
|
80
|
+
return self._id_to_definition[operation_id]
|
|
81
|
+
|
|
82
|
+
def insert_operation(
|
|
83
|
+
self,
|
|
84
|
+
operation: APIOperation,
|
|
85
|
+
*,
|
|
86
|
+
traversal_key: TraversalKey,
|
|
87
|
+
operation_id: str | None = None,
|
|
88
|
+
reference: str | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Insert a new operation into cache by one or multiple keys."""
|
|
91
|
+
idx = self._append_operation(operation)
|
|
92
|
+
self._traversal_key_to_operation[traversal_key] = idx
|
|
93
|
+
if operation_id is not None:
|
|
94
|
+
self._id_to_operation[operation_id] = idx
|
|
95
|
+
if reference is not None:
|
|
96
|
+
self._reference_to_operation[reference] = idx
|
|
97
|
+
|
|
98
|
+
def get_operation_by_id(self, operation_id: str) -> APIOperation | None:
|
|
99
|
+
"""Get an operation by its ID."""
|
|
100
|
+
idx = self._id_to_operation.get(operation_id)
|
|
101
|
+
if idx is not None:
|
|
102
|
+
return self._operations[idx]
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def get_operation_by_reference(self, reference: str) -> APIOperation | None:
|
|
106
|
+
"""Get an operation by its reference."""
|
|
107
|
+
idx = self._reference_to_operation.get(reference)
|
|
108
|
+
if idx is not None:
|
|
109
|
+
return self._operations[idx]
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def get_operation_by_traversal_key(self, key: TraversalKey) -> APIOperation | None:
|
|
113
|
+
"""Get an operation by its traverse key."""
|
|
114
|
+
idx = self._traversal_key_to_operation.get(key)
|
|
115
|
+
if idx is not None:
|
|
116
|
+
return self._operations[idx]
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def get_map(self, key: str) -> APIOperationMap | None:
|
|
120
|
+
return self._maps.get(key)
|
|
121
|
+
|
|
122
|
+
def insert_map(self, key: str, value: APIOperationMap) -> None:
|
|
123
|
+
self._maps[key] = value
|
|
@@ -431,6 +431,8 @@ def _build_custom_formats(
|
|
|
431
431
|
custom_formats = {**get_default_format_strategies(), **STRING_FORMATS, **(custom_formats or {})}
|
|
432
432
|
if generation_config.headers.strategy is not None:
|
|
433
433
|
custom_formats[HEADER_FORMAT] = generation_config.headers.strategy
|
|
434
|
+
elif not generation_config.allow_x00:
|
|
435
|
+
custom_formats[HEADER_FORMAT] = header_values(blacklist_characters="\n\r\x00")
|
|
434
436
|
return custom_formats
|
|
435
437
|
|
|
436
438
|
|
|
@@ -4,22 +4,24 @@ Based on https://swagger.io/docs/specification/links/
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
|
+
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from difflib import get_close_matches
|
|
9
|
-
from typing import Any, Generator, NoReturn, Sequence, Union
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Generator, NoReturn, Sequence, Union
|
|
11
|
+
|
|
12
|
+
from jsonschema import RefResolver
|
|
10
13
|
|
|
14
|
+
from ...constants import NOT_SET
|
|
15
|
+
from ...internal.copy import fast_deepcopy
|
|
11
16
|
from ...models import APIOperation, Case
|
|
12
17
|
from ...parameters import ParameterSet
|
|
13
|
-
from ...stateful import ParsedData, StatefulTest
|
|
18
|
+
from ...stateful import ParsedData, StatefulTest, UnresolvableLink
|
|
14
19
|
from ...stateful.state_machine import Direction
|
|
15
20
|
from ...types import NotSet
|
|
16
|
-
|
|
17
|
-
from ...constants import NOT_SET
|
|
18
|
-
from ...internal.copy import fast_deepcopy
|
|
19
21
|
from . import expressions
|
|
20
22
|
from .constants import LOCATION_TO_CONTAINER
|
|
21
23
|
from .parameters import OpenAPI20Body, OpenAPI30Body, OpenAPIParameter
|
|
22
|
-
|
|
24
|
+
from .references import Unresolvable
|
|
23
25
|
|
|
24
26
|
if TYPE_CHECKING:
|
|
25
27
|
from ...transports.responses import GenericResponse
|
|
@@ -61,16 +63,17 @@ class Link(StatefulTest):
|
|
|
61
63
|
def parse(self, case: Case, response: GenericResponse) -> ParsedData:
|
|
62
64
|
"""Parse data into a structure expected by links definition."""
|
|
63
65
|
context = expressions.ExpressionContext(case=case, response=response)
|
|
64
|
-
parameters = {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
66
|
+
parameters = {}
|
|
67
|
+
for parameter, expression in self.parameters.items():
|
|
68
|
+
evaluated = expressions.evaluate(expression, context)
|
|
69
|
+
if isinstance(evaluated, Unresolvable):
|
|
70
|
+
raise UnresolvableLink(f"Unresolvable reference in the link: {expression}")
|
|
71
|
+
parameters[parameter] = evaluated
|
|
72
|
+
# https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#link-object
|
|
73
|
+
# > A literal value or {expression} to use as a request body when calling the target operation.
|
|
74
|
+
# In this case all literals will be passed as is, and expressions will be evaluated
|
|
75
|
+
body = expressions.evaluate(self.request_body, context)
|
|
76
|
+
return ParsedData(parameters=parameters, body=body)
|
|
74
77
|
|
|
75
78
|
def make_operation(self, collected: list[ParsedData]) -> APIOperation:
|
|
76
79
|
"""Create a modified version of the original API operation with additional data merged in."""
|
|
@@ -153,14 +156,17 @@ class Link(StatefulTest):
|
|
|
153
156
|
|
|
154
157
|
def get_links(response: GenericResponse, operation: APIOperation, field: str) -> Sequence[Link]:
|
|
155
158
|
"""Get `x-links` / `links` definitions from the schema."""
|
|
156
|
-
responses = operation.definition.
|
|
159
|
+
responses = operation.definition.raw["responses"]
|
|
157
160
|
if str(response.status_code) in responses:
|
|
158
|
-
|
|
161
|
+
definition = responses[str(response.status_code)]
|
|
159
162
|
elif response.status_code in responses:
|
|
160
|
-
|
|
163
|
+
definition = responses[response.status_code]
|
|
161
164
|
else:
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
definition = responses.get("default", {})
|
|
166
|
+
if not definition:
|
|
167
|
+
return []
|
|
168
|
+
_, definition = operation.schema.resolver.resolve_in_scope(definition, operation.definition.scope) # type: ignore[attr-defined]
|
|
169
|
+
links = definition.get(field, {})
|
|
164
170
|
return [Link.from_definition(name, definition, operation) for name, definition in links.items()]
|
|
165
171
|
|
|
166
172
|
|
|
@@ -200,7 +206,7 @@ class OpenAPILink(Direction):
|
|
|
200
206
|
# Therefore the container is empty, otherwise it will be at least an empty object
|
|
201
207
|
if container is None:
|
|
202
208
|
message = f"No such parameter in `{case.operation.method.upper()} {case.operation.path}`: `{name}`."
|
|
203
|
-
possibilities = [param.name for param in case.operation.
|
|
209
|
+
possibilities = [param.name for param in case.operation.iter_parameters()]
|
|
204
210
|
matches = get_close_matches(name, possibilities)
|
|
205
211
|
if matches:
|
|
206
212
|
message += f" Did you mean `{matches[0]}`?"
|
|
@@ -222,7 +228,7 @@ def get_container(case: Case, location: str | None, name: str) -> dict[str, Any]
|
|
|
222
228
|
if location:
|
|
223
229
|
container_name = LOCATION_TO_CONTAINER[location]
|
|
224
230
|
else:
|
|
225
|
-
for param in case.operation.
|
|
231
|
+
for param in case.operation.iter_parameters():
|
|
226
232
|
if param.name == name:
|
|
227
233
|
container_name = LOCATION_TO_CONTAINER[param.location]
|
|
228
234
|
break
|
|
@@ -247,7 +253,8 @@ def normalize_parameter(parameter: str, expression: str) -> tuple[str | None, st
|
|
|
247
253
|
|
|
248
254
|
|
|
249
255
|
def get_all_links(operation: APIOperation) -> Generator[tuple[str, OpenAPILink], None, None]:
|
|
250
|
-
for status_code, definition in operation.definition.
|
|
256
|
+
for status_code, definition in operation.definition.raw["responses"].items():
|
|
257
|
+
definition = operation.schema.resolver.resolve_all(definition) # type: ignore[attr-defined]
|
|
251
258
|
for name, link_definition in definition.get(operation.schema.links_field, {}).items(): # type: ignore
|
|
252
259
|
yield status_code, OpenAPILink(name, status_code, link_definition, operation)
|
|
253
260
|
|
|
@@ -274,6 +281,7 @@ def _get_response_by_status_code(responses: dict[StatusCode, dict[str, Any]], st
|
|
|
274
281
|
|
|
275
282
|
|
|
276
283
|
def add_link(
|
|
284
|
+
resolver: RefResolver,
|
|
277
285
|
responses: dict[StatusCode, dict[str, Any]],
|
|
278
286
|
links_field: str,
|
|
279
287
|
parameters: dict[str, str] | None,
|
|
@@ -283,6 +291,8 @@ def add_link(
|
|
|
283
291
|
name: str | None = None,
|
|
284
292
|
) -> None:
|
|
285
293
|
response = _get_response_by_status_code(responses, status_code)
|
|
294
|
+
if "$ref" in response:
|
|
295
|
+
_, response = resolver.resolve(response["$ref"])
|
|
286
296
|
links_definition = response.setdefault(links_field, {})
|
|
287
297
|
new_link: dict[str, str | dict[str, str]] = {}
|
|
288
298
|
if parameters is not None:
|
|
@@ -296,8 +306,8 @@ def add_link(
|
|
|
296
306
|
name = name or f"{target.method.upper()} {target.path}"
|
|
297
307
|
# operationId is a dict lookup which is more efficient than using `operationRef`, since it
|
|
298
308
|
# doesn't involve reference resolving when we will look up for this target during testing.
|
|
299
|
-
if "operationId" in target.definition.
|
|
300
|
-
new_link["operationId"] = target.definition.
|
|
309
|
+
if "operationId" in target.definition.raw:
|
|
310
|
+
new_link["operationId"] = target.definition.raw["operationId"]
|
|
301
311
|
else:
|
|
302
312
|
new_link["operationRef"] = target.operation_reference
|
|
303
313
|
# The name is arbitrary, so we don't really case what it is,
|