robotframework-openapitools 0.4.0__py3-none-any.whl → 1.0.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.
- OpenApiDriver/__init__.py +45 -41
- OpenApiDriver/openapi_executors.py +78 -49
- OpenApiDriver/openapi_reader.py +114 -116
- OpenApiDriver/openapidriver.libspec +209 -133
- OpenApiDriver/openapidriver.py +31 -296
- OpenApiLibCore/__init__.py +39 -13
- OpenApiLibCore/annotations.py +10 -0
- OpenApiLibCore/data_generation/__init__.py +10 -0
- OpenApiLibCore/data_generation/body_data_generation.py +250 -0
- OpenApiLibCore/data_generation/data_generation_core.py +233 -0
- OpenApiLibCore/data_invalidation.py +294 -0
- OpenApiLibCore/dto_base.py +67 -130
- OpenApiLibCore/dto_utils.py +125 -85
- OpenApiLibCore/localized_faker.py +88 -0
- OpenApiLibCore/models.py +723 -0
- OpenApiLibCore/oas_cache.py +14 -13
- OpenApiLibCore/openapi_libcore.libspec +355 -330
- OpenApiLibCore/openapi_libcore.py +385 -1953
- OpenApiLibCore/parameter_utils.py +97 -0
- OpenApiLibCore/path_functions.py +215 -0
- OpenApiLibCore/path_invalidation.py +42 -0
- OpenApiLibCore/protocols.py +38 -0
- OpenApiLibCore/request_data.py +246 -0
- OpenApiLibCore/resource_relations.py +55 -0
- OpenApiLibCore/validation.py +380 -0
- OpenApiLibCore/value_utils.py +216 -481
- openapi_libgen/__init__.py +3 -0
- openapi_libgen/command_line.py +75 -0
- openapi_libgen/generator.py +82 -0
- openapi_libgen/parsing_utils.py +30 -0
- openapi_libgen/spec_parser.py +154 -0
- openapi_libgen/templates/__init__.jinja +3 -0
- openapi_libgen/templates/library.jinja +30 -0
- robotframework_openapitools-1.0.0.dist-info/METADATA +249 -0
- robotframework_openapitools-1.0.0.dist-info/RECORD +40 -0
- {robotframework_openapitools-0.4.0.dist-info → robotframework_openapitools-1.0.0.dist-info}/WHEEL +1 -1
- robotframework_openapitools-1.0.0.dist-info/entry_points.txt +3 -0
- roboswag/__init__.py +0 -9
- roboswag/__main__.py +0 -3
- roboswag/auth.py +0 -44
- roboswag/cli.py +0 -80
- roboswag/core.py +0 -85
- roboswag/generate/__init__.py +0 -1
- roboswag/generate/generate.py +0 -121
- roboswag/generate/models/__init__.py +0 -0
- roboswag/generate/models/api.py +0 -219
- roboswag/generate/models/definition.py +0 -28
- roboswag/generate/models/endpoint.py +0 -68
- roboswag/generate/models/parameter.py +0 -25
- roboswag/generate/models/response.py +0 -8
- roboswag/generate/models/tag.py +0 -16
- roboswag/generate/models/utils.py +0 -60
- roboswag/generate/templates/api_init.jinja +0 -15
- roboswag/generate/templates/models.jinja +0 -7
- roboswag/generate/templates/paths.jinja +0 -68
- roboswag/logger.py +0 -33
- roboswag/validate/__init__.py +0 -6
- roboswag/validate/core.py +0 -3
- roboswag/validate/schema.py +0 -21
- roboswag/validate/text_response.py +0 -14
- robotframework_openapitools-0.4.0.dist-info/METADATA +0 -42
- robotframework_openapitools-0.4.0.dist-info/RECORD +0 -41
- {robotframework_openapitools-0.4.0.dist-info → robotframework_openapitools-1.0.0.dist-info}/LICENSE +0 -0
@@ -1,629 +1,104 @@
|
|
1
|
-
"""
|
2
|
-
# OpenApiLibCore for Robot Framework
|
3
|
-
|
4
|
-
The OpenApiLibCore library is a utility library that is meant to simplify creation
|
5
|
-
of other Robot Framework libraries for API testing based on the information in
|
6
|
-
an OpenAPI document (also known as Swagger document).
|
7
|
-
This document explains how to use the OpenApiLibCore library.
|
8
|
-
|
9
|
-
My RoboCon 2022 talk about OpenApiDriver and OpenApiLibCore can be found
|
10
|
-
[here](https://www.youtube.com/watch?v=7YWZEHxk9Ps)
|
11
|
-
|
12
|
-
For more information about Robot Framework, see http://robotframework.org.
|
13
|
-
|
14
|
-
---
|
15
|
-
|
16
|
-
> Note: OpenApiLibCore is still being developed so there are currently
|
17
|
-
restrictions / limitations that you may encounter when using this library to run
|
18
|
-
tests against an API. See [Limitations](#limitations) for details.
|
19
|
-
|
20
|
-
---
|
21
|
-
|
22
|
-
## Installation
|
23
|
-
|
24
|
-
If you already have Python >= 3.8 with pip installed, you can simply run:
|
25
|
-
|
26
|
-
`pip install --upgrade robotframework-openapi-libcore`
|
27
|
-
|
28
|
-
---
|
29
|
-
|
30
|
-
## OpenAPI (aka Swagger)
|
31
|
-
|
32
|
-
The OpenAPI Specification (OAS) defines a standard, language-agnostic interface
|
33
|
-
to RESTful APIs, see https://swagger.io/specification/
|
34
|
-
|
35
|
-
The OpenApiLibCore implements a number of Robot Framework keywords that make it
|
36
|
-
easy to interact with an OpenAPI implementation by using the information in the
|
37
|
-
openapi document (Swagger file), for examply by automatic generation of valid values
|
38
|
-
for requests based on the schema information in the document.
|
39
|
-
|
40
|
-
> Note: OpenApiLibCore is designed for APIs based on the OAS v3
|
41
|
-
The library has not been tested for APIs based on the OAS v2.
|
42
|
-
|
43
|
-
---
|
44
|
-
|
45
|
-
## Getting started
|
46
|
-
|
47
|
-
Before trying to use the keywords exposed by OpenApiLibCore on the target API
|
48
|
-
it's recommended to first ensure that the openapi document for the API is valid
|
49
|
-
under the OpenAPI Specification.
|
50
|
-
|
51
|
-
This can be done using the command line interface of a package that is installed as
|
52
|
-
a prerequisite for OpenApiLibCore.
|
53
|
-
Both a local openapi.json or openapi.yaml file or one hosted by the API server
|
54
|
-
can be checked using the `prance validate <reference_to_file>` shell command:
|
55
|
-
|
56
|
-
```shell
|
57
|
-
prance validate --backend=openapi-spec-validator http://localhost:8000/openapi.json
|
58
|
-
Processing "http://localhost:8000/openapi.json"...
|
59
|
-
-> Resolving external references.
|
60
|
-
Validates OK as OpenAPI 3.0.2!
|
61
|
-
|
62
|
-
prance validate --backend=openapi-spec-validator /tests/files/petstore_openapi.yaml
|
63
|
-
Processing "/tests/files/petstore_openapi.yaml"...
|
64
|
-
-> Resolving external references.
|
65
|
-
Validates OK as OpenAPI 3.0.2!
|
66
|
-
```
|
67
|
-
|
68
|
-
You'll have to change the url or file reference to the location of the openapi
|
69
|
-
document for your API.
|
70
|
-
|
71
|
-
> Note: Although recursion is technically allowed under the OAS, tool support is limited
|
72
|
-
and changing the OAS to not use recursion is recommended.
|
73
|
-
OpenApiLibCore has limited support for parsing OpenAPI documents with
|
74
|
-
recursion in them. See the `recursion_limit` and `recursion_default` parameters.
|
75
|
-
|
76
|
-
If the openapi document passes this validation, the next step is trying to do a test
|
77
|
-
run with a minimal test suite.
|
78
|
-
The example below can be used, with `source`, `origin` and 'endpoint' altered to
|
79
|
-
fit your situation.
|
80
|
-
|
81
|
-
``` robotframework
|
82
|
-
*** Settings ***
|
83
|
-
Library OpenApiLibCore
|
84
|
-
... source=http://localhost:8000/openapi.json
|
85
|
-
... origin=http://localhost:8000
|
86
|
-
|
87
|
-
*** Test Cases ***
|
88
|
-
Getting Started
|
89
|
-
${url}= Get Valid Url endpoint=/employees/{employee_id} method=get
|
90
|
-
|
91
|
-
```
|
92
|
-
|
93
|
-
Running the above suite for the first time may result in an error / failed test.
|
94
|
-
You should look at the Robot Framework `log.html` to determine the reasons
|
95
|
-
for the failing tests.
|
96
|
-
Depending on the reasons for the failures, different solutions are possible.
|
97
|
-
|
98
|
-
Details about the OpenApiLibCore library parameters and keywords that you may need can be found
|
99
|
-
[here](https://marketsquare.github.io/robotframework-openapi-libcore/openapi_libcore.html).
|
100
|
-
|
101
|
-
The OpenApiLibCore also support handling of relations between resources within the scope
|
102
|
-
of the API being validated as well as handling dependencies on resources outside the
|
103
|
-
scope of the API. In addition there is support for handling restrictions on the values
|
104
|
-
of parameters and properties.
|
105
|
-
|
106
|
-
Details about the `mappings_path` variable usage can be found
|
107
|
-
[here](https://marketsquare.github.io/robotframework-openapi-libcore/advanced_use.html).
|
108
|
-
|
109
|
-
---
|
110
|
-
|
111
|
-
## Limitations
|
112
|
-
|
113
|
-
There are currently a number of limitations to supported API structures, supported
|
114
|
-
data types and properties. The following list details the most important ones:
|
115
|
-
- Only JSON request and response bodies are supported.
|
116
|
-
- No support for per-endpoint authorization levels.
|
117
|
-
- Parsing of OAS 3.1 documents is supported by the parsing tools, but runtime behavior is untested.
|
118
|
-
|
119
|
-
"""
|
120
|
-
|
121
1
|
import json as _json
|
122
|
-
import re
|
123
2
|
import sys
|
3
|
+
from collections.abc import Mapping, MutableMapping
|
124
4
|
from copy import deepcopy
|
125
|
-
from dataclasses import Field, dataclass, field, make_dataclass
|
126
|
-
from enum import Enum
|
127
5
|
from functools import cached_property
|
128
|
-
from itertools import zip_longest
|
129
|
-
from logging import getLogger
|
130
6
|
from pathlib import Path
|
131
|
-
from
|
132
|
-
from typing import
|
133
|
-
Any,
|
134
|
-
Callable,
|
135
|
-
Dict,
|
136
|
-
Generator,
|
137
|
-
List,
|
138
|
-
Optional,
|
139
|
-
Set,
|
140
|
-
Tuple,
|
141
|
-
Type,
|
142
|
-
Union,
|
143
|
-
)
|
144
|
-
from uuid import uuid4
|
7
|
+
from types import MappingProxyType
|
8
|
+
from typing import Any, Generator
|
145
9
|
|
146
10
|
from openapi_core import Config, OpenAPI, Spec
|
147
|
-
from openapi_core.contrib.requests import (
|
148
|
-
RequestsOpenAPIRequest,
|
149
|
-
RequestsOpenAPIResponse,
|
150
|
-
)
|
151
|
-
from openapi_core.exceptions import OpenAPIError
|
152
|
-
from openapi_core.templating.paths.exceptions import ServerNotFound
|
153
11
|
from openapi_core.validation.exceptions import ValidationError
|
154
|
-
from openapi_core.validation.response.exceptions import ResponseValidationError
|
155
|
-
from openapi_core.validation.schemas.exceptions import InvalidSchemaValue
|
156
12
|
from prance import ResolvingParser
|
157
13
|
from prance.util.url import ResolutionError
|
158
14
|
from requests import Response, Session
|
159
15
|
from requests.auth import AuthBase, HTTPBasicAuth
|
160
16
|
from requests.cookies import RequestsCookieJar as CookieJar
|
17
|
+
from robot.api import logger
|
161
18
|
from robot.api.deco import keyword, library
|
162
|
-
from robot.api.exceptions import
|
19
|
+
from robot.api.exceptions import FatalError
|
163
20
|
from robot.libraries.BuiltIn import BuiltIn
|
164
21
|
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
Relation,
|
174
|
-
UniquePropertyValueConstraint,
|
175
|
-
resolve_schema,
|
176
|
-
)
|
22
|
+
import OpenApiLibCore.data_generation as _data_generation
|
23
|
+
import OpenApiLibCore.data_invalidation as _data_invalidation
|
24
|
+
import OpenApiLibCore.path_functions as _path_functions
|
25
|
+
import OpenApiLibCore.path_invalidation as _path_invalidation
|
26
|
+
import OpenApiLibCore.resource_relations as _resource_relations
|
27
|
+
import OpenApiLibCore.validation as _validation
|
28
|
+
from OpenApiLibCore.annotations import JSON
|
29
|
+
from OpenApiLibCore.dto_base import Dto, IdReference
|
177
30
|
from OpenApiLibCore.dto_utils import (
|
178
31
|
DEFAULT_ID_PROPERTY_NAME,
|
179
|
-
DefaultDto,
|
180
32
|
get_dto_class,
|
181
33
|
get_id_property_name,
|
34
|
+
get_path_dto_class,
|
35
|
+
)
|
36
|
+
from OpenApiLibCore.localized_faker import FAKE
|
37
|
+
from OpenApiLibCore.models import (
|
38
|
+
OpenApiObject,
|
39
|
+
PathItemObject,
|
40
|
+
)
|
41
|
+
from OpenApiLibCore.oas_cache import PARSER_CACHE, CachedParser
|
42
|
+
from OpenApiLibCore.parameter_utils import (
|
43
|
+
get_oas_name_from_safe_name,
|
44
|
+
register_path_parameters,
|
45
|
+
)
|
46
|
+
from OpenApiLibCore.protocols import ResponseValidatorType
|
47
|
+
from OpenApiLibCore.request_data import RequestData, RequestValues
|
48
|
+
from openapitools_docs.docstrings import (
|
49
|
+
OPENAPILIBCORE_INIT_DOCSTRING,
|
50
|
+
OPENAPILIBCORE_LIBRARY_DOCSTRING,
|
182
51
|
)
|
183
|
-
from OpenApiLibCore.oas_cache import PARSER_CACHE
|
184
|
-
from OpenApiLibCore.value_utils import FAKE, IGNORE, JSON
|
185
52
|
|
186
53
|
run_keyword = BuiltIn().run_keyword
|
54
|
+
default_str_mapping: Mapping[str, str] = MappingProxyType({})
|
55
|
+
default_json_mapping: Mapping[str, JSON] = MappingProxyType({})
|
187
56
|
|
188
|
-
logger = getLogger(__name__)
|
189
|
-
|
190
|
-
|
191
|
-
class ValidationLevel(str, Enum):
|
192
|
-
"""The available levels for the response_validation parameter."""
|
193
|
-
|
194
|
-
DISABLED = "DISABLED"
|
195
|
-
INFO = "INFO"
|
196
|
-
WARN = "WARN"
|
197
|
-
STRICT = "STRICT"
|
198
|
-
|
199
|
-
|
200
|
-
def get_safe_key(key: str) -> str:
|
201
|
-
"""
|
202
|
-
Helper function to convert a valid JSON property name to a string that can be used
|
203
|
-
as a Python variable or function / method name.
|
204
|
-
"""
|
205
|
-
key = key.replace("-", "_")
|
206
|
-
key = key.replace("@", "_")
|
207
|
-
if key[0].isdigit():
|
208
|
-
key = f"_{key}"
|
209
|
-
return key
|
210
|
-
|
211
|
-
|
212
|
-
@dataclass
|
213
|
-
class RequestValues:
|
214
|
-
"""Helper class to hold parameter values needed to make a request."""
|
215
|
-
|
216
|
-
url: str
|
217
|
-
method: str
|
218
|
-
params: Optional[Dict[str, Any]]
|
219
|
-
headers: Optional[Dict[str, str]]
|
220
|
-
json_data: Optional[Dict[str, Any]]
|
221
|
-
|
222
|
-
|
223
|
-
@dataclass
|
224
|
-
class RequestData:
|
225
|
-
"""Helper class to manage parameters used when making requests."""
|
226
|
-
|
227
|
-
dto: Union[Dto, DefaultDto] = field(default_factory=DefaultDto)
|
228
|
-
dto_schema: Dict[str, Any] = field(default_factory=dict)
|
229
|
-
parameters: List[Dict[str, Any]] = field(default_factory=list)
|
230
|
-
params: Dict[str, Any] = field(default_factory=dict)
|
231
|
-
headers: Dict[str, Any] = field(default_factory=dict)
|
232
|
-
has_body: bool = True
|
233
|
-
|
234
|
-
def __post_init__(self) -> None:
|
235
|
-
# prevent modification by reference
|
236
|
-
self.dto_schema = deepcopy(self.dto_schema)
|
237
|
-
self.parameters = deepcopy(self.parameters)
|
238
|
-
self.params = deepcopy(self.params)
|
239
|
-
self.headers = deepcopy(self.headers)
|
240
|
-
|
241
|
-
@property
|
242
|
-
def has_optional_properties(self) -> bool:
|
243
|
-
"""Whether or not the dto data (json data) contains optional properties."""
|
244
|
-
|
245
|
-
def is_required_property(property_name: str) -> bool:
|
246
|
-
return property_name in self.dto_schema.get("required", [])
|
247
|
-
|
248
|
-
properties = (self.dto.as_dict()).keys()
|
249
|
-
return not all(map(is_required_property, properties))
|
250
|
-
|
251
|
-
@property
|
252
|
-
def has_optional_params(self) -> bool:
|
253
|
-
"""Whether or not any of the query parameters are optional."""
|
254
|
-
|
255
|
-
def is_optional_param(query_param: str) -> bool:
|
256
|
-
optional_params = [
|
257
|
-
p.get("name")
|
258
|
-
for p in self.parameters
|
259
|
-
if p.get("in") == "query" and not p.get("required")
|
260
|
-
]
|
261
|
-
return query_param in optional_params
|
262
57
|
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
def params_that_can_be_invalidated(self) -> Set[str]:
|
267
|
-
"""
|
268
|
-
The query parameters that can be invalidated by violating data
|
269
|
-
restrictions, data type or by not providing them in a request.
|
270
|
-
"""
|
271
|
-
result = set()
|
272
|
-
params = [h for h in self.parameters if h.get("in") == "query"]
|
273
|
-
for param in params:
|
274
|
-
# required params can be omitted to invalidate a request
|
275
|
-
if param["required"]:
|
276
|
-
result.add(param["name"])
|
277
|
-
continue
|
278
|
-
|
279
|
-
schema = resolve_schema(param["schema"])
|
280
|
-
if schema.get("type", None):
|
281
|
-
param_types = [schema]
|
282
|
-
else:
|
283
|
-
param_types = schema["types"]
|
284
|
-
for param_type in param_types:
|
285
|
-
# any basic non-string type except "null" can be invalidated by
|
286
|
-
# replacing it with a string
|
287
|
-
if param_type["type"] not in ["string", "array", "object", "null"]:
|
288
|
-
result.add(param["name"])
|
289
|
-
continue
|
290
|
-
# enums, strings and arrays with boundaries can be invalidated
|
291
|
-
if set(param_type.keys()).intersection(
|
292
|
-
{
|
293
|
-
"enum",
|
294
|
-
"minLength",
|
295
|
-
"maxLength",
|
296
|
-
"minItems",
|
297
|
-
"maxItems",
|
298
|
-
}
|
299
|
-
):
|
300
|
-
result.add(param["name"])
|
301
|
-
continue
|
302
|
-
# an array of basic non-string type can be invalidated by replacing the
|
303
|
-
# items in the array with strings
|
304
|
-
if param_type["type"] == "array" and param_type["items"][
|
305
|
-
"type"
|
306
|
-
] not in [
|
307
|
-
"string",
|
308
|
-
"array",
|
309
|
-
"object",
|
310
|
-
"null",
|
311
|
-
]:
|
312
|
-
result.add(param["name"])
|
313
|
-
return result
|
314
|
-
|
315
|
-
@property
|
316
|
-
def has_optional_headers(self) -> bool:
|
317
|
-
"""Whether or not any of the headers are optional."""
|
318
|
-
|
319
|
-
def is_optional_header(header: str) -> bool:
|
320
|
-
optional_headers = [
|
321
|
-
p.get("name")
|
322
|
-
for p in self.parameters
|
323
|
-
if p.get("in") == "header" and not p.get("required")
|
324
|
-
]
|
325
|
-
return header in optional_headers
|
326
|
-
|
327
|
-
return any(map(is_optional_header, self.headers))
|
328
|
-
|
329
|
-
@cached_property
|
330
|
-
def headers_that_can_be_invalidated(self) -> Set[str]:
|
331
|
-
"""
|
332
|
-
The header parameters that can be invalidated by violating data
|
333
|
-
restrictions or by not providing them in a request.
|
334
|
-
"""
|
335
|
-
result = set()
|
336
|
-
headers = [h for h in self.parameters if h.get("in") == "header"]
|
337
|
-
for header in headers:
|
338
|
-
# required headers can be omitted to invalidate a request
|
339
|
-
if header["required"]:
|
340
|
-
result.add(header["name"])
|
341
|
-
continue
|
342
|
-
|
343
|
-
schema = resolve_schema(header["schema"])
|
344
|
-
if schema.get("type", None):
|
345
|
-
header_types = [schema]
|
346
|
-
else:
|
347
|
-
header_types = schema["types"]
|
348
|
-
for header_type in header_types:
|
349
|
-
# any basic non-string type except "null" can be invalidated by
|
350
|
-
# replacing it with a string
|
351
|
-
if header_type["type"] not in ["string", "array", "object", "null"]:
|
352
|
-
result.add(header["name"])
|
353
|
-
continue
|
354
|
-
# enums, strings and arrays with boundaries can be invalidated
|
355
|
-
if set(header_type.keys()).intersection(
|
356
|
-
{
|
357
|
-
"enum",
|
358
|
-
"minLength",
|
359
|
-
"maxLength",
|
360
|
-
"minItems",
|
361
|
-
"maxItems",
|
362
|
-
}
|
363
|
-
):
|
364
|
-
result.add(header["name"])
|
365
|
-
continue
|
366
|
-
# an array of basic non-string type can be invalidated by replacing the
|
367
|
-
# items in the array with strings
|
368
|
-
if header_type["type"] == "array" and header_type["items"][
|
369
|
-
"type"
|
370
|
-
] not in [
|
371
|
-
"string",
|
372
|
-
"array",
|
373
|
-
"object",
|
374
|
-
"null",
|
375
|
-
]:
|
376
|
-
result.add(header["name"])
|
377
|
-
return result
|
378
|
-
|
379
|
-
def get_required_properties_dict(self) -> Dict[str, Any]:
|
380
|
-
"""Get the json-compatible dto data containing only the required properties."""
|
381
|
-
relations = self.dto.get_relations()
|
382
|
-
mandatory_properties = [
|
383
|
-
relation.property_name
|
384
|
-
for relation in relations
|
385
|
-
if getattr(relation, "treat_as_mandatory", False)
|
386
|
-
]
|
387
|
-
required_properties: List[str] = self.dto_schema.get("required", [])
|
388
|
-
required_properties.extend(mandatory_properties)
|
389
|
-
|
390
|
-
required_properties_dict: Dict[str, Any] = {}
|
391
|
-
for key, value in (self.dto.as_dict()).items():
|
392
|
-
if key in required_properties:
|
393
|
-
required_properties_dict[key] = value
|
394
|
-
return required_properties_dict
|
395
|
-
|
396
|
-
def get_minimal_body_dict(self) -> Dict[str, Any]:
|
397
|
-
required_properties_dict = self.get_required_properties_dict()
|
398
|
-
|
399
|
-
min_properties = self.dto_schema.get("minProperties", 0)
|
400
|
-
number_of_optional_properties_to_add = min_properties - len(
|
401
|
-
required_properties_dict
|
402
|
-
)
|
403
|
-
|
404
|
-
if number_of_optional_properties_to_add < 1:
|
405
|
-
return required_properties_dict
|
406
|
-
|
407
|
-
optional_properties_dict = {
|
408
|
-
k: v
|
409
|
-
for k, v in self.dto.as_dict().items()
|
410
|
-
if k not in required_properties_dict
|
411
|
-
}
|
412
|
-
optional_properties_to_keep = sample(
|
413
|
-
sorted(optional_properties_dict), number_of_optional_properties_to_add
|
414
|
-
)
|
415
|
-
optional_properties_dict = {
|
416
|
-
k: v
|
417
|
-
for k, v in optional_properties_dict.items()
|
418
|
-
if k in optional_properties_to_keep
|
419
|
-
}
|
420
|
-
|
421
|
-
return {**required_properties_dict, **optional_properties_dict}
|
422
|
-
|
423
|
-
def get_required_params(self) -> Dict[str, str]:
|
424
|
-
"""Get the params dict containing only the required query parameters."""
|
425
|
-
relations = self.dto.get_parameter_relations()
|
426
|
-
mandatory_properties = [
|
427
|
-
relation.property_name
|
428
|
-
for relation in relations
|
429
|
-
if getattr(relation, "treat_as_mandatory", False)
|
430
|
-
]
|
431
|
-
mandatory_parameters = [p for p in mandatory_properties if p in self.parameters]
|
432
|
-
|
433
|
-
required_parameters = [
|
434
|
-
p.get("name") for p in self.parameters if p.get("required")
|
435
|
-
]
|
436
|
-
required_parameters.extend(mandatory_parameters)
|
437
|
-
return {k: v for k, v in self.params.items() if k in required_parameters}
|
438
|
-
|
439
|
-
def get_required_headers(self) -> Dict[str, str]:
|
440
|
-
"""Get the headers dict containing only the required headers."""
|
441
|
-
relations = self.dto.get_parameter_relations()
|
442
|
-
mandatory_properties = [
|
443
|
-
relation.property_name
|
444
|
-
for relation in relations
|
445
|
-
if getattr(relation, "treat_as_mandatory", False)
|
446
|
-
]
|
447
|
-
mandatory_parameters = [p for p in mandatory_properties if p in self.parameters]
|
448
|
-
|
449
|
-
required_parameters = [
|
450
|
-
p.get("name") for p in self.parameters if p.get("required")
|
451
|
-
]
|
452
|
-
required_parameters.extend(mandatory_parameters)
|
453
|
-
return {k: v for k, v in self.headers.items() if k in required_parameters}
|
454
|
-
|
455
|
-
|
456
|
-
@library(scope="SUITE", doc_format="ROBOT")
|
457
|
-
class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
458
|
-
"""
|
459
|
-
Main class providing the keywords and core logic to interact with an OpenAPI server.
|
460
|
-
|
461
|
-
Visit the [https://github.com/MarketSquare/robotframework-openapi-libcore | library page]
|
462
|
-
for an introduction.
|
463
|
-
"""
|
464
|
-
|
465
|
-
def __init__( # pylint: disable=too-many-arguments, too-many-locals, dangerous-default-value
|
58
|
+
@library(scope="SUITE", doc_format="HTML")
|
59
|
+
class OpenApiLibCore: # pylint: disable=too-many-public-methods
|
60
|
+
def __init__( # noqa: PLR0913, pylint: disable=dangerous-default-value
|
466
61
|
self,
|
467
62
|
source: str,
|
468
63
|
origin: str = "",
|
469
64
|
base_path: str = "",
|
470
|
-
response_validation: ValidationLevel = ValidationLevel.WARN,
|
65
|
+
response_validation: _validation.ValidationLevel = _validation.ValidationLevel.WARN,
|
471
66
|
disable_server_validation: bool = True,
|
472
|
-
mappings_path:
|
67
|
+
mappings_path: str | Path = "",
|
473
68
|
invalid_property_default_response: int = 422,
|
474
69
|
default_id_property_name: str = "id",
|
475
|
-
faker_locale:
|
70
|
+
faker_locale: str | list[str] = "",
|
476
71
|
require_body_for_invalid_url: bool = False,
|
477
72
|
recursion_limit: int = 1,
|
478
|
-
recursion_default:
|
73
|
+
recursion_default: JSON = {},
|
479
74
|
username: str = "",
|
480
75
|
password: str = "",
|
481
76
|
security_token: str = "",
|
482
|
-
auth:
|
483
|
-
cert:
|
484
|
-
verify_tls:
|
485
|
-
extra_headers:
|
486
|
-
cookies:
|
487
|
-
proxies:
|
77
|
+
auth: AuthBase | None = None,
|
78
|
+
cert: str | tuple[str, str] = "",
|
79
|
+
verify_tls: bool | str = True,
|
80
|
+
extra_headers: Mapping[str, str] = default_str_mapping,
|
81
|
+
cookies: MutableMapping[str, str] | CookieJar | None = None,
|
82
|
+
proxies: MutableMapping[str, str] | None = None,
|
488
83
|
) -> None:
|
489
|
-
"""
|
490
|
-
== Base parameters ==
|
491
|
-
|
492
|
-
=== source ===
|
493
|
-
An absolute path to an openapi.json or openapi.yaml file or an url to such a file.
|
494
|
-
|
495
|
-
=== origin ===
|
496
|
-
The server (and port) of the target server. E.g. ``https://localhost:8000``
|
497
|
-
|
498
|
-
=== base_path ===
|
499
|
-
The routing between ``origin`` and the endpoints as found in the ``paths``
|
500
|
-
section in the openapi document.
|
501
|
-
E.g. ``/petshop/v2``.
|
502
|
-
|
503
|
-
== Test case execution ==
|
504
|
-
|
505
|
-
=== response_validation ===
|
506
|
-
By default, a ``WARN`` is logged when the Response received after a Request does not
|
507
|
-
comply with the schema as defined in the openapi document for the given operation. The
|
508
|
-
following values are supported:
|
509
|
-
|
510
|
-
- ``DISABLED``: All Response validation errors will be ignored
|
511
|
-
- ``INFO``: Any Response validation erros will be logged at ``INFO`` level
|
512
|
-
- ``WARN``: Any Response validation erros will be logged at ``WARN`` level
|
513
|
-
- ``STRICT``: The Test Case will fail on any Response validation errors
|
514
|
-
|
515
|
-
=== disable_server_validation ===
|
516
|
-
If enabled by setting this parameter to ``True``, the Response validation will also
|
517
|
-
include possible errors for Requests made to a server address that is not defined in
|
518
|
-
the list of servers in the openapi document. This generally means that if there is a
|
519
|
-
mismatch, every Test Case will raise this error. Note that ``localhost`` and
|
520
|
-
``127.0.0.1`` are not considered the same by Response validation.
|
521
|
-
|
522
|
-
== API-specific configurations ==
|
523
|
-
|
524
|
-
=== mappings_path ===
|
525
|
-
See [https://marketsquare.github.io/robotframework-openapi-libcore/advanced_use.html | this page]
|
526
|
-
for an in-depth explanation.
|
527
|
-
|
528
|
-
=== invalid_property_default_response ===
|
529
|
-
The default response code for requests with a JSON body that does not comply
|
530
|
-
with the schema.
|
531
|
-
Example: a value outside the specified range or a string value
|
532
|
-
for a property defined as integer in the schema.
|
533
|
-
|
534
|
-
=== default_id_property_name ===
|
535
|
-
The default name for the property that identifies a resource (i.e. a unique
|
536
|
-
entity) within the API.
|
537
|
-
The default value for this property name is ``id``.
|
538
|
-
If the target API uses a different name for all the resources within the API,
|
539
|
-
you can configure it globally using this property.
|
540
|
-
|
541
|
-
If different property names are used for the unique identifier for different
|
542
|
-
types of resources, an ``ID_MAPPING`` can be implemented using the ``mappings_path``.
|
543
|
-
|
544
|
-
=== faker_locale ===
|
545
|
-
A locale string or list of locale strings to pass to the Faker library to be
|
546
|
-
used in generation of string data for supported format types.
|
547
|
-
|
548
|
-
=== require_body_for_invalid_url ===
|
549
|
-
When a request is made against an invalid url, this usually is because of a "404" request;
|
550
|
-
a request for a resource that does not exist. Depending on API implementation, when a
|
551
|
-
request with a missing or invalid request body is made on a non-existent resource,
|
552
|
-
either a 404 or a 422 or 400 Response is normally returned. If the API being tested
|
553
|
-
processes the request body before checking if the requested resource exists, set
|
554
|
-
this parameter to True.
|
555
|
-
|
556
|
-
== Parsing parameters ==
|
557
|
-
|
558
|
-
=== recursion_limit ===
|
559
|
-
The recursion depth to which to fully parse recursive references before the
|
560
|
-
`recursion_default` is used to end the recursion.
|
561
|
-
|
562
|
-
=== recursion_default ===
|
563
|
-
The value that is used instead of the referenced schema when the
|
564
|
-
`recursion_limit` has been reached.
|
565
|
-
The default `{}` represents an empty object in JSON.
|
566
|
-
Depending on schema definitions, this may cause schema validation errors.
|
567
|
-
If this is the case, 'None' (``${NONE}`` in Robot Framework) or an empty list
|
568
|
-
can be tried as an alternative.
|
569
|
-
|
570
|
-
== Security-related parameters ==
|
571
|
-
_Note: these parameters are equivalent to those in the ``requests`` library._
|
572
|
-
|
573
|
-
=== username ===
|
574
|
-
The username to be used for Basic Authentication.
|
575
|
-
|
576
|
-
=== password ===
|
577
|
-
The password to be used for Basic Authentication.
|
578
|
-
|
579
|
-
=== security_token ===
|
580
|
-
The token to be used for token based security using the ``Authorization`` header.
|
581
|
-
|
582
|
-
=== auth ===
|
583
|
-
A [https://requests.readthedocs.io/en/latest/api/#authentication | requests ``AuthBase`` instance]
|
584
|
-
to be used for authentication instead of the ``username`` and ``password``.
|
585
|
-
|
586
|
-
=== cert ===
|
587
|
-
The SSL certificate to use with all requests.
|
588
|
-
If string: the path to ssl client cert file (.pem).
|
589
|
-
If tuple: the ('cert', 'key') pair.
|
590
|
-
|
591
|
-
=== verify_tls ===
|
592
|
-
Whether or not to verify the TLS / SSL certificate of the server.
|
593
|
-
If boolean: whether or not to verify the server TLS certificate.
|
594
|
-
If string: path to a CA bundle to use for verification.
|
595
|
-
|
596
|
-
=== extra_headers ===
|
597
|
-
A dictionary with extra / custom headers that will be send with every request.
|
598
|
-
This parameter can be used to send headers that are not documented in the
|
599
|
-
openapi document or to provide an API-key.
|
600
|
-
|
601
|
-
=== cookies ===
|
602
|
-
A dictionary or
|
603
|
-
[https://docs.python.org/3/library/http.cookiejar.html#http.cookiejar.CookieJar | CookieJar object]
|
604
|
-
to send with all requests.
|
605
|
-
|
606
|
-
=== proxies ===
|
607
|
-
A dictionary of 'protocol': 'proxy url' to use for all requests.
|
608
|
-
"""
|
609
84
|
self._source = source
|
610
85
|
self._origin = origin
|
611
86
|
self._base_path = base_path
|
612
87
|
self.response_validation = response_validation
|
613
88
|
self.disable_server_validation = disable_server_validation
|
614
89
|
self._recursion_limit = recursion_limit
|
615
|
-
self._recursion_default = recursion_default
|
90
|
+
self._recursion_default = deepcopy(recursion_default)
|
616
91
|
self.session = Session()
|
617
|
-
#
|
92
|
+
# Only username and password, security_token or auth object should be provided
|
618
93
|
# if multiple are provided, username and password take precedence
|
619
94
|
self.security_token = security_token
|
620
95
|
self.auth = auth
|
621
96
|
if username:
|
622
97
|
self.auth = HTTPBasicAuth(username, password)
|
623
|
-
#
|
624
|
-
#
|
625
|
-
if isinstance(cert,
|
626
|
-
cert =
|
98
|
+
# Requests only allows a string or a tuple[str, str], so ensure cert is a tuple
|
99
|
+
# if the passed argument is not a string.
|
100
|
+
if not isinstance(cert, str):
|
101
|
+
cert = (cert[0], cert[1])
|
627
102
|
self.cert = cert
|
628
103
|
self.verify = verify_tls
|
629
104
|
self.extra_headers = extra_headers
|
@@ -633,23 +108,27 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
633
108
|
if mappings_path and str(mappings_path) != ".":
|
634
109
|
mappings_path = Path(mappings_path)
|
635
110
|
if not mappings_path.is_file():
|
636
|
-
logger.
|
637
|
-
|
638
|
-
|
639
|
-
# intermediate variable to ensure path.append is possible so we'll never
|
640
|
-
# path.pop a location that we didn't append
|
111
|
+
logger.warn(f"mappings_path '{mappings_path}' is not a Python module.")
|
112
|
+
# Intermediate variable to ensure path.append is possible so we'll never
|
113
|
+
# path.pop a location that we didn't append.
|
641
114
|
mappings_folder = str(mappings_path.parent)
|
642
115
|
sys.path.append(mappings_folder)
|
643
116
|
mappings_module_name = mappings_path.stem
|
644
117
|
self.get_dto_class = get_dto_class(
|
645
118
|
mappings_module_name=mappings_module_name
|
646
119
|
)
|
120
|
+
self.get_path_dto_class = get_path_dto_class(
|
121
|
+
mappings_module_name=mappings_module_name
|
122
|
+
)
|
647
123
|
self.get_id_property_name = get_id_property_name(
|
648
124
|
mappings_module_name=mappings_module_name
|
649
125
|
)
|
650
126
|
sys.path.pop()
|
651
127
|
else:
|
652
128
|
self.get_dto_class = get_dto_class(mappings_module_name="no mapping")
|
129
|
+
self.get_path_dto_class = get_path_dto_class(
|
130
|
+
mappings_module_name="no mapping"
|
131
|
+
)
|
653
132
|
self.get_id_property_name = get_id_property_name(
|
654
133
|
mappings_module_name="no mapping"
|
655
134
|
)
|
@@ -660,10 +139,7 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
660
139
|
DEFAULT_ID_PROPERTY_NAME.id_property_name = default_id_property_name
|
661
140
|
self._server_validation_warning_logged = False
|
662
141
|
|
663
|
-
|
664
|
-
def origin(self) -> str:
|
665
|
-
return self._origin
|
666
|
-
|
142
|
+
# region: library configuration keywords
|
667
143
|
@keyword
|
668
144
|
def set_origin(self, origin: str) -> None:
|
669
145
|
"""
|
@@ -710,7 +186,7 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
710
186
|
self.auth = auth
|
711
187
|
|
712
188
|
@keyword
|
713
|
-
def set_extra_headers(self, extra_headers:
|
189
|
+
def set_extra_headers(self, extra_headers: dict[str, str]) -> None:
|
714
190
|
"""
|
715
191
|
Set the `extra_headers` used in requests after the library is imported.
|
716
192
|
|
@@ -719,1022 +195,221 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
719
195
|
"""
|
720
196
|
self.extra_headers = extra_headers
|
721
197
|
|
722
|
-
|
723
|
-
|
724
|
-
|
725
|
-
|
726
|
-
@cached_property
|
727
|
-
def validation_spec(self) -> Spec:
|
728
|
-
_, validation_spec, _ = self._load_specs_and_validator()
|
729
|
-
return validation_spec
|
730
|
-
|
731
|
-
@property
|
732
|
-
def openapi_spec(self) -> Dict[str, Any]:
|
733
|
-
"""Return a deepcopy of the parsed openapi document."""
|
734
|
-
# protect the parsed openapi spec from being mutated by reference
|
735
|
-
return deepcopy(self._openapi_spec)
|
736
|
-
|
737
|
-
@cached_property
|
738
|
-
def _openapi_spec(self) -> Dict[str, Any]:
|
739
|
-
parser, _, _ = self._load_specs_and_validator()
|
740
|
-
return parser.specification
|
741
|
-
|
742
|
-
@cached_property
|
743
|
-
def response_validator(
|
198
|
+
# endregion
|
199
|
+
# region: data generation keywords
|
200
|
+
@keyword
|
201
|
+
def get_request_values(
|
744
202
|
self,
|
745
|
-
|
746
|
-
|
747
|
-
|
203
|
+
path: str,
|
204
|
+
method: str,
|
205
|
+
overrides: Mapping[str, JSON] = default_json_mapping,
|
206
|
+
) -> RequestValues:
|
207
|
+
"""Return an object with all (valid) request values needed to make a request."""
|
208
|
+
json_data: dict[str, JSON] = {}
|
748
209
|
|
749
|
-
|
750
|
-
|
751
|
-
|
210
|
+
url: str = run_keyword("get_valid_url", path)
|
211
|
+
request_data: RequestData = run_keyword("get_request_data", path, method)
|
212
|
+
params = request_data.params
|
213
|
+
headers = request_data.headers
|
214
|
+
if request_data.has_body:
|
215
|
+
json_data = request_data.dto.as_dict()
|
752
216
|
|
753
|
-
|
754
|
-
|
755
|
-
|
756
|
-
|
757
|
-
|
758
|
-
|
217
|
+
request_values = RequestValues(
|
218
|
+
url=url,
|
219
|
+
method=method,
|
220
|
+
params=params,
|
221
|
+
headers=headers,
|
222
|
+
json_data=json_data,
|
223
|
+
)
|
759
224
|
|
225
|
+
for name, value in overrides.items():
|
226
|
+
if name.startswith(("body_", "header_", "query_")):
|
227
|
+
location, _, name_ = name.partition("_")
|
228
|
+
oas_name = get_oas_name_from_safe_name(name_)
|
229
|
+
if location == "body":
|
230
|
+
request_values.override_body_value(name=oas_name, value=value)
|
231
|
+
if location == "header":
|
232
|
+
request_values.override_header_value(name=oas_name, value=value)
|
233
|
+
if location == "query":
|
234
|
+
request_values.override_param_value(name=oas_name, value=str(value))
|
760
235
|
else:
|
761
|
-
|
762
|
-
|
763
|
-
content_type_without_charset, _, _ = content_type.partition(";")
|
764
|
-
yield content_type_without_charset
|
765
|
-
|
766
|
-
if isinstance(item, list):
|
767
|
-
for list_item in item:
|
768
|
-
yield from self._get_json_types(list_item)
|
769
|
-
|
770
|
-
def _load_specs_and_validator(
|
771
|
-
self,
|
772
|
-
) -> Tuple[
|
773
|
-
ResolvingParser,
|
774
|
-
Spec,
|
775
|
-
Callable[[RequestsOpenAPIRequest, RequestsOpenAPIResponse], None],
|
776
|
-
]:
|
777
|
-
try:
|
236
|
+
oas_name = get_oas_name_from_safe_name(name)
|
237
|
+
request_values.override_request_value(name=oas_name, value=value)
|
778
238
|
|
779
|
-
|
780
|
-
limit: int, refstring: str, recursions: Any
|
781
|
-
) -> Any:
|
782
|
-
return self._recursion_default
|
783
|
-
|
784
|
-
# Since parsing of the OAS and creating the Spec can take a long time,
|
785
|
-
# they are cached. This is done by storing them in an imported module that
|
786
|
-
# will have a global scope due to how the Python import system works. This
|
787
|
-
# ensures that in a Suite of Suites where multiple Suites use the same
|
788
|
-
# `source`, that OAS is only parsed / loaded once.
|
789
|
-
parser, validation_spec, response_validator = PARSER_CACHE.get(
|
790
|
-
self._source, (None, None, None)
|
791
|
-
)
|
239
|
+
return request_values
|
792
240
|
|
793
|
-
|
794
|
-
|
795
|
-
|
796
|
-
|
797
|
-
|
798
|
-
|
799
|
-
|
800
|
-
|
801
|
-
|
802
|
-
|
803
|
-
"Source was loaded, but no specification was present after parsing."
|
804
|
-
)
|
805
|
-
|
806
|
-
validation_spec = Spec.from_dict(parser.specification)
|
807
|
-
|
808
|
-
json_types_from_spec: Set[str] = self._get_json_types_from_spec(
|
809
|
-
parser.specification
|
810
|
-
)
|
811
|
-
extra_deserializers = {
|
812
|
-
json_type: _json.loads for json_type in json_types_from_spec
|
813
|
-
}
|
814
|
-
config = Config(extra_media_type_deserializers=extra_deserializers)
|
815
|
-
openapi = OpenAPI(spec=validation_spec, config=config)
|
816
|
-
response_validator = openapi.validate_response
|
817
|
-
|
818
|
-
PARSER_CACHE[self._source] = (
|
819
|
-
parser,
|
820
|
-
validation_spec,
|
821
|
-
response_validator,
|
822
|
-
)
|
241
|
+
@keyword
|
242
|
+
def get_request_data(self, path: str, method: str) -> RequestData:
|
243
|
+
"""Return an object with valid request data for body, headers and query params."""
|
244
|
+
return _data_generation.get_request_data(
|
245
|
+
path=path,
|
246
|
+
method=method,
|
247
|
+
get_dto_class=self.get_dto_class,
|
248
|
+
get_id_property_name=self.get_id_property_name,
|
249
|
+
openapi_spec=self.openapi_spec,
|
250
|
+
)
|
823
251
|
|
824
|
-
|
252
|
+
@keyword
|
253
|
+
def get_invalid_body_data(
|
254
|
+
self,
|
255
|
+
url: str,
|
256
|
+
method: str,
|
257
|
+
status_code: int,
|
258
|
+
request_data: RequestData,
|
259
|
+
) -> dict[str, JSON]:
|
260
|
+
"""
|
261
|
+
Return `json_data` based on the `dto` on the `request_data` that will cause
|
262
|
+
the provided `status_code` for the `method` operation on the `url`.
|
825
263
|
|
826
|
-
|
827
|
-
|
828
|
-
|
829
|
-
|
830
|
-
|
831
|
-
|
832
|
-
|
833
|
-
|
264
|
+
> Note: applicable UniquePropertyValueConstraint and IdReference Relations are
|
265
|
+
considered before changes to `json_data` are made.
|
266
|
+
"""
|
267
|
+
return _data_invalidation.get_invalid_body_data(
|
268
|
+
url=url,
|
269
|
+
method=method,
|
270
|
+
status_code=status_code,
|
271
|
+
request_data=request_data,
|
272
|
+
invalid_property_default_response=self.invalid_property_default_response,
|
273
|
+
)
|
834
274
|
|
835
|
-
|
836
|
-
|
837
|
-
|
275
|
+
@keyword
|
276
|
+
def get_invalidated_parameters(
|
277
|
+
self,
|
278
|
+
status_code: int,
|
279
|
+
request_data: RequestData,
|
280
|
+
) -> tuple[dict[str, JSON], dict[str, JSON]]:
|
838
281
|
"""
|
839
|
-
|
840
|
-
|
282
|
+
Returns a version of `params, headers` as present on `request_data` that has
|
283
|
+
been modified to cause the provided `status_code`.
|
841
284
|
"""
|
842
|
-
|
285
|
+
return _data_invalidation.get_invalidated_parameters(
|
286
|
+
status_code=status_code,
|
287
|
+
request_data=request_data,
|
288
|
+
invalid_property_default_response=self.invalid_property_default_response,
|
289
|
+
)
|
843
290
|
|
844
|
-
|
845
|
-
|
291
|
+
@keyword
|
292
|
+
def get_json_data_with_conflict(
|
293
|
+
self, url: str, method: str, dto: Dto, conflict_status_code: int
|
294
|
+
) -> dict[str, JSON]:
|
295
|
+
"""
|
296
|
+
Return `json_data` based on the `UniquePropertyValueConstraint` that must be
|
297
|
+
returned by the `get_relations` implementation on the `dto` for the given
|
298
|
+
`conflict_status_code`.
|
299
|
+
"""
|
300
|
+
return _data_invalidation.get_json_data_with_conflict(
|
301
|
+
url=url,
|
302
|
+
base_url=self.base_url,
|
303
|
+
method=method,
|
304
|
+
dto=dto,
|
305
|
+
conflict_status_code=conflict_status_code,
|
306
|
+
)
|
846
307
|
|
308
|
+
# endregion
|
309
|
+
# region: path-related keywords
|
847
310
|
@keyword
|
848
|
-
def get_valid_url(self,
|
311
|
+
def get_valid_url(self, path: str) -> str:
|
849
312
|
"""
|
850
|
-
This keyword returns a valid url for the given `
|
313
|
+
This keyword returns a valid url for the given `path`.
|
851
314
|
|
852
|
-
If the `
|
315
|
+
If the `path` contains path parameters the Get Valid Id For Path
|
853
316
|
keyword will be executed to retrieve valid ids for the path parameters.
|
854
317
|
|
855
318
|
> Note: if valid ids cannot be retrieved within the scope of the API, the
|
856
319
|
`PathPropertiesConstraint` Relation can be used. More information can be found
|
857
|
-
[https://marketsquare.github.io/robotframework-
|
320
|
+
[https://marketsquare.github.io/robotframework-openapitools/advanced_use.html | here].
|
858
321
|
"""
|
859
|
-
|
860
|
-
|
861
|
-
|
862
|
-
|
863
|
-
|
864
|
-
|
865
|
-
raise ValueError(
|
866
|
-
f"{endpoint} not found in paths section of the OpenAPI document."
|
867
|
-
) from None
|
868
|
-
dto_class = self.get_dto_class(endpoint=endpoint, method=method)
|
869
|
-
relations = dto_class.get_relations()
|
870
|
-
paths = [p.path for p in relations if isinstance(p, PathPropertiesConstraint)]
|
871
|
-
if paths:
|
872
|
-
url = f"{self.base_url}{choice(paths)}"
|
873
|
-
return url
|
874
|
-
endpoint_parts = list(endpoint.split("/"))
|
875
|
-
for index, part in enumerate(endpoint_parts):
|
876
|
-
if part.startswith("{") and part.endswith("}"):
|
877
|
-
type_endpoint_parts = endpoint_parts[slice(index)]
|
878
|
-
type_endpoint = "/".join(type_endpoint_parts)
|
879
|
-
existing_id: Union[str, int, float] = run_keyword(
|
880
|
-
"get_valid_id_for_endpoint", type_endpoint, method
|
881
|
-
)
|
882
|
-
endpoint_parts[index] = str(existing_id)
|
883
|
-
resolved_endpoint = "/".join(endpoint_parts)
|
884
|
-
url = f"{self.base_url}{resolved_endpoint}"
|
885
|
-
return url
|
322
|
+
return _path_functions.get_valid_url(
|
323
|
+
path=path,
|
324
|
+
base_url=self.base_url,
|
325
|
+
get_path_dto_class=self.get_path_dto_class,
|
326
|
+
openapi_spec=self.openapi_spec,
|
327
|
+
)
|
886
328
|
|
887
329
|
@keyword
|
888
|
-
def
|
889
|
-
self, endpoint: str, method: str
|
890
|
-
) -> Union[str, int, float]:
|
330
|
+
def get_valid_id_for_path(self, path: str) -> str | int | float:
|
891
331
|
"""
|
892
|
-
Support keyword that returns the `id` for an existing resource at `
|
332
|
+
Support keyword that returns the `id` for an existing resource at `path`.
|
893
333
|
|
894
334
|
To prevent resource conflicts with other test cases, a new resource is created
|
895
|
-
(POST) if possible.
|
335
|
+
(by a POST operation) if possible.
|
896
336
|
"""
|
897
|
-
|
898
|
-
|
899
|
-
valid_id: Union[str, int, float]
|
900
|
-
) -> Union[str, int, float]:
|
901
|
-
return valid_id
|
902
|
-
|
903
|
-
method = method.lower()
|
904
|
-
url: str = run_keyword("get_valid_url", endpoint, method)
|
905
|
-
# Try to create a new resource to prevent conflicts caused by
|
906
|
-
# operations performed on the same resource by other test cases
|
907
|
-
request_data = self.get_request_data(endpoint=endpoint, method="post")
|
908
|
-
|
909
|
-
response: Response = run_keyword(
|
910
|
-
"authorized_request",
|
911
|
-
url,
|
912
|
-
"post",
|
913
|
-
request_data.get_required_params(),
|
914
|
-
request_data.get_required_headers(),
|
915
|
-
request_data.get_required_properties_dict(),
|
337
|
+
return _path_functions.get_valid_id_for_path(
|
338
|
+
path=path, get_id_property_name=self.get_id_property_name
|
916
339
|
)
|
917
340
|
|
918
|
-
# determine the id property name for this path and whether or not a transformer is used
|
919
|
-
mapping = self.get_id_property_name(endpoint=endpoint)
|
920
|
-
if isinstance(mapping, str):
|
921
|
-
id_property = mapping
|
922
|
-
# set the transformer to a dummy callable that returns the original value so
|
923
|
-
# the transformer can be applied on any returned id
|
924
|
-
id_transformer = dummy_transformer
|
925
|
-
else:
|
926
|
-
id_property, id_transformer = mapping
|
927
|
-
|
928
|
-
if not response.ok:
|
929
|
-
# If a new resource cannot be created using POST, try to retrieve a
|
930
|
-
# valid id using a GET request.
|
931
|
-
try:
|
932
|
-
valid_id = choice(run_keyword("get_ids_from_url", url))
|
933
|
-
return id_transformer(valid_id)
|
934
|
-
except Exception as exception:
|
935
|
-
raise AssertionError(
|
936
|
-
f"Failed to get a valid id using GET on {url}"
|
937
|
-
) from exception
|
938
|
-
|
939
|
-
response_data = response.json()
|
940
|
-
if prepared_body := response.request.body:
|
941
|
-
if isinstance(prepared_body, bytes):
|
942
|
-
send_json = _json.loads(prepared_body.decode("UTF-8"))
|
943
|
-
else:
|
944
|
-
send_json = _json.loads(prepared_body)
|
945
|
-
else:
|
946
|
-
send_json = None
|
947
|
-
|
948
|
-
# no support for retrieving an id from an array returned on a POST request
|
949
|
-
if isinstance(response_data, list):
|
950
|
-
raise NotImplementedError(
|
951
|
-
f"Unexpected response body for POST request: expected an object but "
|
952
|
-
f"received an array ({response_data})"
|
953
|
-
)
|
954
|
-
|
955
|
-
# POST on /resource_type/{id}/array_item/ will return the updated {id} resource
|
956
|
-
# instead of a newly created resource. In this case, the send_json must be
|
957
|
-
# in the array of the 'array_item' property on {id}
|
958
|
-
send_path: str = response.request.path_url
|
959
|
-
response_href: Optional[str] = response_data.get("href", None)
|
960
|
-
if response_href and (send_path not in response_href) and send_json:
|
961
|
-
try:
|
962
|
-
property_to_check = send_path.replace(response_href, "")[1:]
|
963
|
-
item_list: List[Dict[str, Any]] = response_data[property_to_check]
|
964
|
-
# Use the (mandatory) id to get the POSTed resource from the list
|
965
|
-
[valid_id] = [
|
966
|
-
item[id_property]
|
967
|
-
for item in item_list
|
968
|
-
if item[id_property] == send_json[id_property]
|
969
|
-
]
|
970
|
-
except Exception as exception:
|
971
|
-
raise AssertionError(
|
972
|
-
f"Failed to get a valid id from {response_href}"
|
973
|
-
) from exception
|
974
|
-
else:
|
975
|
-
try:
|
976
|
-
valid_id = response_data[id_property]
|
977
|
-
except KeyError:
|
978
|
-
raise AssertionError(
|
979
|
-
f"Failed to get a valid id from {response_data}"
|
980
|
-
) from None
|
981
|
-
return id_transformer(valid_id)
|
982
|
-
|
983
341
|
@keyword
|
984
|
-
def
|
342
|
+
def get_parameterized_path_from_url(self, url: str) -> str:
|
985
343
|
"""
|
986
|
-
|
987
|
-
`ids` from the response.
|
344
|
+
Return the path as found in the `paths` section based on the given `url`.
|
988
345
|
"""
|
989
|
-
|
990
|
-
request_data = self.get_request_data(endpoint=endpoint, method="get")
|
991
|
-
response = run_keyword(
|
992
|
-
"authorized_request",
|
993
|
-
url,
|
994
|
-
"get",
|
995
|
-
request_data.get_required_params(),
|
996
|
-
request_data.get_required_headers(),
|
997
|
-
)
|
998
|
-
response.raise_for_status()
|
999
|
-
response_data: Union[Dict[str, Any], List[Dict[str, Any]]] = response.json()
|
1000
|
-
|
1001
|
-
# determine the property name to use
|
1002
|
-
mapping = self.get_id_property_name(endpoint=endpoint)
|
1003
|
-
if isinstance(mapping, str):
|
1004
|
-
id_property = mapping
|
1005
|
-
else:
|
1006
|
-
id_property, _ = mapping
|
1007
|
-
|
1008
|
-
if isinstance(response_data, list):
|
1009
|
-
valid_ids: List[str] = [item[id_property] for item in response_data]
|
1010
|
-
return valid_ids
|
1011
|
-
# if the response is an object (dict), check if it's hal+json
|
1012
|
-
if embedded := response_data.get("_embedded"):
|
1013
|
-
# there should be 1 item in the dict that has a value that's a list
|
1014
|
-
for value in embedded.values():
|
1015
|
-
if isinstance(value, list):
|
1016
|
-
valid_ids = [item[id_property] for item in value]
|
1017
|
-
return valid_ids
|
1018
|
-
if (valid_id := response_data.get(id_property)) is not None:
|
1019
|
-
return [valid_id]
|
1020
|
-
valid_ids = [item[id_property] for item in response_data["items"]]
|
1021
|
-
return valid_ids
|
1022
|
-
|
1023
|
-
@keyword
|
1024
|
-
def get_request_data(self, endpoint: str, method: str) -> RequestData:
|
1025
|
-
"""Return an object with valid request data for body, headers and query params."""
|
1026
|
-
method = method.lower()
|
1027
|
-
dto_cls_name = self._get_dto_cls_name(endpoint=endpoint, method=method)
|
1028
|
-
# The endpoint can contain already resolved Ids that have to be matched
|
1029
|
-
# against the parametrized endpoints in the paths section.
|
1030
|
-
spec_endpoint = self.get_parametrized_endpoint(endpoint)
|
1031
|
-
dto_class = self.get_dto_class(endpoint=spec_endpoint, method=method)
|
1032
|
-
try:
|
1033
|
-
method_spec = self.openapi_spec["paths"][spec_endpoint][method]
|
1034
|
-
except KeyError:
|
1035
|
-
logger.info(
|
1036
|
-
f"method '{method}' not supported on '{spec_endpoint}, using empty spec."
|
1037
|
-
)
|
1038
|
-
method_spec = {}
|
1039
|
-
|
1040
|
-
parameters, params, headers = self.get_request_parameters(
|
1041
|
-
dto_class=dto_class, method_spec=method_spec
|
1042
|
-
)
|
1043
|
-
if (body_spec := method_spec.get("requestBody", None)) is None:
|
1044
|
-
if dto_class == DefaultDto:
|
1045
|
-
dto_instance: Dto = DefaultDto()
|
1046
|
-
else:
|
1047
|
-
dto_class = make_dataclass(
|
1048
|
-
cls_name=method_spec.get("operationId", dto_cls_name),
|
1049
|
-
fields=[],
|
1050
|
-
bases=(dto_class,),
|
1051
|
-
)
|
1052
|
-
dto_instance = dto_class()
|
1053
|
-
return RequestData(
|
1054
|
-
dto=dto_instance,
|
1055
|
-
parameters=parameters,
|
1056
|
-
params=params,
|
1057
|
-
headers=headers,
|
1058
|
-
has_body=False,
|
1059
|
-
)
|
1060
|
-
content_schema = resolve_schema(self.get_content_schema(body_spec))
|
1061
|
-
headers.update({"content-type": self.get_content_type(body_spec)})
|
1062
|
-
dto_data = self.get_json_data_for_dto_class(
|
1063
|
-
schema=content_schema,
|
1064
|
-
dto_class=dto_class,
|
1065
|
-
operation_id=method_spec.get("operationId", ""),
|
1066
|
-
)
|
1067
|
-
if dto_data is None:
|
1068
|
-
dto_instance = DefaultDto()
|
1069
|
-
else:
|
1070
|
-
fields = self.get_fields_from_dto_data(content_schema, dto_data)
|
1071
|
-
dto_class = make_dataclass(
|
1072
|
-
cls_name=method_spec.get("operationId", dto_cls_name),
|
1073
|
-
fields=fields,
|
1074
|
-
bases=(dto_class,),
|
1075
|
-
)
|
1076
|
-
dto_data = {get_safe_key(key): value for key, value in dto_data.items()}
|
1077
|
-
dto_instance = dto_class(**dto_data)
|
1078
|
-
return RequestData(
|
1079
|
-
dto=dto_instance,
|
1080
|
-
dto_schema=content_schema,
|
1081
|
-
parameters=parameters,
|
1082
|
-
params=params,
|
1083
|
-
headers=headers,
|
1084
|
-
)
|
1085
|
-
|
1086
|
-
@staticmethod
|
1087
|
-
def _get_dto_cls_name(endpoint: str, method: str) -> str:
|
1088
|
-
method = method.capitalize()
|
1089
|
-
path = endpoint.translate({ord(i): None for i in "{}"})
|
346
|
+
path = url.replace(self.base_url, "")
|
1090
347
|
path_parts = path.split("/")
|
1091
|
-
|
1092
|
-
|
1093
|
-
|
1094
|
-
|
1095
|
-
@staticmethod
|
1096
|
-
def get_fields_from_dto_data(
|
1097
|
-
content_schema: Dict[str, Any], dto_data: Dict[str, Any]
|
1098
|
-
):
|
1099
|
-
# FIXME: annotation is not Pyhon 3.8-compatible
|
1100
|
-
# ) -> List[Union[str, Tuple[str, Type[Any]], Tuple[str, Type[Any], Field[Any]]]]:
|
1101
|
-
"""Get a dataclasses fields list based on the content_schema and dto_data."""
|
1102
|
-
fields: List[
|
1103
|
-
Union[str, Tuple[str, Type[Any]], Tuple[str, Type[Any], Field[Any]]]
|
1104
|
-
] = []
|
1105
|
-
for key, value in dto_data.items():
|
1106
|
-
required_properties = content_schema.get("required", [])
|
1107
|
-
safe_key = get_safe_key(key)
|
1108
|
-
metadata = {"original_property_name": key}
|
1109
|
-
if key in required_properties:
|
1110
|
-
# The fields list is used to create a dataclass, so non-default fields
|
1111
|
-
# must go before fields with a default
|
1112
|
-
fields.insert(0, (safe_key, type(value), field(metadata=metadata)))
|
1113
|
-
else:
|
1114
|
-
fields.append((safe_key, type(value), field(default=None, metadata=metadata))) # type: ignore[arg-type]
|
1115
|
-
return fields
|
1116
|
-
|
1117
|
-
def get_request_parameters(
|
1118
|
-
self, dto_class: Union[Dto, Type[Dto]], method_spec: Dict[str, Any]
|
1119
|
-
) -> Tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, str]]:
|
1120
|
-
"""Get the methods parameter spec and params and headers with valid data."""
|
1121
|
-
parameters = method_spec.get("parameters", [])
|
1122
|
-
parameter_relations = dto_class.get_parameter_relations()
|
1123
|
-
query_params = [p for p in parameters if p.get("in") == "query"]
|
1124
|
-
header_params = [p for p in parameters if p.get("in") == "header"]
|
1125
|
-
params = self.get_parameter_data(query_params, parameter_relations)
|
1126
|
-
headers = self.get_parameter_data(header_params, parameter_relations)
|
1127
|
-
return parameters, params, headers
|
1128
|
-
|
1129
|
-
@classmethod
|
1130
|
-
def get_content_schema(cls, body_spec: Dict[str, Any]) -> Dict[str, Any]:
|
1131
|
-
"""Get the content schema from the requestBody spec."""
|
1132
|
-
content_type = cls.get_content_type(body_spec)
|
1133
|
-
content_schema = body_spec["content"][content_type]["schema"]
|
1134
|
-
return resolve_schema(content_schema)
|
1135
|
-
|
1136
|
-
@staticmethod
|
1137
|
-
def get_content_type(body_spec: Dict[str, Any]) -> str:
|
1138
|
-
"""Get and validate the first supported content type from the requested body spec
|
1139
|
-
|
1140
|
-
Should be application/json like content type,
|
1141
|
-
e.g "application/json;charset=utf-8" or "application/merge-patch+json"
|
1142
|
-
"""
|
1143
|
-
content_types: List[str] = body_spec["content"].keys()
|
1144
|
-
json_regex = r"application/([a-z\-]+\+)?json(;\s?charset=(.+))?"
|
1145
|
-
for content_type in content_types:
|
1146
|
-
if re.search(json_regex, content_type):
|
1147
|
-
return content_type
|
1148
|
-
|
1149
|
-
# At present no supported for other types.
|
1150
|
-
raise NotImplementedError(
|
1151
|
-
f"Only content types like 'application/json' are supported. "
|
1152
|
-
f"Content types definded in the spec are '{content_types}'."
|
348
|
+
# first part will be '' since a path starts with /
|
349
|
+
path_parts.pop(0)
|
350
|
+
parameterized_path = _path_functions.get_parametrized_path(
|
351
|
+
path=path, openapi_spec=self.openapi_spec
|
1153
352
|
)
|
1154
|
-
|
1155
|
-
def get_parametrized_endpoint(self, endpoint: str) -> str:
|
1156
|
-
"""
|
1157
|
-
Get the parametrized endpoint as found in the `paths` section of the openapi
|
1158
|
-
document from a (partially) resolved endpoint.
|
1159
|
-
"""
|
1160
|
-
|
1161
|
-
def match_parts(parts: List[str], spec_parts: List[str]) -> bool:
|
1162
|
-
for part, spec_part in zip_longest(parts, spec_parts, fillvalue="Filler"):
|
1163
|
-
if part == "Filler" or spec_part == "Filler":
|
1164
|
-
return False
|
1165
|
-
if part != spec_part and not spec_part.startswith("{"):
|
1166
|
-
return False
|
1167
|
-
return True
|
1168
|
-
|
1169
|
-
endpoint_parts = endpoint.split("/")
|
1170
|
-
# if the last part is empty, the path has a trailing `/` that
|
1171
|
-
# should be ignored during matching
|
1172
|
-
if endpoint_parts[-1] == "":
|
1173
|
-
_ = endpoint_parts.pop(-1)
|
1174
|
-
|
1175
|
-
spec_endpoints: List[str] = {**self.openapi_spec}["paths"].keys()
|
1176
|
-
|
1177
|
-
candidates: List[str] = []
|
1178
|
-
|
1179
|
-
for spec_endpoint in spec_endpoints:
|
1180
|
-
spec_endpoint_parts = spec_endpoint.split("/")
|
1181
|
-
# ignore trailing `/` the same way as for endpoint_parts
|
1182
|
-
if spec_endpoint_parts[-1] == "":
|
1183
|
-
_ = spec_endpoint_parts.pop(-1)
|
1184
|
-
if match_parts(endpoint_parts, spec_endpoint_parts):
|
1185
|
-
candidates.append(spec_endpoint)
|
1186
|
-
|
1187
|
-
if not candidates:
|
1188
|
-
raise ValueError(
|
1189
|
-
f"{endpoint} not found in paths section of the OpenAPI document."
|
1190
|
-
)
|
1191
|
-
|
1192
|
-
if len(candidates) == 1:
|
1193
|
-
return candidates[0]
|
1194
|
-
# Multiple matches can happen in APIs with overloaded endpoints, e.g.
|
1195
|
-
# /users/me
|
1196
|
-
# /users/${user_id}
|
1197
|
-
# In this case, find the closest (or exact) match
|
1198
|
-
exact_match = [c for c in candidates if c == endpoint]
|
1199
|
-
if exact_match:
|
1200
|
-
return exact_match[0]
|
1201
|
-
# TODO: Implement a decision mechanism when real-world examples become available
|
1202
|
-
# In the face of ambiguity, refuse the temptation to guess.
|
1203
|
-
raise ValueError(f"{endpoint} matched to multiple paths: {candidates}")
|
1204
|
-
|
1205
|
-
@staticmethod
|
1206
|
-
def get_parameter_data(
|
1207
|
-
parameters: List[Dict[str, Any]],
|
1208
|
-
parameter_relations: List[Relation],
|
1209
|
-
) -> Dict[str, str]:
|
1210
|
-
"""Generate a valid list of key-value pairs for all parameters."""
|
1211
|
-
result: Dict[str, str] = {}
|
1212
|
-
value: Any = None
|
1213
|
-
for parameter in parameters:
|
1214
|
-
parameter_name = parameter["name"]
|
1215
|
-
parameter_schema = resolve_schema(parameter["schema"])
|
1216
|
-
relations = [
|
1217
|
-
r for r in parameter_relations if r.property_name == parameter_name
|
1218
|
-
]
|
1219
|
-
if constrained_values := [
|
1220
|
-
r.values for r in relations if isinstance(r, PropertyValueConstraint)
|
1221
|
-
]:
|
1222
|
-
value = choice(*constrained_values)
|
1223
|
-
if value is IGNORE:
|
1224
|
-
continue
|
1225
|
-
result[parameter_name] = value
|
1226
|
-
continue
|
1227
|
-
value = value_utils.get_valid_value(parameter_schema)
|
1228
|
-
result[parameter_name] = value
|
1229
|
-
return result
|
353
|
+
return parameterized_path
|
1230
354
|
|
1231
355
|
@keyword
|
1232
|
-
def
|
1233
|
-
self,
|
1234
|
-
schema: Dict[str, Any],
|
1235
|
-
dto_class: Union[Dto, Type[Dto]],
|
1236
|
-
operation_id: str = "",
|
1237
|
-
) -> Optional[Dict[str, Any]]:
|
356
|
+
def get_ids_from_url(self, url: str) -> list[str]:
|
1238
357
|
"""
|
1239
|
-
|
358
|
+
Perform a GET request on the `url` and return the list of resource
|
359
|
+
`ids` from the response.
|
1240
360
|
"""
|
1241
|
-
|
1242
|
-
|
1243
|
-
|
1244
|
-
values_list = [
|
1245
|
-
c.values
|
1246
|
-
for c in relations
|
1247
|
-
if (
|
1248
|
-
isinstance(c, PropertyValueConstraint)
|
1249
|
-
and c.property_name == property_name
|
1250
|
-
)
|
1251
|
-
]
|
1252
|
-
# values should be empty or contain 1 list of allowed values
|
1253
|
-
return values_list.pop() if values_list else []
|
1254
|
-
|
1255
|
-
def get_dependent_id(
|
1256
|
-
property_name: str, operation_id: str
|
1257
|
-
) -> Optional[Union[str, int, float]]:
|
1258
|
-
relations = dto_class.get_relations()
|
1259
|
-
# multiple get paths are possible based on the operation being performed
|
1260
|
-
id_get_paths = [
|
1261
|
-
(d.get_path, d.operation_id)
|
1262
|
-
for d in relations
|
1263
|
-
if (isinstance(d, IdDependency) and d.property_name == property_name)
|
1264
|
-
]
|
1265
|
-
if not id_get_paths:
|
1266
|
-
return None
|
1267
|
-
if len(id_get_paths) == 1:
|
1268
|
-
id_get_path, _ = id_get_paths.pop()
|
1269
|
-
else:
|
1270
|
-
try:
|
1271
|
-
[id_get_path] = [
|
1272
|
-
path
|
1273
|
-
for path, operation in id_get_paths
|
1274
|
-
if operation == operation_id
|
1275
|
-
]
|
1276
|
-
# There could be multiple get_paths, but not one for the current operation
|
1277
|
-
except ValueError:
|
1278
|
-
return None
|
1279
|
-
valid_id = self.get_valid_id_for_endpoint(
|
1280
|
-
endpoint=id_get_path, method="get"
|
1281
|
-
)
|
1282
|
-
logger.debug(f"get_dependent_id for {id_get_path} returned {valid_id}")
|
1283
|
-
return valid_id
|
1284
|
-
|
1285
|
-
json_data: Dict[str, Any] = {}
|
1286
|
-
|
1287
|
-
property_names = []
|
1288
|
-
for property_name in schema.get("properties", []):
|
1289
|
-
if constrained_values := get_constrained_values(property_name):
|
1290
|
-
# do not add properties that are configured to be ignored
|
1291
|
-
if IGNORE in constrained_values:
|
1292
|
-
continue
|
1293
|
-
property_names.append(property_name)
|
1294
|
-
|
1295
|
-
max_properties = schema.get("maxProperties")
|
1296
|
-
if max_properties and len(property_names) > max_properties:
|
1297
|
-
required_properties = schema.get("required", [])
|
1298
|
-
number_of_optional_properties = max_properties - len(required_properties)
|
1299
|
-
optional_properties = [
|
1300
|
-
name for name in property_names if name not in required_properties
|
1301
|
-
]
|
1302
|
-
selected_optional_properties = sample(
|
1303
|
-
optional_properties, number_of_optional_properties
|
1304
|
-
)
|
1305
|
-
property_names = required_properties + selected_optional_properties
|
1306
|
-
|
1307
|
-
for property_name in property_names:
|
1308
|
-
properties_schema = schema["properties"][property_name]
|
1309
|
-
|
1310
|
-
property_type = properties_schema.get("type")
|
1311
|
-
if property_type is None:
|
1312
|
-
property_types = properties_schema.get("types")
|
1313
|
-
if property_types is None:
|
1314
|
-
if properties_schema.get("properties") is not None:
|
1315
|
-
nested_data = self.get_json_data_for_dto_class(
|
1316
|
-
schema=properties_schema,
|
1317
|
-
dto_class=DefaultDto,
|
1318
|
-
)
|
1319
|
-
json_data[property_name] = nested_data
|
1320
|
-
continue
|
1321
|
-
selected_type_schema = choice(property_types)
|
1322
|
-
property_type = selected_type_schema["type"]
|
1323
|
-
if properties_schema.get("readOnly", False):
|
1324
|
-
continue
|
1325
|
-
if constrained_values := get_constrained_values(property_name):
|
1326
|
-
json_data[property_name] = choice(constrained_values)
|
1327
|
-
continue
|
1328
|
-
if (
|
1329
|
-
dependent_id := get_dependent_id(
|
1330
|
-
property_name=property_name, operation_id=operation_id
|
1331
|
-
)
|
1332
|
-
) is not None:
|
1333
|
-
json_data[property_name] = dependent_id
|
1334
|
-
continue
|
1335
|
-
if property_type == "object":
|
1336
|
-
object_data = self.get_json_data_for_dto_class(
|
1337
|
-
schema=properties_schema,
|
1338
|
-
dto_class=DefaultDto,
|
1339
|
-
operation_id="",
|
1340
|
-
)
|
1341
|
-
json_data[property_name] = object_data
|
1342
|
-
continue
|
1343
|
-
if property_type == "array":
|
1344
|
-
array_data = self.get_json_data_for_dto_class(
|
1345
|
-
schema=properties_schema["items"],
|
1346
|
-
dto_class=DefaultDto,
|
1347
|
-
operation_id=operation_id,
|
1348
|
-
)
|
1349
|
-
json_data[property_name] = [array_data]
|
1350
|
-
continue
|
1351
|
-
json_data[property_name] = value_utils.get_valid_value(properties_schema)
|
1352
|
-
|
1353
|
-
return json_data
|
361
|
+
return _path_functions.get_ids_from_url(
|
362
|
+
url=url, get_id_property_name=self.get_id_property_name
|
363
|
+
)
|
1354
364
|
|
1355
365
|
@keyword
|
1356
366
|
def get_invalidated_url(
|
1357
367
|
self,
|
1358
368
|
valid_url: str,
|
1359
369
|
path: str = "",
|
1360
|
-
method: str = "",
|
1361
370
|
expected_status_code: int = 404,
|
1362
|
-
) ->
|
371
|
+
) -> str:
|
1363
372
|
"""
|
1364
373
|
Return an url with all the path parameters in the `valid_url` replaced by a
|
1365
|
-
random UUID if no PathPropertiesConstraint is mapped for the `
|
1366
|
-
and `expected_status_code`.
|
374
|
+
random UUID if no PathPropertiesConstraint is mapped for the `"get"` operation
|
375
|
+
on the mapped `path` and `expected_status_code`.
|
1367
376
|
If a PathPropertiesConstraint is mapped, the `invalid_value` is returned.
|
1368
377
|
|
1369
|
-
Raises ValueError if the valid_url cannot be invalidated.
|
1370
|
-
"""
|
1371
|
-
dto_class = self.get_dto_class(endpoint=path, method=method)
|
1372
|
-
relations = dto_class.get_relations()
|
1373
|
-
paths = [
|
1374
|
-
p.invalid_value
|
1375
|
-
for p in relations
|
1376
|
-
if isinstance(p, PathPropertiesConstraint)
|
1377
|
-
and p.invalid_value_error_code == expected_status_code
|
1378
|
-
]
|
1379
|
-
if paths:
|
1380
|
-
url = f"{self.base_url}{choice(paths)}"
|
1381
|
-
return url
|
1382
|
-
parameterized_endpoint = self.get_parameterized_endpoint_from_url(valid_url)
|
1383
|
-
parameterized_url = self.base_url + parameterized_endpoint
|
1384
|
-
valid_url_parts = list(reversed(valid_url.split("/")))
|
1385
|
-
parameterized_parts = reversed(parameterized_url.split("/"))
|
1386
|
-
for index, (parameterized_part, _) in enumerate(
|
1387
|
-
zip(parameterized_parts, valid_url_parts)
|
1388
|
-
):
|
1389
|
-
if parameterized_part.startswith("{") and parameterized_part.endswith("}"):
|
1390
|
-
valid_url_parts[index] = uuid4().hex
|
1391
|
-
valid_url_parts.reverse()
|
1392
|
-
invalid_url = "/".join(valid_url_parts)
|
1393
|
-
return invalid_url
|
1394
|
-
raise ValueError(f"{parameterized_endpoint} could not be invalidated.")
|
1395
|
-
|
1396
|
-
@keyword
|
1397
|
-
def get_parameterized_endpoint_from_url(self, url: str) -> str:
|
1398
|
-
"""
|
1399
|
-
Return the endpoint as found in the `paths` section based on the given `url`.
|
1400
|
-
"""
|
1401
|
-
endpoint = url.replace(self.base_url, "")
|
1402
|
-
endpoint_parts = endpoint.split("/")
|
1403
|
-
# first part will be '' since an endpoint starts with /
|
1404
|
-
endpoint_parts.pop(0)
|
1405
|
-
parameterized_endpoint = self.get_parametrized_endpoint(endpoint=endpoint)
|
1406
|
-
return parameterized_endpoint
|
1407
|
-
|
1408
|
-
@keyword
|
1409
|
-
def get_invalid_json_data(
|
1410
|
-
self,
|
1411
|
-
url: str,
|
1412
|
-
method: str,
|
1413
|
-
status_code: int,
|
1414
|
-
request_data: RequestData,
|
1415
|
-
) -> Dict[str, Any]:
|
1416
|
-
"""
|
1417
|
-
Return `json_data` based on the `dto` on the `request_data` that will cause
|
1418
|
-
the provided `status_code` for the `method` operation on the `url`.
|
1419
|
-
|
1420
|
-
> Note: applicable UniquePropertyValueConstraint and IdReference Relations are
|
1421
|
-
considered before changes to `json_data` are made.
|
1422
|
-
"""
|
1423
|
-
method = method.lower()
|
1424
|
-
data_relations = request_data.dto.get_relations_for_error_code(status_code)
|
1425
|
-
data_relations = [
|
1426
|
-
r for r in data_relations if not isinstance(r, PathPropertiesConstraint)
|
1427
|
-
]
|
1428
|
-
if not data_relations:
|
1429
|
-
if not request_data.dto_schema:
|
1430
|
-
raise ValueError(
|
1431
|
-
"Failed to invalidate: no data_relations and empty schema."
|
1432
|
-
)
|
1433
|
-
json_data = request_data.dto.get_invalidated_data(
|
1434
|
-
schema=request_data.dto_schema,
|
1435
|
-
status_code=status_code,
|
1436
|
-
invalid_property_default_code=self.invalid_property_default_response,
|
1437
|
-
)
|
1438
|
-
return json_data
|
1439
|
-
resource_relation = choice(data_relations)
|
1440
|
-
if isinstance(resource_relation, UniquePropertyValueConstraint):
|
1441
|
-
json_data = run_keyword(
|
1442
|
-
"get_json_data_with_conflict",
|
1443
|
-
url,
|
1444
|
-
method,
|
1445
|
-
request_data.dto,
|
1446
|
-
status_code,
|
1447
|
-
)
|
1448
|
-
elif isinstance(resource_relation, IdReference):
|
1449
|
-
run_keyword("ensure_in_use", url, resource_relation)
|
1450
|
-
json_data = request_data.dto.as_dict()
|
1451
|
-
else:
|
1452
|
-
json_data = request_data.dto.get_invalidated_data(
|
1453
|
-
schema=request_data.dto_schema,
|
1454
|
-
status_code=status_code,
|
1455
|
-
invalid_property_default_code=self.invalid_property_default_response,
|
1456
|
-
)
|
1457
|
-
return json_data
|
1458
|
-
|
1459
|
-
@keyword
|
1460
|
-
def get_invalidated_parameters(
|
1461
|
-
self,
|
1462
|
-
status_code: int,
|
1463
|
-
request_data: RequestData,
|
1464
|
-
) -> Tuple[Dict[str, Any], Dict[str, str]]:
|
378
|
+
Raises: ValueError if the valid_url cannot be invalidated.
|
1465
379
|
"""
|
1466
|
-
|
1467
|
-
|
1468
|
-
|
1469
|
-
|
1470
|
-
|
1471
|
-
|
1472
|
-
# ensure the status_code can be triggered
|
1473
|
-
relations = request_data.dto.get_parameter_relations_for_error_code(status_code)
|
1474
|
-
relations_for_status_code = [
|
1475
|
-
r
|
1476
|
-
for r in relations
|
1477
|
-
if isinstance(r, PropertyValueConstraint)
|
1478
|
-
and (
|
1479
|
-
r.error_code == status_code or r.invalid_value_error_code == status_code
|
1480
|
-
)
|
1481
|
-
]
|
1482
|
-
parameters_to_ignore = {
|
1483
|
-
r.property_name
|
1484
|
-
for r in relations_for_status_code
|
1485
|
-
if r.invalid_value_error_code == status_code and r.invalid_value == IGNORE
|
1486
|
-
}
|
1487
|
-
relation_property_names = {r.property_name for r in relations_for_status_code}
|
1488
|
-
if not relation_property_names:
|
1489
|
-
if status_code != self.invalid_property_default_response:
|
1490
|
-
raise ValueError(
|
1491
|
-
f"No relations to cause status_code {status_code} found."
|
1492
|
-
)
|
1493
|
-
|
1494
|
-
# ensure we're not modifying mutable properties
|
1495
|
-
params = deepcopy(request_data.params)
|
1496
|
-
headers = deepcopy(request_data.headers)
|
1497
|
-
|
1498
|
-
if status_code == self.invalid_property_default_response:
|
1499
|
-
# take the params and headers that can be invalidated based on data type
|
1500
|
-
# and expand the set with properties that can be invalided by relations
|
1501
|
-
parameter_names = set(request_data.params_that_can_be_invalidated).union(
|
1502
|
-
request_data.headers_that_can_be_invalidated
|
1503
|
-
)
|
1504
|
-
parameter_names.update(relation_property_names)
|
1505
|
-
if not parameter_names:
|
1506
|
-
raise ValueError(
|
1507
|
-
"None of the query parameters and headers can be invalidated."
|
1508
|
-
)
|
1509
|
-
else:
|
1510
|
-
# non-default status_codes can only be the result of a Relation
|
1511
|
-
parameter_names = relation_property_names
|
1512
|
-
|
1513
|
-
# Dto mappings may contain generic mappings for properties that are not present
|
1514
|
-
# in this specific schema
|
1515
|
-
request_data_parameter_names = [p.get("name") for p in request_data.parameters]
|
1516
|
-
additional_relation_property_names = {
|
1517
|
-
n for n in relation_property_names if n not in request_data_parameter_names
|
1518
|
-
}
|
1519
|
-
if additional_relation_property_names:
|
1520
|
-
logger.warning(
|
1521
|
-
f"get_parameter_relations_for_error_code yielded properties that are "
|
1522
|
-
f"not defined in the schema: {additional_relation_property_names}\n"
|
1523
|
-
f"These properties will be ignored for parameter invalidation."
|
1524
|
-
)
|
1525
|
-
parameter_names = parameter_names - additional_relation_property_names
|
1526
|
-
|
1527
|
-
if not parameter_names:
|
1528
|
-
raise ValueError(
|
1529
|
-
f"No parameter can be changed to cause status_code {status_code}."
|
1530
|
-
)
|
1531
|
-
|
1532
|
-
parameter_names = parameter_names - parameters_to_ignore
|
1533
|
-
parameter_to_invalidate = choice(tuple(parameter_names))
|
1534
|
-
|
1535
|
-
# check for invalid parameters in the provided request_data
|
1536
|
-
try:
|
1537
|
-
[parameter_data] = [
|
1538
|
-
data
|
1539
|
-
for data in request_data.parameters
|
1540
|
-
if data["name"] == parameter_to_invalidate
|
1541
|
-
]
|
1542
|
-
except Exception:
|
1543
|
-
raise ValueError(
|
1544
|
-
f"{parameter_to_invalidate} not found in provided parameters."
|
1545
|
-
) from None
|
1546
|
-
|
1547
|
-
# get the invalid_value for the chosen parameter
|
1548
|
-
try:
|
1549
|
-
[invalid_value_for_error_code] = [
|
1550
|
-
r.invalid_value
|
1551
|
-
for r in relations_for_status_code
|
1552
|
-
if r.property_name == parameter_to_invalidate
|
1553
|
-
and r.invalid_value_error_code == status_code
|
1554
|
-
]
|
1555
|
-
except ValueError:
|
1556
|
-
invalid_value_for_error_code = NOT_SET
|
1557
|
-
|
1558
|
-
# get the constraint values if available for the chosen parameter
|
1559
|
-
try:
|
1560
|
-
[values_from_constraint] = [
|
1561
|
-
r.values
|
1562
|
-
for r in relations_for_status_code
|
1563
|
-
if r.property_name == parameter_to_invalidate
|
1564
|
-
]
|
1565
|
-
except ValueError:
|
1566
|
-
values_from_constraint = []
|
1567
|
-
|
1568
|
-
# if the parameter was not provided, add it to params / headers
|
1569
|
-
params, headers = self.ensure_parameter_in_parameters(
|
1570
|
-
parameter_to_invalidate=parameter_to_invalidate,
|
1571
|
-
params=params,
|
1572
|
-
headers=headers,
|
1573
|
-
parameter_data=parameter_data,
|
1574
|
-
values_from_constraint=values_from_constraint,
|
380
|
+
return _path_invalidation.get_invalidated_url(
|
381
|
+
valid_url=valid_url,
|
382
|
+
path=path,
|
383
|
+
base_url=self.base_url,
|
384
|
+
get_path_dto_class=self.get_path_dto_class,
|
385
|
+
expected_status_code=expected_status_code,
|
1575
386
|
)
|
1576
387
|
|
1577
|
-
|
1578
|
-
|
1579
|
-
invalid_value = invalid_value_for_error_code
|
1580
|
-
else:
|
1581
|
-
if parameter_to_invalidate in params.keys():
|
1582
|
-
valid_value = params[parameter_to_invalidate]
|
1583
|
-
else:
|
1584
|
-
valid_value = headers[parameter_to_invalidate]
|
1585
|
-
|
1586
|
-
value_schema = resolve_schema(parameter_data["schema"])
|
1587
|
-
invalid_value = value_utils.get_invalid_value(
|
1588
|
-
value_schema=value_schema,
|
1589
|
-
current_value=valid_value,
|
1590
|
-
values_from_constraint=values_from_constraint,
|
1591
|
-
)
|
1592
|
-
logger.debug(f"{parameter_to_invalidate} changed to {invalid_value}")
|
1593
|
-
|
1594
|
-
# update the params / headers and return
|
1595
|
-
if parameter_to_invalidate in params.keys():
|
1596
|
-
params[parameter_to_invalidate] = invalid_value
|
1597
|
-
else:
|
1598
|
-
headers[parameter_to_invalidate] = invalid_value
|
1599
|
-
return params, headers
|
1600
|
-
|
1601
|
-
@staticmethod
|
1602
|
-
def ensure_parameter_in_parameters(
|
1603
|
-
parameter_to_invalidate: str,
|
1604
|
-
params: Dict[str, Any],
|
1605
|
-
headers: Dict[str, str],
|
1606
|
-
parameter_data: Dict[str, Any],
|
1607
|
-
values_from_constraint: List[Any],
|
1608
|
-
) -> Tuple[Dict[str, Any], Dict[str, str]]:
|
1609
|
-
"""
|
1610
|
-
Returns the params, headers tuple with parameter_to_invalidate with a valid
|
1611
|
-
value to params or headers if not originally present.
|
1612
|
-
"""
|
1613
|
-
if (
|
1614
|
-
parameter_to_invalidate not in params.keys()
|
1615
|
-
and parameter_to_invalidate not in headers.keys()
|
1616
|
-
):
|
1617
|
-
if values_from_constraint:
|
1618
|
-
valid_value = choice(values_from_constraint)
|
1619
|
-
else:
|
1620
|
-
parameter_schema = resolve_schema(parameter_data["schema"])
|
1621
|
-
valid_value = value_utils.get_valid_value(parameter_schema)
|
1622
|
-
if (
|
1623
|
-
parameter_data["in"] == "query"
|
1624
|
-
and parameter_to_invalidate not in params.keys()
|
1625
|
-
):
|
1626
|
-
params[parameter_to_invalidate] = valid_value
|
1627
|
-
if (
|
1628
|
-
parameter_data["in"] == "header"
|
1629
|
-
and parameter_to_invalidate not in headers.keys()
|
1630
|
-
):
|
1631
|
-
headers[parameter_to_invalidate] = valid_value
|
1632
|
-
return params, headers
|
1633
|
-
|
388
|
+
# endregion
|
389
|
+
# region: resource relations keywords
|
1634
390
|
@keyword
|
1635
391
|
def ensure_in_use(self, url: str, resource_relation: IdReference) -> None:
|
1636
392
|
"""
|
1637
393
|
Ensure that the (right-most) `id` of the resource referenced by the `url`
|
1638
394
|
is used by the resource defined by the `resource_relation`.
|
1639
395
|
"""
|
1640
|
-
|
1641
|
-
|
1642
|
-
|
1643
|
-
|
1644
|
-
|
1645
|
-
parameterized_endpoint_parts = parameterized_endpoint.split("/")
|
1646
|
-
for part, param_part in zip(
|
1647
|
-
reversed(endpoint_parts), reversed(parameterized_endpoint_parts)
|
1648
|
-
):
|
1649
|
-
if param_part.endswith("}"):
|
1650
|
-
resource_id = part
|
1651
|
-
break
|
1652
|
-
if not resource_id:
|
1653
|
-
raise ValueError(f"The provided url ({url}) does not contain an id.")
|
1654
|
-
request_data = self.get_request_data(
|
1655
|
-
method="post", endpoint=resource_relation.post_path
|
1656
|
-
)
|
1657
|
-
json_data = request_data.dto.as_dict()
|
1658
|
-
json_data[resource_relation.property_name] = resource_id
|
1659
|
-
post_url: str = run_keyword(
|
1660
|
-
"get_valid_url",
|
1661
|
-
resource_relation.post_path,
|
1662
|
-
"post",
|
1663
|
-
)
|
1664
|
-
response: Response = run_keyword(
|
1665
|
-
"authorized_request",
|
1666
|
-
post_url,
|
1667
|
-
"post",
|
1668
|
-
request_data.params,
|
1669
|
-
request_data.headers,
|
1670
|
-
json_data,
|
1671
|
-
)
|
1672
|
-
if not response.ok:
|
1673
|
-
logger.debug(
|
1674
|
-
f"POST on {post_url} with json {json_data} failed: {response.json()}"
|
1675
|
-
)
|
1676
|
-
response.raise_for_status()
|
1677
|
-
|
1678
|
-
@keyword
|
1679
|
-
def get_json_data_with_conflict(
|
1680
|
-
self, url: str, method: str, dto: Dto, conflict_status_code: int
|
1681
|
-
) -> Dict[str, Any]:
|
1682
|
-
"""
|
1683
|
-
Return `json_data` based on the `UniquePropertyValueConstraint` that must be
|
1684
|
-
returned by the `get_relations` implementation on the `dto` for the given
|
1685
|
-
`conflict_status_code`.
|
1686
|
-
"""
|
1687
|
-
method = method.lower()
|
1688
|
-
json_data = dto.as_dict()
|
1689
|
-
unique_property_value_constraints = [
|
1690
|
-
r
|
1691
|
-
for r in dto.get_relations()
|
1692
|
-
if isinstance(r, UniquePropertyValueConstraint)
|
1693
|
-
]
|
1694
|
-
for relation in unique_property_value_constraints:
|
1695
|
-
json_data[relation.property_name] = relation.value
|
1696
|
-
# create a new resource that the original request will conflict with
|
1697
|
-
if method in ["patch", "put"]:
|
1698
|
-
post_url_parts = url.split("/")[:-1]
|
1699
|
-
post_url = "/".join(post_url_parts)
|
1700
|
-
# the PATCH or PUT may use a different dto than required for POST
|
1701
|
-
# so a valid POST dto must be constructed
|
1702
|
-
endpoint = post_url.replace(self.base_url, "")
|
1703
|
-
request_data = self.get_request_data(endpoint=endpoint, method="post")
|
1704
|
-
post_json = request_data.dto.as_dict()
|
1705
|
-
for key in post_json.keys():
|
1706
|
-
if key in json_data:
|
1707
|
-
post_json[key] = json_data.get(key)
|
1708
|
-
else:
|
1709
|
-
post_url = url
|
1710
|
-
post_json = json_data
|
1711
|
-
endpoint = post_url.replace(self.base_url, "")
|
1712
|
-
request_data = self.get_request_data(endpoint=endpoint, method="post")
|
1713
|
-
response: Response = run_keyword(
|
1714
|
-
"authorized_request",
|
1715
|
-
post_url,
|
1716
|
-
"post",
|
1717
|
-
request_data.params,
|
1718
|
-
request_data.headers,
|
1719
|
-
post_json,
|
1720
|
-
)
|
1721
|
-
# conflicting resource may already exist
|
1722
|
-
assert (
|
1723
|
-
response.ok or response.status_code == conflict_status_code
|
1724
|
-
), f"get_json_data_with_conflict received {response.status_code}: {response.json()}"
|
1725
|
-
return json_data
|
1726
|
-
raise ValueError(
|
1727
|
-
f"No UniquePropertyValueConstraint in the get_relations list on dto {dto}."
|
396
|
+
_resource_relations.ensure_in_use(
|
397
|
+
url=url,
|
398
|
+
base_url=self.base_url,
|
399
|
+
openapi_spec=self.openapi_spec,
|
400
|
+
resource_relation=resource_relation,
|
1728
401
|
)
|
1729
402
|
|
403
|
+
# endregion
|
404
|
+
# region: request keywords
|
1730
405
|
@keyword
|
1731
406
|
def authorized_request( # pylint: disable=too-many-arguments
|
1732
407
|
self,
|
1733
408
|
url: str,
|
1734
409
|
method: str,
|
1735
|
-
params:
|
1736
|
-
headers:
|
1737
|
-
json_data:
|
410
|
+
params: dict[str, Any] | None = None,
|
411
|
+
headers: dict[str, str] | None = None,
|
412
|
+
json_data: JSON = None,
|
1738
413
|
data: Any = None,
|
1739
414
|
files: Any = None,
|
1740
415
|
) -> Response:
|
@@ -1749,7 +424,7 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
1749
424
|
> Note: provided username / password or auth objects take precedence over token
|
1750
425
|
based security
|
1751
426
|
"""
|
1752
|
-
headers = headers if headers else {}
|
427
|
+
headers = deepcopy(headers) if headers else {}
|
1753
428
|
if self.extra_headers:
|
1754
429
|
headers.update(self.extra_headers)
|
1755
430
|
# if both an auth object and a token are available, auth takes precedence
|
@@ -1774,90 +449,64 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
1774
449
|
logger.debug(f"Response text: {response.text}")
|
1775
450
|
return response
|
1776
451
|
|
452
|
+
# endregion
|
453
|
+
# region: validation keywords
|
1777
454
|
@keyword
|
1778
455
|
def perform_validated_request(
|
1779
456
|
self,
|
1780
457
|
path: str,
|
1781
458
|
status_code: int,
|
1782
459
|
request_values: RequestValues,
|
1783
|
-
original_data:
|
460
|
+
original_data: Mapping[str, JSON] = default_json_mapping,
|
1784
461
|
) -> None:
|
1785
462
|
"""
|
1786
463
|
This keyword first calls the Authorized Request keyword, then the Validate
|
1787
464
|
Response keyword and finally validates, for `DELETE` operations, whether
|
1788
465
|
the target resource was indeed deleted (OK response) or not (error responses).
|
1789
466
|
"""
|
1790
|
-
|
1791
|
-
|
1792
|
-
|
1793
|
-
request_values
|
1794
|
-
|
1795
|
-
request_values.headers,
|
1796
|
-
request_values.json_data,
|
467
|
+
_validation.perform_validated_request(
|
468
|
+
path=path,
|
469
|
+
status_code=status_code,
|
470
|
+
request_values=request_values,
|
471
|
+
original_data=original_data,
|
1797
472
|
)
|
1798
|
-
if response.status_code != status_code:
|
1799
|
-
try:
|
1800
|
-
response_json = response.json()
|
1801
|
-
except Exception as _: # pylint: disable=broad-except
|
1802
|
-
logger.info(
|
1803
|
-
f"Failed to get json content from response. "
|
1804
|
-
f"Response text was: {response.text}"
|
1805
|
-
)
|
1806
|
-
response_json = {}
|
1807
|
-
if not response.ok:
|
1808
|
-
if description := response_json.get("detail"):
|
1809
|
-
pass
|
1810
|
-
else:
|
1811
|
-
description = response_json.get(
|
1812
|
-
"message", "response contains no message or detail."
|
1813
|
-
)
|
1814
|
-
logger.error(f"{response.reason}: {description}")
|
1815
|
-
|
1816
|
-
logger.debug(
|
1817
|
-
f"\nSend: {_json.dumps(request_values.json_data, indent=4, sort_keys=True)}"
|
1818
|
-
f"\nGot: {_json.dumps(response_json, indent=4, sort_keys=True)}"
|
1819
|
-
)
|
1820
|
-
raise AssertionError(
|
1821
|
-
f"Response status_code {response.status_code} was not {status_code}"
|
1822
|
-
)
|
1823
473
|
|
1824
|
-
|
474
|
+
@keyword
|
475
|
+
def validate_response_using_validator(self, response: Response) -> None:
|
476
|
+
"""
|
477
|
+
Validate the `response` against the OpenAPI Spec that is
|
478
|
+
loaded during library initialization.
|
479
|
+
"""
|
480
|
+
_validation.validate_response_using_validator(
|
481
|
+
response=response,
|
482
|
+
response_validator=self.response_validator,
|
483
|
+
)
|
1825
484
|
|
1826
|
-
|
1827
|
-
|
1828
|
-
|
1829
|
-
|
1830
|
-
|
1831
|
-
|
1832
|
-
|
1833
|
-
|
1834
|
-
|
1835
|
-
|
1836
|
-
|
1837
|
-
|
1838
|
-
|
1839
|
-
|
1840
|
-
logger.warning(
|
1841
|
-
f"Unexpected response after deleting resource: Status_code "
|
1842
|
-
f"{get_response.status_code} was received after trying to get {request_values.url} "
|
1843
|
-
f"after sucessfully deleting it."
|
1844
|
-
)
|
1845
|
-
elif not get_response.ok:
|
1846
|
-
raise AssertionError(
|
1847
|
-
f"Resource could not be retrieved after failed deletion. "
|
1848
|
-
f"Url was {request_values.url}, status_code was {get_response.status_code}"
|
1849
|
-
)
|
485
|
+
@keyword
|
486
|
+
def assert_href_to_resource_is_valid(
|
487
|
+
self, href: str, referenced_resource: dict[str, JSON]
|
488
|
+
) -> None:
|
489
|
+
"""
|
490
|
+
Attempt to GET the resource referenced by the `href` and validate it's equal
|
491
|
+
to the provided `referenced_resource` object / dictionary.
|
492
|
+
"""
|
493
|
+
_validation.assert_href_to_resource_is_valid(
|
494
|
+
href=href,
|
495
|
+
origin=self.origin,
|
496
|
+
base_url=self.base_url,
|
497
|
+
referenced_resource=referenced_resource,
|
498
|
+
)
|
1850
499
|
|
1851
500
|
@keyword
|
1852
501
|
def validate_response(
|
1853
502
|
self,
|
1854
503
|
path: str,
|
1855
504
|
response: Response,
|
1856
|
-
original_data:
|
505
|
+
original_data: Mapping[str, JSON] = default_json_mapping,
|
1857
506
|
) -> None:
|
1858
507
|
"""
|
1859
508
|
Validate the `response` by performing the following validations:
|
1860
|
-
- validate the `response` against the openapi schema for the `
|
509
|
+
- validate the `response` against the openapi schema for the `path`
|
1861
510
|
- validate that the response does not contain extra properties
|
1862
511
|
- validate that a href, if present, refers to the correct resource
|
1863
512
|
- validate that the value for a property that is in the response is equal to
|
@@ -1865,380 +514,163 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
1865
514
|
- validate that no `original_data` is preserved when performing a PUT operation
|
1866
515
|
- validate that a PATCH operation only updates the provided properties
|
1867
516
|
"""
|
1868
|
-
|
1869
|
-
assert not response.content
|
1870
|
-
return None
|
1871
|
-
|
1872
|
-
try:
|
1873
|
-
self._validate_response_against_spec(response)
|
1874
|
-
except OpenAPIError as exception:
|
1875
|
-
raise Failure(f"Response did not pass schema validation: {exception}")
|
1876
|
-
|
1877
|
-
request_method = response.request.method
|
1878
|
-
if request_method is None:
|
1879
|
-
logger.warning(
|
1880
|
-
f"Could not validate response for path {path}; no method found "
|
1881
|
-
f"on the request property of the provided response."
|
1882
|
-
)
|
1883
|
-
return None
|
1884
|
-
|
1885
|
-
response_spec = self._get_response_spec(
|
517
|
+
_validation.validate_response(
|
1886
518
|
path=path,
|
1887
|
-
|
1888
|
-
|
519
|
+
response=response,
|
520
|
+
response_validator=self.response_validator,
|
521
|
+
server_validation_warning_logged=self._server_validation_warning_logged,
|
522
|
+
disable_server_validation=self.disable_server_validation,
|
523
|
+
invalid_property_default_response=self.invalid_property_default_response,
|
524
|
+
response_validation=self.response_validation,
|
525
|
+
openapi_spec=self.openapi_spec,
|
526
|
+
original_data=original_data,
|
1889
527
|
)
|
1890
528
|
|
1891
|
-
|
1892
|
-
|
529
|
+
@staticmethod
|
530
|
+
@keyword
|
531
|
+
def validate_send_response(
|
532
|
+
response: Response,
|
533
|
+
original_data: Mapping[str, JSON] = default_json_mapping,
|
534
|
+
) -> None:
|
535
|
+
"""
|
536
|
+
Validate that each property that was send that is in the response has the value
|
537
|
+
that was send.
|
538
|
+
In case a PATCH request, validate that only the properties that were patched
|
539
|
+
have changed and that other properties are still at their pre-patch values.
|
540
|
+
"""
|
541
|
+
_validation.validate_send_response(
|
542
|
+
response=response, original_data=original_data
|
543
|
+
)
|
1893
544
|
|
1894
|
-
|
1895
|
-
logger.warning(
|
1896
|
-
"The response cannot be validated: 'content' not specified in the OAS."
|
1897
|
-
)
|
1898
|
-
return None
|
1899
|
-
|
1900
|
-
# multiple content types can be specified in the OAS
|
1901
|
-
content_types = list(response_spec["content"].keys())
|
1902
|
-
supported_types = [
|
1903
|
-
ct for ct in content_types if ct.partition(";")[0].endswith("json")
|
1904
|
-
]
|
1905
|
-
if not supported_types:
|
1906
|
-
raise NotImplementedError(
|
1907
|
-
f"The content_types '{content_types}' are not supported. "
|
1908
|
-
f"Only json types are currently supported."
|
1909
|
-
)
|
1910
|
-
content_type = supported_types[0]
|
1911
|
-
mime_type = content_type.partition(";")[0]
|
545
|
+
# endregion
|
1912
546
|
|
1913
|
-
|
1914
|
-
|
1915
|
-
|
1916
|
-
f"does not match '{mime_type}' as specified in the OpenAPI document."
|
1917
|
-
)
|
547
|
+
@property
|
548
|
+
def origin(self) -> str:
|
549
|
+
return self._origin
|
1918
550
|
|
1919
|
-
|
1920
|
-
|
1921
|
-
|
1922
|
-
|
551
|
+
@property
|
552
|
+
def base_url(self) -> str:
|
553
|
+
return f"{self.origin}{self._base_path}"
|
554
|
+
|
555
|
+
@cached_property
|
556
|
+
def validation_spec(self) -> Spec:
|
557
|
+
_, validation_spec, _ = self._load_specs_and_validator()
|
558
|
+
return validation_spec
|
559
|
+
|
560
|
+
@property
|
561
|
+
def openapi_spec(self) -> OpenApiObject:
|
562
|
+
"""Return a deepcopy of the parsed openapi document."""
|
563
|
+
# protect the parsed openapi spec from being mutated by reference
|
564
|
+
return deepcopy(self._openapi_spec)
|
565
|
+
|
566
|
+
@cached_property
|
567
|
+
def _openapi_spec(self) -> OpenApiObject:
|
568
|
+
parser, _, _ = self._load_specs_and_validator()
|
569
|
+
spec_model = OpenApiObject.model_validate(parser.specification)
|
570
|
+
register_path_parameters(spec_model.paths)
|
571
|
+
return spec_model
|
572
|
+
|
573
|
+
@cached_property
|
574
|
+
def response_validator(
|
575
|
+
self,
|
576
|
+
) -> ResponseValidatorType:
|
577
|
+
_, _, response_validator = self._load_specs_and_validator()
|
578
|
+
return response_validator
|
579
|
+
|
580
|
+
def _get_json_types_from_spec(self, spec: dict[str, JSON]) -> set[str]:
|
581
|
+
json_types: set[str] = set(self._get_json_types(spec))
|
582
|
+
return {json_type for json_type in json_types if json_type is not None}
|
583
|
+
|
584
|
+
def _get_json_types(self, item: object) -> Generator[str, None, None]:
|
585
|
+
if isinstance(item, dict):
|
586
|
+
content_dict = item.get("content")
|
587
|
+
if content_dict is None:
|
588
|
+
for value in item.values():
|
589
|
+
yield from self._get_json_types(value)
|
1923
590
|
|
1924
|
-
response_types = response_schema.get("types")
|
1925
|
-
if response_types:
|
1926
|
-
# In case of oneOf / anyOf there can be multiple possible response types
|
1927
|
-
# which makes generic validation too complex
|
1928
|
-
return None
|
1929
|
-
response_type = response_schema.get("type", "undefined")
|
1930
|
-
if response_type not in ["object", "array"]:
|
1931
|
-
self._validate_value_type(value=json_response, expected_type=response_type)
|
1932
|
-
return None
|
1933
|
-
|
1934
|
-
if list_item_schema := response_schema.get("items"):
|
1935
|
-
if not isinstance(json_response, list):
|
1936
|
-
raise AssertionError(
|
1937
|
-
f"Response schema violation: the schema specifies an array as "
|
1938
|
-
f"response type but the response was of type {type(json_response)}."
|
1939
|
-
)
|
1940
|
-
type_of_list_items = list_item_schema.get("type")
|
1941
|
-
if type_of_list_items == "object":
|
1942
|
-
for resource in json_response:
|
1943
|
-
run_keyword(
|
1944
|
-
"validate_resource_properties", resource, list_item_schema
|
1945
|
-
)
|
1946
591
|
else:
|
1947
|
-
for
|
1948
|
-
|
1949
|
-
|
1950
|
-
|
1951
|
-
|
1952
|
-
|
1953
|
-
|
1954
|
-
|
1955
|
-
|
1956
|
-
|
1957
|
-
|
1958
|
-
|
1959
|
-
|
1960
|
-
|
1961
|
-
|
1962
|
-
|
1963
|
-
|
1964
|
-
|
1965
|
-
|
1966
|
-
|
1967
|
-
|
1968
|
-
|
1969
|
-
params = request_data.params
|
1970
|
-
headers = request_data.headers
|
1971
|
-
get_response = run_keyword("authorized_request", url, "GET", params, headers)
|
1972
|
-
assert (
|
1973
|
-
get_response.json() == json_response
|
1974
|
-
), f"{get_response.json()} not equal to original {json_response}"
|
592
|
+
for content_type in content_dict:
|
593
|
+
if "json" in content_type:
|
594
|
+
content_type_without_charset, _, _ = content_type.partition(";")
|
595
|
+
yield content_type_without_charset
|
596
|
+
|
597
|
+
if isinstance(item, list):
|
598
|
+
for list_item in item:
|
599
|
+
yield from self._get_json_types(list_item)
|
600
|
+
|
601
|
+
def _load_specs_and_validator(
|
602
|
+
self,
|
603
|
+
) -> tuple[
|
604
|
+
ResolvingParser,
|
605
|
+
Spec,
|
606
|
+
ResponseValidatorType,
|
607
|
+
]:
|
608
|
+
def recursion_limit_handler(
|
609
|
+
limit: int, # pylint: disable=unused-argument
|
610
|
+
refstring: str, # pylint: disable=unused-argument
|
611
|
+
recursions: JSON, # pylint: disable=unused-argument
|
612
|
+
) -> JSON:
|
613
|
+
return self._recursion_default # pragma: no cover
|
1975
614
|
|
1976
|
-
def _validate_response_against_spec(self, response: Response) -> None:
|
1977
615
|
try:
|
1978
|
-
|
1979
|
-
|
1980
|
-
|
1981
|
-
|
1982
|
-
|
1983
|
-
|
1984
|
-
|
1985
|
-
|
1986
|
-
|
1987
|
-
|
1988
|
-
|
1989
|
-
[
|
1990
|
-
f"{list(error.schema_path)}: {error.message}"
|
1991
|
-
for error in validation_errors
|
1992
|
-
]
|
616
|
+
# Since parsing of the OAS and creating the Spec can take a long time,
|
617
|
+
# they are cached. This is done by storing them in an imported module that
|
618
|
+
# will have a global scope due to how the Python import system works. This
|
619
|
+
# ensures that in a Suite of Suites where multiple Suites use the same
|
620
|
+
# `source`, that OAS is only parsed / loaded once.
|
621
|
+
cached_parser = PARSER_CACHE.get(self._source, None)
|
622
|
+
if cached_parser:
|
623
|
+
return (
|
624
|
+
cached_parser.parser,
|
625
|
+
cached_parser.validation_spec,
|
626
|
+
cached_parser.response_validator,
|
1993
627
|
)
|
1994
|
-
else:
|
1995
|
-
error_message = str(exception)
|
1996
|
-
|
1997
|
-
if isinstance(exception, ServerNotFound):
|
1998
|
-
if not self._server_validation_warning_logged:
|
1999
|
-
logger.warning(
|
2000
|
-
f"ServerNotFound was raised during response validation. "
|
2001
|
-
f"Due to this, no full response validation will be performed."
|
2002
|
-
f"\nThe original error was: {error_message}"
|
2003
|
-
)
|
2004
|
-
self._server_validation_warning_logged = True
|
2005
|
-
if self.disable_server_validation:
|
2006
|
-
return
|
2007
|
-
if response.status_code == self.invalid_property_default_response:
|
2008
|
-
logger.debug(error_message)
|
2009
|
-
return
|
2010
|
-
if self.response_validation == ValidationLevel.STRICT:
|
2011
|
-
logger.error(error_message)
|
2012
|
-
raise exception
|
2013
|
-
if self.response_validation == ValidationLevel.WARN:
|
2014
|
-
logger.warning(error_message)
|
2015
|
-
elif self.response_validation == ValidationLevel.INFO:
|
2016
|
-
logger.info(error_message)
|
2017
628
|
|
2018
|
-
|
2019
|
-
|
2020
|
-
|
2021
|
-
|
2022
|
-
|
2023
|
-
Validate that the `resource` does not contain any properties that are not
|
2024
|
-
defined in the `schema_properties`.
|
2025
|
-
"""
|
2026
|
-
schema_properties = schema.get("properties", {})
|
2027
|
-
property_names_from_schema = set(schema_properties.keys())
|
2028
|
-
property_names_in_resource = set(resource.keys())
|
2029
|
-
|
2030
|
-
if property_names_from_schema != property_names_in_resource:
|
2031
|
-
# The additionalProperties property determines whether properties with
|
2032
|
-
# unspecified names are allowed. This property can be boolean or an object
|
2033
|
-
# (dict) that specifies the type of any additional properties.
|
2034
|
-
additional_properties = schema.get("additionalProperties", True)
|
2035
|
-
if isinstance(additional_properties, bool):
|
2036
|
-
allow_additional_properties = additional_properties
|
2037
|
-
allowed_additional_properties_type = None
|
2038
|
-
else:
|
2039
|
-
allow_additional_properties = True
|
2040
|
-
allowed_additional_properties_type = additional_properties["type"]
|
2041
|
-
|
2042
|
-
extra_property_names = property_names_in_resource.difference(
|
2043
|
-
property_names_from_schema
|
2044
|
-
)
|
2045
|
-
if allow_additional_properties:
|
2046
|
-
# If a type is defined for extra properties, validate them
|
2047
|
-
if allowed_additional_properties_type:
|
2048
|
-
extra_properties = {
|
2049
|
-
key: value
|
2050
|
-
for key, value in resource.items()
|
2051
|
-
if key in extra_property_names
|
2052
|
-
}
|
2053
|
-
self._validate_type_of_extra_properties(
|
2054
|
-
extra_properties=extra_properties,
|
2055
|
-
expected_type=allowed_additional_properties_type,
|
2056
|
-
)
|
2057
|
-
# If allowed, validation should not fail on extra properties
|
2058
|
-
extra_property_names = set()
|
2059
|
-
|
2060
|
-
required_properties = set(schema.get("required", []))
|
2061
|
-
missing_properties = required_properties.difference(
|
2062
|
-
property_names_in_resource
|
629
|
+
parser = ResolvingParser(
|
630
|
+
self._source,
|
631
|
+
backend="openapi-spec-validator",
|
632
|
+
recursion_limit=self._recursion_limit,
|
633
|
+
recursion_limit_handler=recursion_limit_handler,
|
2063
634
|
)
|
2064
635
|
|
2065
|
-
if
|
2066
|
-
|
2067
|
-
|
2068
|
-
if extra_property_names
|
2069
|
-
else ""
|
2070
|
-
)
|
2071
|
-
missing = (
|
2072
|
-
f"\n\tRequired properties missing in response: {missing_properties}"
|
2073
|
-
if missing_properties
|
2074
|
-
else ""
|
2075
|
-
)
|
2076
|
-
raise AssertionError(
|
2077
|
-
f"Response schema violation: the response contains properties that are "
|
2078
|
-
f"not specified in the schema or does not contain properties that are "
|
2079
|
-
f"required according to the schema."
|
2080
|
-
f"\n\tReceived in the response: {property_names_in_resource}"
|
2081
|
-
f"\n\tDefined in the schema: {property_names_from_schema}"
|
2082
|
-
f"{extra}{missing}"
|
636
|
+
if parser.specification is None: # pragma: no cover
|
637
|
+
raise FatalError(
|
638
|
+
"Source was loaded, but no specification was present after parsing."
|
2083
639
|
)
|
2084
640
|
|
2085
|
-
|
2086
|
-
def _validate_value_type(value: Any, expected_type: str) -> None:
|
2087
|
-
type_mapping = {
|
2088
|
-
"string": str,
|
2089
|
-
"number": float,
|
2090
|
-
"integer": int,
|
2091
|
-
"boolean": bool,
|
2092
|
-
"array": list,
|
2093
|
-
"object": dict,
|
2094
|
-
}
|
2095
|
-
python_type = type_mapping.get(expected_type, None)
|
2096
|
-
if python_type is None:
|
2097
|
-
raise AssertionError(
|
2098
|
-
f"Validation of type '{expected_type}' is not supported."
|
2099
|
-
)
|
2100
|
-
if not isinstance(value, python_type):
|
2101
|
-
raise AssertionError(f"{value} is not of type {expected_type}")
|
641
|
+
validation_spec = Spec.from_dict(parser.specification) # pyright: ignore[reportArgumentType]
|
2102
642
|
|
2103
|
-
|
2104
|
-
|
2105
|
-
extra_properties: Dict[str, Any], expected_type: str
|
2106
|
-
) -> None:
|
2107
|
-
type_mapping = {
|
2108
|
-
"string": str,
|
2109
|
-
"number": float,
|
2110
|
-
"integer": int,
|
2111
|
-
"boolean": bool,
|
2112
|
-
"array": list,
|
2113
|
-
"object": dict,
|
2114
|
-
}
|
2115
|
-
|
2116
|
-
python_type = type_mapping.get(expected_type, None)
|
2117
|
-
if python_type is None:
|
2118
|
-
logger.warning(
|
2119
|
-
f"Additonal properties were not validated: "
|
2120
|
-
f"type '{expected_type}' is not supported."
|
643
|
+
json_types_from_spec: set[str] = self._get_json_types_from_spec(
|
644
|
+
parser.specification
|
2121
645
|
)
|
2122
|
-
|
2123
|
-
|
2124
|
-
|
2125
|
-
|
2126
|
-
|
2127
|
-
|
2128
|
-
|
2129
|
-
|
2130
|
-
|
2131
|
-
|
2132
|
-
|
646
|
+
extra_deserializers = {
|
647
|
+
json_type: _json.loads for json_type in json_types_from_spec
|
648
|
+
}
|
649
|
+
config = Config(extra_media_type_deserializers=extra_deserializers) # type: ignore[arg-type]
|
650
|
+
openapi = OpenAPI(spec=validation_spec, config=config)
|
651
|
+
response_validator: ResponseValidatorType = openapi.validate_response # type: ignore[assignment]
|
652
|
+
|
653
|
+
PARSER_CACHE[self._source] = CachedParser(
|
654
|
+
parser=parser,
|
655
|
+
validation_spec=validation_spec,
|
656
|
+
response_validator=response_validator,
|
2133
657
|
)
|
2134
658
|
|
2135
|
-
|
2136
|
-
@keyword
|
2137
|
-
def validate_send_response(
|
2138
|
-
response: Response, original_data: Optional[Dict[str, Any]] = None
|
2139
|
-
) -> None:
|
2140
|
-
"""
|
2141
|
-
Validate that each property that was send that is in the response has the value
|
2142
|
-
that was send.
|
2143
|
-
In case a PATCH request, validate that only the properties that were patched
|
2144
|
-
have changed and that other properties are still at their pre-patch values.
|
2145
|
-
"""
|
659
|
+
return parser, validation_spec, response_validator
|
2146
660
|
|
2147
|
-
|
2148
|
-
|
2149
|
-
|
2150
|
-
|
2151
|
-
|
2152
|
-
|
2153
|
-
|
2154
|
-
|
2155
|
-
|
2156
|
-
|
2157
|
-
|
2158
|
-
|
2159
|
-
|
2160
|
-
|
2161
|
-
|
2162
|
-
|
2163
|
-
# sometimes, a property in the request is not in the response, e.g. a password
|
2164
|
-
if send_property_name not in received_dict.keys():
|
2165
|
-
continue
|
2166
|
-
if send_property_value is not None:
|
2167
|
-
# if a None value is send, the target property should be cleared or
|
2168
|
-
# reverted to the default value (which cannot be specified in the
|
2169
|
-
# openapi document)
|
2170
|
-
received_value = received_dict[send_property_name]
|
2171
|
-
# In case of lists / arrays, the send values are often appended to
|
2172
|
-
# existing data
|
2173
|
-
if isinstance(received_value, list):
|
2174
|
-
validate_list_response(
|
2175
|
-
send_list=send_property_value, received_list=received_value
|
2176
|
-
)
|
2177
|
-
continue
|
2178
|
-
|
2179
|
-
# when dealing with objects, we'll need to iterate the properties
|
2180
|
-
if isinstance(received_value, dict):
|
2181
|
-
validate_dict_response(
|
2182
|
-
send_dict=send_property_value, received_dict=received_value
|
2183
|
-
)
|
2184
|
-
continue
|
2185
|
-
|
2186
|
-
assert received_value == send_property_value, (
|
2187
|
-
f"Received value for {send_property_name} '{received_value}' does not "
|
2188
|
-
f"match '{send_property_value}' in the {response.request.method} request."
|
2189
|
-
f"\nSend: {_json.dumps(send_json, indent=4, sort_keys=True)}"
|
2190
|
-
f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
|
2191
|
-
)
|
2192
|
-
|
2193
|
-
if response.request.body is None:
|
2194
|
-
logger.warning(
|
2195
|
-
"Could not validate send response; the body of the request property "
|
2196
|
-
"on the provided response was None."
|
2197
|
-
)
|
2198
|
-
return None
|
2199
|
-
if isinstance(response.request.body, bytes):
|
2200
|
-
send_json = _json.loads(response.request.body.decode("UTF-8"))
|
2201
|
-
else:
|
2202
|
-
send_json = _json.loads(response.request.body)
|
2203
|
-
|
2204
|
-
response_data = response.json()
|
2205
|
-
# POST on /resource_type/{id}/array_item/ will return the updated {id} resource
|
2206
|
-
# instead of a newly created resource. In this case, the send_json must be
|
2207
|
-
# in the array of the 'array_item' property on {id}
|
2208
|
-
send_path: str = response.request.path_url
|
2209
|
-
response_path = response_data.get("href", None)
|
2210
|
-
if response_path and send_path not in response_path:
|
2211
|
-
property_to_check = send_path.replace(response_path, "")[1:]
|
2212
|
-
if response_data.get(property_to_check) and isinstance(
|
2213
|
-
response_data[property_to_check], list
|
2214
|
-
):
|
2215
|
-
item_list: List[Dict[str, Any]] = response_data[property_to_check]
|
2216
|
-
# Use the (mandatory) id to get the POSTed resource from the list
|
2217
|
-
[response_data] = [
|
2218
|
-
item for item in item_list if item["id"] == send_json["id"]
|
2219
|
-
]
|
2220
|
-
|
2221
|
-
# incoming arguments are dictionaries, so they can be validated as such
|
2222
|
-
validate_dict_response(send_dict=send_json, received_dict=response_data)
|
2223
|
-
|
2224
|
-
# In case of PATCH requests, ensure that only send properties have changed
|
2225
|
-
if original_data:
|
2226
|
-
for send_property_name, send_value in original_data.items():
|
2227
|
-
if send_property_name not in send_json.keys():
|
2228
|
-
assert send_value == response_data[send_property_name], (
|
2229
|
-
f"Received value for {send_property_name} '{response_data[send_property_name]}' does not "
|
2230
|
-
f"match '{send_value}' in the pre-patch data"
|
2231
|
-
f"\nPre-patch: {_json.dumps(original_data, indent=4, sort_keys=True)}"
|
2232
|
-
f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
|
2233
|
-
)
|
2234
|
-
return None
|
2235
|
-
|
2236
|
-
def _get_response_spec(
|
2237
|
-
self, path: str, method: str, status_code: int
|
2238
|
-
) -> Dict[str, Any]:
|
2239
|
-
method = method.lower()
|
2240
|
-
status = str(status_code)
|
2241
|
-
spec: Dict[str, Any] = {**self.openapi_spec}["paths"][path][method][
|
2242
|
-
"responses"
|
2243
|
-
][status]
|
2244
|
-
return spec
|
661
|
+
except ResolutionError as exception: # pragma: no cover
|
662
|
+
raise FatalError(
|
663
|
+
f"ResolutionError while trying to load openapi spec: {exception}"
|
664
|
+
) from exception
|
665
|
+
except ValidationError as exception: # pragma: no cover
|
666
|
+
raise FatalError(
|
667
|
+
f"ValidationError while trying to load openapi spec: {exception}"
|
668
|
+
) from exception
|
669
|
+
|
670
|
+
def read_paths(self) -> dict[str, PathItemObject]:
|
671
|
+
return self.openapi_spec.paths
|
672
|
+
|
673
|
+
__init__.__doc__ = OPENAPILIBCORE_INIT_DOCSTRING
|
674
|
+
|
675
|
+
|
676
|
+
OpenApiLibCore.__doc__ = OPENAPILIBCORE_LIBRARY_DOCSTRING
|