robotframework-openapitools 0.4.0__py3-none-any.whl → 1.0.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- OpenApiDriver/__init__.py +44 -41
- OpenApiDriver/openapi_executors.py +40 -39
- OpenApiDriver/openapi_reader.py +115 -116
- OpenApiDriver/openapidriver.libspec +71 -61
- OpenApiDriver/openapidriver.py +25 -19
- OpenApiLibCore/__init__.py +13 -11
- OpenApiLibCore/annotations.py +3 -0
- OpenApiLibCore/data_generation/__init__.py +12 -0
- OpenApiLibCore/data_generation/body_data_generation.py +269 -0
- OpenApiLibCore/data_generation/data_generation_core.py +240 -0
- OpenApiLibCore/data_invalidation.py +281 -0
- OpenApiLibCore/dto_base.py +29 -35
- OpenApiLibCore/dto_utils.py +97 -85
- OpenApiLibCore/oas_cache.py +14 -13
- OpenApiLibCore/openapi_libcore.libspec +350 -193
- OpenApiLibCore/openapi_libcore.py +392 -1698
- OpenApiLibCore/parameter_utils.py +89 -0
- OpenApiLibCore/path_functions.py +215 -0
- OpenApiLibCore/path_invalidation.py +44 -0
- OpenApiLibCore/protocols.py +30 -0
- OpenApiLibCore/request_data.py +275 -0
- OpenApiLibCore/resource_relations.py +54 -0
- OpenApiLibCore/validation.py +497 -0
- OpenApiLibCore/value_utils.py +528 -481
- openapi_libgen/__init__.py +46 -0
- openapi_libgen/command_line.py +87 -0
- openapi_libgen/parsing_utils.py +26 -0
- openapi_libgen/spec_parser.py +212 -0
- openapi_libgen/templates/__init__.jinja +3 -0
- openapi_libgen/templates/library.jinja +30 -0
- robotframework_openapitools-1.0.0b1.dist-info/METADATA +237 -0
- robotframework_openapitools-1.0.0b1.dist-info/RECORD +37 -0
- {robotframework_openapitools-0.4.0.dist-info → robotframework_openapitools-1.0.0b1.dist-info}/WHEEL +1 -1
- robotframework_openapitools-1.0.0b1.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.0b1.dist-info}/LICENSE +0 -0
@@ -75,7 +75,7 @@ recursion in them. See the `recursion_limit` and `recursion_default` parameters.
|
|
75
75
|
|
76
76
|
If the openapi document passes this validation, the next step is trying to do a test
|
77
77
|
run with a minimal test suite.
|
78
|
-
The example below can be used, with `source`, `origin` and
|
78
|
+
The example below can be used, with `source`, `origin` and `path` altered to
|
79
79
|
fit your situation.
|
80
80
|
|
81
81
|
``` robotframework
|
@@ -86,7 +86,7 @@ Library OpenApiLibCore
|
|
86
86
|
|
87
87
|
*** Test Cases ***
|
88
88
|
Getting Started
|
89
|
-
${url}= Get Valid Url
|
89
|
+
${url}= Get Valid Url path=/employees/{employee_id}
|
90
90
|
|
91
91
|
```
|
92
92
|
|
@@ -119,342 +119,59 @@ data types and properties. The following list details the most important ones:
|
|
119
119
|
"""
|
120
120
|
|
121
121
|
import json as _json
|
122
|
-
import re
|
123
122
|
import sys
|
123
|
+
from collections.abc import Mapping, MutableMapping
|
124
124
|
from copy import deepcopy
|
125
|
-
from dataclasses import Field, dataclass, field, make_dataclass
|
126
|
-
from enum import Enum
|
127
125
|
from functools import cached_property
|
128
|
-
from itertools import zip_longest
|
129
|
-
from logging import getLogger
|
130
126
|
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
|
127
|
+
from types import MappingProxyType
|
128
|
+
from typing import Any, Generator
|
145
129
|
|
146
130
|
from openapi_core import Config, OpenAPI, Spec
|
147
131
|
from openapi_core.contrib.requests import (
|
148
132
|
RequestsOpenAPIRequest,
|
149
133
|
RequestsOpenAPIResponse,
|
150
134
|
)
|
151
|
-
from openapi_core.exceptions import OpenAPIError
|
152
|
-
from openapi_core.templating.paths.exceptions import ServerNotFound
|
153
135
|
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
136
|
from prance import ResolvingParser
|
157
137
|
from prance.util.url import ResolutionError
|
158
138
|
from requests import Response, Session
|
159
139
|
from requests.auth import AuthBase, HTTPBasicAuth
|
160
140
|
from requests.cookies import RequestsCookieJar as CookieJar
|
141
|
+
from robot.api import logger
|
161
142
|
from robot.api.deco import keyword, library
|
162
|
-
from robot.api.exceptions import
|
143
|
+
from robot.api.exceptions import FatalError
|
163
144
|
from robot.libraries.BuiltIn import BuiltIn
|
164
145
|
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
Relation,
|
174
|
-
UniquePropertyValueConstraint,
|
175
|
-
resolve_schema,
|
176
|
-
)
|
146
|
+
import OpenApiLibCore.data_generation as _data_generation
|
147
|
+
import OpenApiLibCore.data_invalidation as di
|
148
|
+
import OpenApiLibCore.path_functions as pf
|
149
|
+
import OpenApiLibCore.path_invalidation as pi
|
150
|
+
import OpenApiLibCore.resource_relations as rr
|
151
|
+
import OpenApiLibCore.validation as val
|
152
|
+
from OpenApiLibCore.annotations import JSON
|
153
|
+
from OpenApiLibCore.dto_base import Dto, IdReference
|
177
154
|
from OpenApiLibCore.dto_utils import (
|
178
155
|
DEFAULT_ID_PROPERTY_NAME,
|
179
|
-
DefaultDto,
|
180
156
|
get_dto_class,
|
181
157
|
get_id_property_name,
|
182
158
|
)
|
183
|
-
from OpenApiLibCore.oas_cache import PARSER_CACHE
|
184
|
-
from OpenApiLibCore.
|
159
|
+
from OpenApiLibCore.oas_cache import PARSER_CACHE, CachedParser
|
160
|
+
from OpenApiLibCore.parameter_utils import (
|
161
|
+
get_oas_name_from_safe_name,
|
162
|
+
register_path_parameters,
|
163
|
+
)
|
164
|
+
from OpenApiLibCore.protocols import ResponseValidatorType
|
165
|
+
from OpenApiLibCore.request_data import RequestData, RequestValues
|
166
|
+
from OpenApiLibCore.value_utils import FAKE
|
185
167
|
|
186
168
|
run_keyword = BuiltIn().run_keyword
|
187
|
-
|
188
|
-
|
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
|
-
|
263
|
-
return any(map(is_optional_param, self.params))
|
264
|
-
|
265
|
-
@cached_property
|
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}
|
169
|
+
default_str_mapping: Mapping[str, str] = MappingProxyType({})
|
170
|
+
default_any_mapping: Mapping[str, object] = MappingProxyType({})
|
454
171
|
|
455
172
|
|
456
173
|
@library(scope="SUITE", doc_format="ROBOT")
|
457
|
-
class OpenApiLibCore: # pylint: disable=too-many-
|
174
|
+
class OpenApiLibCore: # pylint: disable=too-many-public-methods
|
458
175
|
"""
|
459
176
|
Main class providing the keywords and core logic to interact with an OpenAPI server.
|
460
177
|
|
@@ -462,29 +179,29 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
462
179
|
for an introduction.
|
463
180
|
"""
|
464
181
|
|
465
|
-
def __init__( # pylint: disable=
|
182
|
+
def __init__( # noqa: PLR0913, pylint: disable=dangerous-default-value
|
466
183
|
self,
|
467
184
|
source: str,
|
468
185
|
origin: str = "",
|
469
186
|
base_path: str = "",
|
470
|
-
response_validation: ValidationLevel = ValidationLevel.WARN,
|
187
|
+
response_validation: val.ValidationLevel = val.ValidationLevel.WARN,
|
471
188
|
disable_server_validation: bool = True,
|
472
|
-
mappings_path:
|
189
|
+
mappings_path: str | Path = "",
|
473
190
|
invalid_property_default_response: int = 422,
|
474
191
|
default_id_property_name: str = "id",
|
475
|
-
faker_locale:
|
192
|
+
faker_locale: str | list[str] = "",
|
476
193
|
require_body_for_invalid_url: bool = False,
|
477
194
|
recursion_limit: int = 1,
|
478
|
-
recursion_default:
|
195
|
+
recursion_default: JSON = {},
|
479
196
|
username: str = "",
|
480
197
|
password: str = "",
|
481
198
|
security_token: str = "",
|
482
|
-
auth:
|
483
|
-
cert:
|
484
|
-
verify_tls:
|
485
|
-
extra_headers:
|
486
|
-
cookies:
|
487
|
-
proxies:
|
199
|
+
auth: AuthBase | None = None,
|
200
|
+
cert: str | tuple[str, str] = "",
|
201
|
+
verify_tls: bool | str = True,
|
202
|
+
extra_headers: Mapping[str, str] = default_str_mapping,
|
203
|
+
cookies: MutableMapping[str, str] | CookieJar | None = None,
|
204
|
+
proxies: MutableMapping[str, str] | None = None,
|
488
205
|
) -> None:
|
489
206
|
"""
|
490
207
|
== Base parameters ==
|
@@ -496,7 +213,7 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
496
213
|
The server (and port) of the target server. E.g. ``https://localhost:8000``
|
497
214
|
|
498
215
|
=== base_path ===
|
499
|
-
The routing between ``origin`` and the
|
216
|
+
The routing between ``origin`` and the paths as found in the ``paths``
|
500
217
|
section in the openapi document.
|
501
218
|
E.g. ``/petshop/v2``.
|
502
219
|
|
@@ -612,18 +329,18 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
612
329
|
self.response_validation = response_validation
|
613
330
|
self.disable_server_validation = disable_server_validation
|
614
331
|
self._recursion_limit = recursion_limit
|
615
|
-
self._recursion_default = recursion_default
|
332
|
+
self._recursion_default = deepcopy(recursion_default)
|
616
333
|
self.session = Session()
|
617
|
-
#
|
334
|
+
# Only username and password, security_token or auth object should be provided
|
618
335
|
# if multiple are provided, username and password take precedence
|
619
336
|
self.security_token = security_token
|
620
337
|
self.auth = auth
|
621
338
|
if username:
|
622
339
|
self.auth = HTTPBasicAuth(username, password)
|
623
|
-
#
|
624
|
-
#
|
625
|
-
if isinstance(cert,
|
626
|
-
cert =
|
340
|
+
# Requests only allows a string or a tuple[str, str], so ensure cert is a tuple
|
341
|
+
# if the passed argument is not a string.
|
342
|
+
if not isinstance(cert, str):
|
343
|
+
cert = (cert[0], cert[1])
|
627
344
|
self.cert = cert
|
628
345
|
self.verify = verify_tls
|
629
346
|
self.extra_headers = extra_headers
|
@@ -633,11 +350,9 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
633
350
|
if mappings_path and str(mappings_path) != ".":
|
634
351
|
mappings_path = Path(mappings_path)
|
635
352
|
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
|
353
|
+
logger.warn(f"mappings_path '{mappings_path}' is not a Python module.")
|
354
|
+
# Intermediate variable to ensure path.append is possible so we'll never
|
355
|
+
# path.pop a location that we didn't append.
|
641
356
|
mappings_folder = str(mappings_path.parent)
|
642
357
|
sys.path.append(mappings_folder)
|
643
358
|
mappings_module_name = mappings_path.stem
|
@@ -660,10 +375,7 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
660
375
|
DEFAULT_ID_PROPERTY_NAME.id_property_name = default_id_property_name
|
661
376
|
self._server_validation_warning_logged = False
|
662
377
|
|
663
|
-
|
664
|
-
def origin(self) -> str:
|
665
|
-
return self._origin
|
666
|
-
|
378
|
+
# region: library configuration keywords
|
667
379
|
@keyword
|
668
380
|
def set_origin(self, origin: str) -> None:
|
669
381
|
"""
|
@@ -710,7 +422,7 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
710
422
|
self.auth = auth
|
711
423
|
|
712
424
|
@keyword
|
713
|
-
def set_extra_headers(self, extra_headers:
|
425
|
+
def set_extra_headers(self, extra_headers: dict[str, str]) -> None:
|
714
426
|
"""
|
715
427
|
Set the `extra_headers` used in requests after the library is imported.
|
716
428
|
|
@@ -719,691 +431,76 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
719
431
|
"""
|
720
432
|
self.extra_headers = extra_headers
|
721
433
|
|
722
|
-
|
723
|
-
|
724
|
-
return f"{self.origin}{self._base_path}"
|
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(
|
744
|
-
self,
|
745
|
-
) -> Callable[[RequestsOpenAPIRequest, RequestsOpenAPIResponse], None]:
|
746
|
-
_, _, response_validator = self._load_specs_and_validator()
|
747
|
-
return response_validator
|
748
|
-
|
749
|
-
def _get_json_types_from_spec(self, spec: Dict[str, Any]) -> Set[str]:
|
750
|
-
json_types: Set[str] = set(self._get_json_types(spec))
|
751
|
-
return {json_type for json_type in json_types if json_type is not None}
|
752
|
-
|
753
|
-
def _get_json_types(self, item: Any) -> Generator[str, None, None]:
|
754
|
-
if isinstance(item, dict):
|
755
|
-
content_dict = item.get("content")
|
756
|
-
if content_dict is None:
|
757
|
-
for value in item.values():
|
758
|
-
yield from self._get_json_types(value)
|
759
|
-
|
760
|
-
else:
|
761
|
-
for content_type in content_dict:
|
762
|
-
if "json" in content_type:
|
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:
|
778
|
-
|
779
|
-
def recursion_limit_handler(
|
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
|
-
)
|
792
|
-
|
793
|
-
if parser is None:
|
794
|
-
parser = ResolvingParser(
|
795
|
-
self._source,
|
796
|
-
backend="openapi-spec-validator",
|
797
|
-
recursion_limit=self._recursion_limit,
|
798
|
-
recursion_limit_handler=recursion_limit_handler,
|
799
|
-
)
|
800
|
-
|
801
|
-
if parser.specification is None: # pragma: no cover
|
802
|
-
BuiltIn().fatal_error(
|
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
|
-
)
|
823
|
-
|
824
|
-
return parser, validation_spec, response_validator
|
825
|
-
|
826
|
-
except ResolutionError as exception:
|
827
|
-
BuiltIn().fatal_error(
|
828
|
-
f"ResolutionError while trying to load openapi spec: {exception}"
|
829
|
-
)
|
830
|
-
except ValidationError as exception:
|
831
|
-
BuiltIn().fatal_error(
|
832
|
-
f"ValidationError while trying to load openapi spec: {exception}"
|
833
|
-
)
|
834
|
-
|
835
|
-
def validate_response_vs_spec(
|
836
|
-
self, request: RequestsOpenAPIRequest, response: RequestsOpenAPIResponse
|
837
|
-
) -> None:
|
838
|
-
"""
|
839
|
-
Validate the reponse for a given request against the OpenAPI Spec that is
|
840
|
-
loaded during library initialization.
|
841
|
-
"""
|
842
|
-
self.response_validator(request=request, response=response)
|
843
|
-
|
844
|
-
def read_paths(self) -> Dict[str, Any]:
|
845
|
-
return self.openapi_spec["paths"]
|
846
|
-
|
847
|
-
@keyword
|
848
|
-
def get_valid_url(self, endpoint: str, method: str) -> str:
|
849
|
-
"""
|
850
|
-
This keyword returns a valid url for the given `endpoint` and `method`.
|
851
|
-
|
852
|
-
If the `endpoint` contains path parameters the Get Valid Id For Endpoint
|
853
|
-
keyword will be executed to retrieve valid ids for the path parameters.
|
854
|
-
|
855
|
-
> Note: if valid ids cannot be retrieved within the scope of the API, the
|
856
|
-
`PathPropertiesConstraint` Relation can be used. More information can be found
|
857
|
-
[https://marketsquare.github.io/robotframework-openapi-libcore/advanced_use.html | here].
|
858
|
-
"""
|
859
|
-
method = method.lower()
|
860
|
-
try:
|
861
|
-
# endpoint can be partially resolved or provided by a PathPropertiesConstraint
|
862
|
-
parametrized_endpoint = self.get_parametrized_endpoint(endpoint=endpoint)
|
863
|
-
_ = self.openapi_spec["paths"][parametrized_endpoint]
|
864
|
-
except KeyError:
|
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
|
886
|
-
|
887
|
-
@keyword
|
888
|
-
def get_valid_id_for_endpoint(
|
889
|
-
self, endpoint: str, method: str
|
890
|
-
) -> Union[str, int, float]:
|
891
|
-
"""
|
892
|
-
Support keyword that returns the `id` for an existing resource at `endpoint`.
|
893
|
-
|
894
|
-
To prevent resource conflicts with other test cases, a new resource is created
|
895
|
-
(POST) if possible.
|
896
|
-
"""
|
897
|
-
|
898
|
-
def dummy_transformer(
|
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(),
|
916
|
-
)
|
917
|
-
|
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
|
-
|
434
|
+
# endregion
|
435
|
+
# region: data generation keywords
|
983
436
|
@keyword
|
984
|
-
def
|
985
|
-
|
986
|
-
|
987
|
-
|
988
|
-
|
989
|
-
|
990
|
-
|
991
|
-
|
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
|
437
|
+
def get_request_values(
|
438
|
+
self,
|
439
|
+
path: str,
|
440
|
+
method: str,
|
441
|
+
overrides: Mapping[str, object] = default_any_mapping,
|
442
|
+
) -> RequestValues:
|
443
|
+
"""Return an object with all (valid) request values needed to make a request."""
|
444
|
+
json_data: dict[str, JSON] = {}
|
1022
445
|
|
1023
|
-
|
1024
|
-
|
1025
|
-
|
1026
|
-
|
1027
|
-
|
1028
|
-
|
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 = {}
|
446
|
+
url: str = run_keyword("get_valid_url", path)
|
447
|
+
request_data: RequestData = run_keyword("get_request_data", path, method)
|
448
|
+
params = request_data.params
|
449
|
+
headers = request_data.headers
|
450
|
+
if request_data.has_body:
|
451
|
+
json_data = request_data.dto.as_dict()
|
1039
452
|
|
1040
|
-
|
1041
|
-
|
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,
|
453
|
+
request_values = RequestValues(
|
454
|
+
url=url,
|
455
|
+
method=method,
|
1082
456
|
params=params,
|
1083
457
|
headers=headers,
|
458
|
+
json_data=json_data,
|
1084
459
|
)
|
1085
460
|
|
1086
|
-
|
1087
|
-
|
1088
|
-
|
1089
|
-
|
1090
|
-
|
1091
|
-
|
1092
|
-
|
1093
|
-
|
1094
|
-
|
1095
|
-
|
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)))
|
461
|
+
for name, value in overrides.items():
|
462
|
+
if name.startswith(("body_", "header_", "query_")):
|
463
|
+
location, _, name_ = name.partition("_")
|
464
|
+
oas_name = get_oas_name_from_safe_name(name_)
|
465
|
+
if location == "body":
|
466
|
+
request_values.override_body_value(name=oas_name, value=value)
|
467
|
+
if location == "header":
|
468
|
+
request_values.override_header_value(name=oas_name, value=value)
|
469
|
+
if location == "query":
|
470
|
+
request_values.override_param_value(name=oas_name, value=str(value))
|
1113
471
|
else:
|
1114
|
-
|
1115
|
-
|
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)
|
472
|
+
oas_name = get_oas_name_from_safe_name(name)
|
473
|
+
request_values.override_request_value(name=oas_name, value=value)
|
1135
474
|
|
1136
|
-
|
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
|
475
|
+
return request_values
|
1139
476
|
|
1140
|
-
|
1141
|
-
|
1142
|
-
"""
|
1143
|
-
|
1144
|
-
|
1145
|
-
|
1146
|
-
|
1147
|
-
|
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}'."
|
477
|
+
@keyword
|
478
|
+
def get_request_data(self, path: str, method: str) -> RequestData:
|
479
|
+
"""Return an object with valid request data for body, headers and query params."""
|
480
|
+
return _data_generation.get_request_data(
|
481
|
+
path=path,
|
482
|
+
method=method,
|
483
|
+
get_dto_class=self.get_dto_class,
|
484
|
+
get_id_property_name=self.get_id_property_name,
|
485
|
+
openapi_spec=self.openapi_spec,
|
1153
486
|
)
|
1154
487
|
|
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
|
1230
|
-
|
1231
488
|
@keyword
|
1232
489
|
def get_json_data_for_dto_class(
|
1233
490
|
self,
|
1234
|
-
schema:
|
1235
|
-
dto_class:
|
491
|
+
schema: dict[str, JSON],
|
492
|
+
dto_class: type[Dto],
|
1236
493
|
operation_id: str = "",
|
1237
|
-
) ->
|
494
|
+
) -> JSON:
|
1238
495
|
"""
|
1239
|
-
Generate
|
496
|
+
Generate valid (json-compatible) data for the `dto_class`.
|
1240
497
|
"""
|
1241
|
-
|
1242
|
-
|
1243
|
-
|
1244
|
-
|
1245
|
-
|
1246
|
-
|
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
|
1354
|
-
|
1355
|
-
@keyword
|
1356
|
-
def get_invalidated_url(
|
1357
|
-
self,
|
1358
|
-
valid_url: str,
|
1359
|
-
path: str = "",
|
1360
|
-
method: str = "",
|
1361
|
-
expected_status_code: int = 404,
|
1362
|
-
) -> Optional[str]:
|
1363
|
-
"""
|
1364
|
-
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 `path`, `method`
|
1366
|
-
and `expected_status_code`.
|
1367
|
-
If a PathPropertiesConstraint is mapped, the `invalid_value` is returned.
|
1368
|
-
|
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
|
498
|
+
return _data_generation.get_json_data_for_dto_class(
|
499
|
+
schema=schema,
|
500
|
+
dto_class=dto_class,
|
501
|
+
get_id_property_name=self.get_id_property_name,
|
502
|
+
operation_id=operation_id,
|
503
|
+
)
|
1407
504
|
|
1408
505
|
@keyword
|
1409
506
|
def get_invalid_json_data(
|
@@ -1412,7 +509,7 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
1412
509
|
method: str,
|
1413
510
|
status_code: int,
|
1414
511
|
request_data: RequestData,
|
1415
|
-
) ->
|
512
|
+
) -> dict[str, JSON]:
|
1416
513
|
"""
|
1417
514
|
Return `json_data` based on the `dto` on the `request_data` that will cause
|
1418
515
|
the provided `status_code` for the `method` operation on the `url`.
|
@@ -1420,321 +517,152 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
1420
517
|
> Note: applicable UniquePropertyValueConstraint and IdReference Relations are
|
1421
518
|
considered before changes to `json_data` are made.
|
1422
519
|
"""
|
1423
|
-
|
1424
|
-
|
1425
|
-
|
1426
|
-
|
1427
|
-
|
1428
|
-
|
1429
|
-
|
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
|
520
|
+
return di.get_invalid_json_data(
|
521
|
+
url=url,
|
522
|
+
method=method,
|
523
|
+
status_code=status_code,
|
524
|
+
request_data=request_data,
|
525
|
+
invalid_property_default_response=self.invalid_property_default_response,
|
526
|
+
)
|
1458
527
|
|
1459
528
|
@keyword
|
1460
529
|
def get_invalidated_parameters(
|
1461
530
|
self,
|
1462
531
|
status_code: int,
|
1463
532
|
request_data: RequestData,
|
1464
|
-
) ->
|
533
|
+
) -> tuple[dict[str, JSON], dict[str, str]]:
|
1465
534
|
"""
|
1466
535
|
Returns a version of `params, headers` as present on `request_data` that has
|
1467
536
|
been modified to cause the provided `status_code`.
|
1468
537
|
"""
|
1469
|
-
|
1470
|
-
|
1471
|
-
|
1472
|
-
|
1473
|
-
|
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)
|
538
|
+
return di.get_invalidated_parameters(
|
539
|
+
status_code=status_code,
|
540
|
+
request_data=request_data,
|
541
|
+
invalid_property_default_response=self.invalid_property_default_response,
|
542
|
+
)
|
1497
543
|
|
1498
|
-
|
1499
|
-
|
1500
|
-
|
1501
|
-
|
1502
|
-
|
1503
|
-
|
1504
|
-
|
1505
|
-
|
1506
|
-
|
1507
|
-
|
1508
|
-
|
1509
|
-
|
1510
|
-
|
1511
|
-
|
1512
|
-
|
1513
|
-
|
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
|
544
|
+
@keyword
|
545
|
+
def get_json_data_with_conflict(
|
546
|
+
self, url: str, method: str, dto: Dto, conflict_status_code: int
|
547
|
+
) -> dict[str, JSON]:
|
548
|
+
"""
|
549
|
+
Return `json_data` based on the `UniquePropertyValueConstraint` that must be
|
550
|
+
returned by the `get_relations` implementation on the `dto` for the given
|
551
|
+
`conflict_status_code`.
|
552
|
+
"""
|
553
|
+
return di.get_json_data_with_conflict(
|
554
|
+
url=url,
|
555
|
+
base_url=self.base_url,
|
556
|
+
method=method,
|
557
|
+
dto=dto,
|
558
|
+
conflict_status_code=conflict_status_code,
|
559
|
+
)
|
1526
560
|
|
1527
|
-
|
1528
|
-
|
1529
|
-
|
1530
|
-
|
561
|
+
# endregion
|
562
|
+
# region: path-related keywords
|
563
|
+
@keyword
|
564
|
+
def get_valid_url(self, path: str) -> str:
|
565
|
+
"""
|
566
|
+
This keyword returns a valid url for the given `path`.
|
1531
567
|
|
1532
|
-
|
1533
|
-
|
568
|
+
If the `path` contains path parameters the Get Valid Id For Path
|
569
|
+
keyword will be executed to retrieve valid ids for the path parameters.
|
1534
570
|
|
1535
|
-
|
1536
|
-
|
1537
|
-
|
1538
|
-
|
1539
|
-
|
1540
|
-
|
1541
|
-
|
1542
|
-
|
1543
|
-
|
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,
|
571
|
+
> Note: if valid ids cannot be retrieved within the scope of the API, the
|
572
|
+
`PathPropertiesConstraint` Relation can be used. More information can be found
|
573
|
+
[https://marketsquare.github.io/robotframework-openapitools/advanced_use.html | here].
|
574
|
+
"""
|
575
|
+
return pf.get_valid_url(
|
576
|
+
path=path,
|
577
|
+
base_url=self.base_url,
|
578
|
+
get_dto_class=self.get_dto_class,
|
579
|
+
openapi_spec=self.openapi_spec,
|
1575
580
|
)
|
1576
581
|
|
1577
|
-
|
1578
|
-
|
1579
|
-
|
1580
|
-
|
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}")
|
582
|
+
@keyword
|
583
|
+
def get_valid_id_for_path(self, path: str) -> str | int | float:
|
584
|
+
"""
|
585
|
+
Support keyword that returns the `id` for an existing resource at `path`.
|
1593
586
|
|
1594
|
-
|
1595
|
-
|
1596
|
-
|
1597
|
-
|
1598
|
-
|
1599
|
-
|
587
|
+
To prevent resource conflicts with other test cases, a new resource is created
|
588
|
+
(by a POST operation) if possible.
|
589
|
+
"""
|
590
|
+
return pf.get_valid_id_for_path(
|
591
|
+
path=path, get_id_property_name=self.get_id_property_name
|
592
|
+
)
|
1600
593
|
|
1601
|
-
@
|
1602
|
-
def
|
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]]:
|
594
|
+
@keyword
|
595
|
+
def get_parameterized_path_from_url(self, url: str) -> str:
|
1609
596
|
"""
|
1610
|
-
|
1611
|
-
value to params or headers if not originally present.
|
597
|
+
Return the path as found in the `paths` section based on the given `url`.
|
1612
598
|
"""
|
1613
|
-
|
1614
|
-
|
1615
|
-
|
1616
|
-
)
|
1617
|
-
|
1618
|
-
|
1619
|
-
|
1620
|
-
|
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
|
599
|
+
path = url.replace(self.base_url, "")
|
600
|
+
path_parts = path.split("/")
|
601
|
+
# first part will be '' since a path starts with /
|
602
|
+
path_parts.pop(0)
|
603
|
+
parameterized_path = pf.get_parametrized_path(
|
604
|
+
path=path, openapi_spec=self.openapi_spec
|
605
|
+
)
|
606
|
+
return parameterized_path
|
1633
607
|
|
1634
608
|
@keyword
|
1635
|
-
def
|
609
|
+
def get_ids_from_url(self, url: str) -> list[str]:
|
1636
610
|
"""
|
1637
|
-
|
1638
|
-
|
611
|
+
Perform a GET request on the `url` and return the list of resource
|
612
|
+
`ids` from the response.
|
1639
613
|
"""
|
1640
|
-
|
1641
|
-
|
1642
|
-
endpoint = url.replace(self.base_url, "")
|
1643
|
-
endpoint_parts = endpoint.split("/")
|
1644
|
-
parameterized_endpoint = self.get_parametrized_endpoint(endpoint=endpoint)
|
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
|
614
|
+
return pf.get_ids_from_url(
|
615
|
+
url=url, get_id_property_name=self.get_id_property_name
|
1656
616
|
)
|
1657
|
-
|
1658
|
-
|
1659
|
-
|
1660
|
-
|
1661
|
-
|
1662
|
-
|
1663
|
-
|
1664
|
-
|
1665
|
-
|
1666
|
-
|
1667
|
-
|
1668
|
-
|
1669
|
-
|
1670
|
-
|
617
|
+
|
618
|
+
@keyword
|
619
|
+
def get_invalidated_url(
|
620
|
+
self,
|
621
|
+
valid_url: str,
|
622
|
+
path: str = "",
|
623
|
+
expected_status_code: int = 404,
|
624
|
+
) -> str:
|
625
|
+
"""
|
626
|
+
Return an url with all the path parameters in the `valid_url` replaced by a
|
627
|
+
random UUID if no PathPropertiesConstraint is mapped for the `"get"` operation
|
628
|
+
on the mapped `path` and `expected_status_code`.
|
629
|
+
If a PathPropertiesConstraint is mapped, the `invalid_value` is returned.
|
630
|
+
|
631
|
+
Raises ValueError if the valid_url cannot be invalidated.
|
632
|
+
"""
|
633
|
+
return pi.get_invalidated_url(
|
634
|
+
valid_url=valid_url,
|
635
|
+
path=path,
|
636
|
+
base_url=self.base_url,
|
637
|
+
get_dto_class=self.get_dto_class,
|
638
|
+
expected_status_code=expected_status_code,
|
1671
639
|
)
|
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
640
|
|
641
|
+
# endregion
|
642
|
+
# region: resource relations keywords
|
1678
643
|
@keyword
|
1679
|
-
def
|
1680
|
-
self, url: str, method: str, dto: Dto, conflict_status_code: int
|
1681
|
-
) -> Dict[str, Any]:
|
644
|
+
def ensure_in_use(self, url: str, resource_relation: IdReference) -> None:
|
1682
645
|
"""
|
1683
|
-
|
1684
|
-
|
1685
|
-
`conflict_status_code`.
|
646
|
+
Ensure that the (right-most) `id` of the resource referenced by the `url`
|
647
|
+
is used by the resource defined by the `resource_relation`.
|
1686
648
|
"""
|
1687
|
-
|
1688
|
-
|
1689
|
-
|
1690
|
-
|
1691
|
-
|
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}."
|
649
|
+
rr.ensure_in_use(
|
650
|
+
url=url,
|
651
|
+
base_url=self.base_url,
|
652
|
+
openapi_spec=self.openapi_spec,
|
653
|
+
resource_relation=resource_relation,
|
1728
654
|
)
|
1729
655
|
|
656
|
+
# endregion
|
657
|
+
# region: request keywords
|
1730
658
|
@keyword
|
1731
659
|
def authorized_request( # pylint: disable=too-many-arguments
|
1732
660
|
self,
|
1733
661
|
url: str,
|
1734
662
|
method: str,
|
1735
|
-
params:
|
1736
|
-
headers:
|
1737
|
-
json_data:
|
663
|
+
params: dict[str, Any] | None = None,
|
664
|
+
headers: dict[str, str] | None = None,
|
665
|
+
json_data: JSON = None,
|
1738
666
|
data: Any = None,
|
1739
667
|
files: Any = None,
|
1740
668
|
) -> Response:
|
@@ -1749,7 +677,7 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
1749
677
|
> Note: provided username / password or auth objects take precedence over token
|
1750
678
|
based security
|
1751
679
|
"""
|
1752
|
-
headers = headers if headers else {}
|
680
|
+
headers = deepcopy(headers) if headers else {}
|
1753
681
|
if self.extra_headers:
|
1754
682
|
headers.update(self.extra_headers)
|
1755
683
|
# if both an auth object and a token are available, auth takes precedence
|
@@ -1774,90 +702,67 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
1774
702
|
logger.debug(f"Response text: {response.text}")
|
1775
703
|
return response
|
1776
704
|
|
705
|
+
# endregion
|
706
|
+
# region: validation keywords
|
1777
707
|
@keyword
|
1778
708
|
def perform_validated_request(
|
1779
709
|
self,
|
1780
710
|
path: str,
|
1781
711
|
status_code: int,
|
1782
712
|
request_values: RequestValues,
|
1783
|
-
original_data:
|
713
|
+
original_data: Mapping[str, object] = default_any_mapping,
|
1784
714
|
) -> None:
|
1785
715
|
"""
|
1786
716
|
This keyword first calls the Authorized Request keyword, then the Validate
|
1787
717
|
Response keyword and finally validates, for `DELETE` operations, whether
|
1788
718
|
the target resource was indeed deleted (OK response) or not (error responses).
|
1789
719
|
"""
|
1790
|
-
|
1791
|
-
|
1792
|
-
|
1793
|
-
request_values
|
1794
|
-
|
1795
|
-
request_values.headers,
|
1796
|
-
request_values.json_data,
|
720
|
+
val.perform_validated_request(
|
721
|
+
path=path,
|
722
|
+
status_code=status_code,
|
723
|
+
request_values=request_values,
|
724
|
+
original_data=original_data,
|
1797
725
|
)
|
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
726
|
|
1824
|
-
|
727
|
+
@keyword
|
728
|
+
def validate_response_using_validator(
|
729
|
+
self, request: RequestsOpenAPIRequest, response: RequestsOpenAPIResponse
|
730
|
+
) -> None:
|
731
|
+
"""
|
732
|
+
Validate the `response` for a given `request` against the OpenAPI Spec that is
|
733
|
+
loaded during library initialization.
|
734
|
+
"""
|
735
|
+
val.validate_response_using_validator(
|
736
|
+
request=request,
|
737
|
+
response=response,
|
738
|
+
response_validator=self.response_validator,
|
739
|
+
)
|
1825
740
|
|
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
|
-
)
|
741
|
+
@keyword
|
742
|
+
def assert_href_to_resource_is_valid(
|
743
|
+
self, href: str, referenced_resource: dict[str, JSON]
|
744
|
+
) -> None:
|
745
|
+
"""
|
746
|
+
Attempt to GET the resource referenced by the `href` and validate it's equal
|
747
|
+
to the provided `referenced_resource` object / dictionary.
|
748
|
+
"""
|
749
|
+
val.assert_href_to_resource_is_valid(
|
750
|
+
href=href,
|
751
|
+
origin=self.origin,
|
752
|
+
base_url=self.base_url,
|
753
|
+
referenced_resource=referenced_resource,
|
754
|
+
)
|
1850
755
|
|
1851
756
|
@keyword
|
1852
757
|
def validate_response(
|
1853
758
|
self,
|
1854
759
|
path: str,
|
1855
760
|
response: Response,
|
1856
|
-
original_data:
|
761
|
+
original_data: Mapping[str, object] = default_any_mapping,
|
1857
762
|
) -> None:
|
1858
763
|
"""
|
1859
764
|
Validate the `response` by performing the following validations:
|
1860
|
-
- validate the `response` against the openapi schema for the `
|
765
|
+
- validate the `response` against the openapi schema for the `path`
|
1861
766
|
- validate that the response does not contain extra properties
|
1862
767
|
- validate that a href, if present, refers to the correct resource
|
1863
768
|
- validate that the value for a property that is in the response is equal to
|
@@ -1865,277 +770,36 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
1865
770
|
- validate that no `original_data` is preserved when performing a PUT operation
|
1866
771
|
- validate that a PATCH operation only updates the provided properties
|
1867
772
|
"""
|
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(
|
773
|
+
val.validate_response(
|
1886
774
|
path=path,
|
1887
|
-
|
1888
|
-
|
775
|
+
response=response,
|
776
|
+
response_validator=self.response_validator,
|
777
|
+
server_validation_warning_logged=self._server_validation_warning_logged,
|
778
|
+
disable_server_validation=self.disable_server_validation,
|
779
|
+
invalid_property_default_response=self.invalid_property_default_response,
|
780
|
+
response_validation=self.response_validation,
|
781
|
+
openapi_spec=self.openapi_spec,
|
782
|
+
original_data=original_data,
|
1889
783
|
)
|
1890
784
|
|
1891
|
-
content_type_from_response = response.headers.get("Content-Type", "unknown")
|
1892
|
-
mime_type_from_response, _, _ = content_type_from_response.partition(";")
|
1893
|
-
|
1894
|
-
if not response_spec.get("content"):
|
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]
|
1912
|
-
|
1913
|
-
if mime_type != mime_type_from_response:
|
1914
|
-
raise ValueError(
|
1915
|
-
f"Content-Type '{content_type_from_response}' of the response "
|
1916
|
-
f"does not match '{mime_type}' as specified in the OpenAPI document."
|
1917
|
-
)
|
1918
|
-
|
1919
|
-
json_response = response.json()
|
1920
|
-
response_schema = resolve_schema(
|
1921
|
-
response_spec["content"][content_type]["schema"]
|
1922
|
-
)
|
1923
|
-
|
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
|
-
else:
|
1947
|
-
for item in json_response:
|
1948
|
-
self._validate_value_type(
|
1949
|
-
value=item, expected_type=type_of_list_items
|
1950
|
-
)
|
1951
|
-
# no further validation; value validation of individual resources should
|
1952
|
-
# be performed on the endpoints for the specific resource
|
1953
|
-
return None
|
1954
|
-
|
1955
|
-
run_keyword("validate_resource_properties", json_response, response_schema)
|
1956
|
-
# ensure the href is valid if present in the response
|
1957
|
-
if href := json_response.get("href"):
|
1958
|
-
self._assert_href_is_valid(href, json_response)
|
1959
|
-
# every property that was sucessfully send and that is in the response
|
1960
|
-
# schema must have the value that was send
|
1961
|
-
if response.ok and response.request.method in ["POST", "PUT", "PATCH"]:
|
1962
|
-
run_keyword("validate_send_response", response, original_data)
|
1963
|
-
return None
|
1964
|
-
|
1965
|
-
def _assert_href_is_valid(self, href: str, json_response: Dict[str, Any]) -> None:
|
1966
|
-
url = f"{self.origin}{href}"
|
1967
|
-
path = url.replace(self.base_url, "")
|
1968
|
-
request_data = self.get_request_data(endpoint=path, method="GET")
|
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}"
|
1975
|
-
|
1976
|
-
def _validate_response_against_spec(self, response: Response) -> None:
|
1977
|
-
try:
|
1978
|
-
self.validate_response_vs_spec(
|
1979
|
-
request=RequestsOpenAPIRequest(response.request),
|
1980
|
-
response=RequestsOpenAPIResponse(response),
|
1981
|
-
)
|
1982
|
-
except (ResponseValidationError, ServerNotFound) as exception:
|
1983
|
-
errors: List[InvalidSchemaValue] = exception.__cause__
|
1984
|
-
validation_errors: Optional[List[ValidationError]] = getattr(
|
1985
|
-
errors, "schema_errors", None
|
1986
|
-
)
|
1987
|
-
if validation_errors:
|
1988
|
-
error_message = "\n".join(
|
1989
|
-
[
|
1990
|
-
f"{list(error.schema_path)}: {error.message}"
|
1991
|
-
for error in validation_errors
|
1992
|
-
]
|
1993
|
-
)
|
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
|
-
|
2018
785
|
@keyword
|
2019
786
|
def validate_resource_properties(
|
2020
|
-
self, resource:
|
787
|
+
self, resource: dict[str, JSON], schema: dict[str, JSON]
|
2021
788
|
) -> None:
|
2022
789
|
"""
|
2023
790
|
Validate that the `resource` does not contain any properties that are not
|
2024
791
|
defined in the `schema_properties`.
|
2025
792
|
"""
|
2026
|
-
|
2027
|
-
|
2028
|
-
|
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
|
2063
|
-
)
|
2064
|
-
|
2065
|
-
if extra_property_names or missing_properties:
|
2066
|
-
extra = (
|
2067
|
-
f"\n\tExtra properties in response: {extra_property_names}"
|
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}"
|
2083
|
-
)
|
2084
|
-
|
2085
|
-
@staticmethod
|
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}")
|
2102
|
-
|
2103
|
-
@staticmethod
|
2104
|
-
def _validate_type_of_extra_properties(
|
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."
|
2121
|
-
)
|
2122
|
-
return
|
2123
|
-
|
2124
|
-
invalid_extra_properties = {
|
2125
|
-
key: value
|
2126
|
-
for key, value in extra_properties.items()
|
2127
|
-
if not isinstance(value, python_type)
|
2128
|
-
}
|
2129
|
-
if invalid_extra_properties:
|
2130
|
-
raise AssertionError(
|
2131
|
-
f"Response contains invalid additionalProperties: "
|
2132
|
-
f"{invalid_extra_properties} are not of type {expected_type}."
|
2133
|
-
)
|
793
|
+
val.validate_resource_properties(
|
794
|
+
resource=resource,
|
795
|
+
schema=schema,
|
796
|
+
)
|
2134
797
|
|
2135
798
|
@staticmethod
|
2136
799
|
@keyword
|
2137
800
|
def validate_send_response(
|
2138
|
-
response: Response,
|
801
|
+
response: Response,
|
802
|
+
original_data: Mapping[str, object] = default_any_mapping,
|
2139
803
|
) -> None:
|
2140
804
|
"""
|
2141
805
|
Validate that each property that was send that is in the response has the value
|
@@ -2143,102 +807,132 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
|
|
2143
807
|
In case a PATCH request, validate that only the properties that were patched
|
2144
808
|
have changed and that other properties are still at their pre-patch values.
|
2145
809
|
"""
|
810
|
+
val.validate_send_response(response=response, original_data=original_data)
|
811
|
+
|
812
|
+
# endregion
|
813
|
+
|
814
|
+
@property
|
815
|
+
def origin(self) -> str:
|
816
|
+
return self._origin
|
817
|
+
|
818
|
+
@property
|
819
|
+
def base_url(self) -> str:
|
820
|
+
return f"{self.origin}{self._base_path}"
|
821
|
+
|
822
|
+
@cached_property
|
823
|
+
def validation_spec(self) -> Spec:
|
824
|
+
_, validation_spec, _ = self._load_specs_and_validator()
|
825
|
+
return validation_spec
|
2146
826
|
|
2147
|
-
|
2148
|
-
|
2149
|
-
|
2150
|
-
|
2151
|
-
|
2152
|
-
|
2153
|
-
|
2154
|
-
|
2155
|
-
|
2156
|
-
|
2157
|
-
|
2158
|
-
|
2159
|
-
|
2160
|
-
|
2161
|
-
|
2162
|
-
|
2163
|
-
|
2164
|
-
|
2165
|
-
|
2166
|
-
|
2167
|
-
|
2168
|
-
|
2169
|
-
|
2170
|
-
|
2171
|
-
|
2172
|
-
|
2173
|
-
|
2174
|
-
|
2175
|
-
|
2176
|
-
|
2177
|
-
|
2178
|
-
|
2179
|
-
|
2180
|
-
if
|
2181
|
-
|
2182
|
-
|
2183
|
-
|
2184
|
-
|
2185
|
-
|
2186
|
-
|
2187
|
-
|
2188
|
-
|
2189
|
-
|
2190
|
-
|
2191
|
-
|
2192
|
-
|
2193
|
-
|
2194
|
-
|
2195
|
-
|
2196
|
-
|
827
|
+
@property
|
828
|
+
def openapi_spec(self) -> dict[str, JSON]:
|
829
|
+
"""Return a deepcopy of the parsed openapi document."""
|
830
|
+
# protect the parsed openapi spec from being mutated by reference
|
831
|
+
return deepcopy(self._openapi_spec)
|
832
|
+
|
833
|
+
@cached_property
|
834
|
+
def _openapi_spec(self) -> dict[str, JSON]:
|
835
|
+
parser, _, _ = self._load_specs_and_validator()
|
836
|
+
spec_dict: dict[str, JSON] = parser.specification
|
837
|
+
register_path_parameters(spec_dict["paths"])
|
838
|
+
return spec_dict
|
839
|
+
|
840
|
+
@cached_property
|
841
|
+
def response_validator(
|
842
|
+
self,
|
843
|
+
) -> ResponseValidatorType:
|
844
|
+
_, _, response_validator = self._load_specs_and_validator()
|
845
|
+
return response_validator
|
846
|
+
|
847
|
+
def _get_json_types_from_spec(self, spec: dict[str, JSON]) -> set[str]:
|
848
|
+
json_types: set[str] = set(self._get_json_types(spec))
|
849
|
+
return {json_type for json_type in json_types if json_type is not None}
|
850
|
+
|
851
|
+
def _get_json_types(self, item: object) -> Generator[str, None, None]:
|
852
|
+
if isinstance(item, dict):
|
853
|
+
content_dict = item.get("content")
|
854
|
+
if content_dict is None:
|
855
|
+
for value in item.values():
|
856
|
+
yield from self._get_json_types(value)
|
857
|
+
|
858
|
+
else:
|
859
|
+
for content_type in content_dict:
|
860
|
+
if "json" in content_type:
|
861
|
+
content_type_without_charset, _, _ = content_type.partition(";")
|
862
|
+
yield content_type_without_charset
|
863
|
+
|
864
|
+
if isinstance(item, list):
|
865
|
+
for list_item in item:
|
866
|
+
yield from self._get_json_types(list_item)
|
867
|
+
|
868
|
+
def _load_specs_and_validator(
|
869
|
+
self,
|
870
|
+
) -> tuple[
|
871
|
+
ResolvingParser,
|
872
|
+
Spec,
|
873
|
+
ResponseValidatorType,
|
874
|
+
]:
|
875
|
+
def recursion_limit_handler(
|
876
|
+
limit: int, # pylint: disable=unused-argument
|
877
|
+
refstring: str, # pylint: disable=unused-argument
|
878
|
+
recursions: JSON, # pylint: disable=unused-argument
|
879
|
+
) -> JSON:
|
880
|
+
return self._recursion_default
|
881
|
+
|
882
|
+
try:
|
883
|
+
# Since parsing of the OAS and creating the Spec can take a long time,
|
884
|
+
# they are cached. This is done by storing them in an imported module that
|
885
|
+
# will have a global scope due to how the Python import system works. This
|
886
|
+
# ensures that in a Suite of Suites where multiple Suites use the same
|
887
|
+
# `source`, that OAS is only parsed / loaded once.
|
888
|
+
cached_parser = PARSER_CACHE.get(self._source, None)
|
889
|
+
if cached_parser:
|
890
|
+
return (
|
891
|
+
cached_parser.parser,
|
892
|
+
cached_parser.validation_spec,
|
893
|
+
cached_parser.response_validator,
|
894
|
+
)
|
895
|
+
|
896
|
+
parser = ResolvingParser(
|
897
|
+
self._source,
|
898
|
+
backend="openapi-spec-validator",
|
899
|
+
recursion_limit=self._recursion_limit,
|
900
|
+
recursion_limit_handler=recursion_limit_handler,
|
2197
901
|
)
|
2198
|
-
|
2199
|
-
|
2200
|
-
|
2201
|
-
|
2202
|
-
|
2203
|
-
|
2204
|
-
|
2205
|
-
|
2206
|
-
|
2207
|
-
|
2208
|
-
|
2209
|
-
|
2210
|
-
|
2211
|
-
|
2212
|
-
|
2213
|
-
|
2214
|
-
|
2215
|
-
|
2216
|
-
|
2217
|
-
|
2218
|
-
|
2219
|
-
|
2220
|
-
|
2221
|
-
|
2222
|
-
|
2223
|
-
|
2224
|
-
|
2225
|
-
|
2226
|
-
|
2227
|
-
|
2228
|
-
|
2229
|
-
|
2230
|
-
|
2231
|
-
|
2232
|
-
|
2233
|
-
|
2234
|
-
return
|
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
|
902
|
+
|
903
|
+
if parser.specification is None: # pragma: no cover
|
904
|
+
raise FatalError(
|
905
|
+
"Source was loaded, but no specification was present after parsing."
|
906
|
+
)
|
907
|
+
|
908
|
+
validation_spec = Spec.from_dict(parser.specification) # pyright: ignore[reportArgumentType]
|
909
|
+
|
910
|
+
json_types_from_spec: set[str] = self._get_json_types_from_spec(
|
911
|
+
parser.specification
|
912
|
+
)
|
913
|
+
extra_deserializers = {
|
914
|
+
json_type: _json.loads for json_type in json_types_from_spec
|
915
|
+
}
|
916
|
+
config = Config(extra_media_type_deserializers=extra_deserializers) # type: ignore[arg-type]
|
917
|
+
openapi = OpenAPI(spec=validation_spec, config=config)
|
918
|
+
response_validator: ResponseValidatorType = openapi.validate_response # type: ignore[assignment]
|
919
|
+
|
920
|
+
PARSER_CACHE[self._source] = CachedParser(
|
921
|
+
parser=parser,
|
922
|
+
validation_spec=validation_spec,
|
923
|
+
response_validator=response_validator,
|
924
|
+
)
|
925
|
+
|
926
|
+
return parser, validation_spec, response_validator
|
927
|
+
|
928
|
+
except ResolutionError as exception:
|
929
|
+
raise FatalError(
|
930
|
+
f"ResolutionError while trying to load openapi spec: {exception}"
|
931
|
+
) from exception
|
932
|
+
except ValidationError as exception:
|
933
|
+
raise FatalError(
|
934
|
+
f"ValidationError while trying to load openapi spec: {exception}"
|
935
|
+
) from exception
|
936
|
+
|
937
|
+
def read_paths(self) -> dict[str, JSON]:
|
938
|
+
return self.openapi_spec["paths"] # type: ignore[return-value]
|