schemathesis 3.28.1__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 +19 -10
- schemathesis/specs/openapi/references.py +17 -6
- schemathesis/specs/openapi/schemas.py +227 -156
- schemathesis/specs/openapi/security.py +3 -5
- schemathesis/specs/openapi/stateful/links.py +5 -5
- schemathesis/transports/content_types.py +2 -0
- {schemathesis-3.28.1.dist-info → schemathesis-3.29.0.dist-info}/METADATA +6 -2
- {schemathesis-3.28.1.dist-info → schemathesis-3.29.0.dist-info}/RECORD +19 -17
- {schemathesis-3.28.1.dist-info → schemathesis-3.29.0.dist-info}/WHEEL +0 -0
- {schemathesis-3.28.1.dist-info → schemathesis-3.29.0.dist-info}/entry_points.txt +0 -0
- {schemathesis-3.28.1.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
|
|
|
@@ -9,6 +9,8 @@ from dataclasses import dataclass, field
|
|
|
9
9
|
from difflib import get_close_matches
|
|
10
10
|
from typing import TYPE_CHECKING, Any, Generator, NoReturn, Sequence, Union
|
|
11
11
|
|
|
12
|
+
from jsonschema import RefResolver
|
|
13
|
+
|
|
12
14
|
from ...constants import NOT_SET
|
|
13
15
|
from ...internal.copy import fast_deepcopy
|
|
14
16
|
from ...models import APIOperation, Case
|
|
@@ -154,14 +156,17 @@ class Link(StatefulTest):
|
|
|
154
156
|
|
|
155
157
|
def get_links(response: GenericResponse, operation: APIOperation, field: str) -> Sequence[Link]:
|
|
156
158
|
"""Get `x-links` / `links` definitions from the schema."""
|
|
157
|
-
responses = operation.definition.
|
|
159
|
+
responses = operation.definition.raw["responses"]
|
|
158
160
|
if str(response.status_code) in responses:
|
|
159
|
-
|
|
161
|
+
definition = responses[str(response.status_code)]
|
|
160
162
|
elif response.status_code in responses:
|
|
161
|
-
|
|
163
|
+
definition = responses[response.status_code]
|
|
162
164
|
else:
|
|
163
|
-
|
|
164
|
-
|
|
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, {})
|
|
165
170
|
return [Link.from_definition(name, definition, operation) for name, definition in links.items()]
|
|
166
171
|
|
|
167
172
|
|
|
@@ -201,7 +206,7 @@ class OpenAPILink(Direction):
|
|
|
201
206
|
# Therefore the container is empty, otherwise it will be at least an empty object
|
|
202
207
|
if container is None:
|
|
203
208
|
message = f"No such parameter in `{case.operation.method.upper()} {case.operation.path}`: `{name}`."
|
|
204
|
-
possibilities = [param.name for param in case.operation.
|
|
209
|
+
possibilities = [param.name for param in case.operation.iter_parameters()]
|
|
205
210
|
matches = get_close_matches(name, possibilities)
|
|
206
211
|
if matches:
|
|
207
212
|
message += f" Did you mean `{matches[0]}`?"
|
|
@@ -223,7 +228,7 @@ def get_container(case: Case, location: str | None, name: str) -> dict[str, Any]
|
|
|
223
228
|
if location:
|
|
224
229
|
container_name = LOCATION_TO_CONTAINER[location]
|
|
225
230
|
else:
|
|
226
|
-
for param in case.operation.
|
|
231
|
+
for param in case.operation.iter_parameters():
|
|
227
232
|
if param.name == name:
|
|
228
233
|
container_name = LOCATION_TO_CONTAINER[param.location]
|
|
229
234
|
break
|
|
@@ -248,7 +253,8 @@ def normalize_parameter(parameter: str, expression: str) -> tuple[str | None, st
|
|
|
248
253
|
|
|
249
254
|
|
|
250
255
|
def get_all_links(operation: APIOperation) -> Generator[tuple[str, OpenAPILink], None, None]:
|
|
251
|
-
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]
|
|
252
258
|
for name, link_definition in definition.get(operation.schema.links_field, {}).items(): # type: ignore
|
|
253
259
|
yield status_code, OpenAPILink(name, status_code, link_definition, operation)
|
|
254
260
|
|
|
@@ -275,6 +281,7 @@ def _get_response_by_status_code(responses: dict[StatusCode, dict[str, Any]], st
|
|
|
275
281
|
|
|
276
282
|
|
|
277
283
|
def add_link(
|
|
284
|
+
resolver: RefResolver,
|
|
278
285
|
responses: dict[StatusCode, dict[str, Any]],
|
|
279
286
|
links_field: str,
|
|
280
287
|
parameters: dict[str, str] | None,
|
|
@@ -284,6 +291,8 @@ def add_link(
|
|
|
284
291
|
name: str | None = None,
|
|
285
292
|
) -> None:
|
|
286
293
|
response = _get_response_by_status_code(responses, status_code)
|
|
294
|
+
if "$ref" in response:
|
|
295
|
+
_, response = resolver.resolve(response["$ref"])
|
|
287
296
|
links_definition = response.setdefault(links_field, {})
|
|
288
297
|
new_link: dict[str, str | dict[str, str]] = {}
|
|
289
298
|
if parameters is not None:
|
|
@@ -297,8 +306,8 @@ def add_link(
|
|
|
297
306
|
name = name or f"{target.method.upper()} {target.path}"
|
|
298
307
|
# operationId is a dict lookup which is more efficient than using `operationRef`, since it
|
|
299
308
|
# doesn't involve reference resolving when we will look up for this target during testing.
|
|
300
|
-
if "operationId" in target.definition.
|
|
301
|
-
new_link["operationId"] = target.definition.
|
|
309
|
+
if "operationId" in target.definition.raw:
|
|
310
|
+
new_link["operationId"] = target.definition.raw["operationId"]
|
|
302
311
|
else:
|
|
303
312
|
new_link["operationRef"] = target.operation_reference
|
|
304
313
|
# The name is arbitrary, so we don't really case what it is,
|
|
@@ -65,10 +65,13 @@ class InliningResolver(jsonschema.RefResolver):
|
|
|
65
65
|
|
|
66
66
|
def resolve_all(self, item: JSONType, recursion_level: int = 0) -> JSONType:
|
|
67
67
|
"""Recursively resolve all references in the given object."""
|
|
68
|
+
resolve = self.resolve_all
|
|
68
69
|
if isinstance(item, dict):
|
|
69
70
|
ref = item.get("$ref")
|
|
70
|
-
if
|
|
71
|
-
|
|
71
|
+
if isinstance(ref, str):
|
|
72
|
+
url, resolved = self.resolve(ref)
|
|
73
|
+
self.push_scope(url)
|
|
74
|
+
try:
|
|
72
75
|
# If the next level of recursion exceeds the limit, then we need to copy it explicitly
|
|
73
76
|
# In other cases, this method create new objects for mutable types (dict & list)
|
|
74
77
|
next_recursion_level = recursion_level + 1
|
|
@@ -76,10 +79,18 @@ class InliningResolver(jsonschema.RefResolver):
|
|
|
76
79
|
copied = fast_deepcopy(resolved)
|
|
77
80
|
remove_optional_references(copied)
|
|
78
81
|
return copied
|
|
79
|
-
return
|
|
80
|
-
|
|
82
|
+
return resolve(resolved, next_recursion_level)
|
|
83
|
+
finally:
|
|
84
|
+
self.pop_scope()
|
|
85
|
+
return {
|
|
86
|
+
key: resolve(sub_item, recursion_level) if isinstance(sub_item, (dict, list)) else sub_item
|
|
87
|
+
for key, sub_item in item.items()
|
|
88
|
+
}
|
|
81
89
|
if isinstance(item, list):
|
|
82
|
-
return [
|
|
90
|
+
return [
|
|
91
|
+
self.resolve_all(sub_item, recursion_level) if isinstance(sub_item, (dict, list)) else sub_item
|
|
92
|
+
for sub_item in item
|
|
93
|
+
]
|
|
83
94
|
return item
|
|
84
95
|
|
|
85
96
|
def resolve_in_scope(self, definition: dict[str, Any], scope: str) -> tuple[list[str], dict[str, Any]]:
|
|
@@ -89,7 +100,7 @@ class InliningResolver(jsonschema.RefResolver):
|
|
|
89
100
|
if "$ref" in definition:
|
|
90
101
|
self.push_scope(scope)
|
|
91
102
|
try:
|
|
92
|
-
new_scope, definition =
|
|
103
|
+
new_scope, definition = self.resolve(definition["$ref"])
|
|
93
104
|
finally:
|
|
94
105
|
self.pop_scope()
|
|
95
106
|
scopes.append(new_scope)
|