robotframework-openapitools 0.1.2__py3-none-any.whl → 0.1.3__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/openapi_executors.py +748 -744
- OpenApiDriver/openapidriver.libspec +2 -2
- OpenApiLibCore/openapi_libcore.libspec +14 -14
- OpenApiLibCore/openapi_libcore.py +1525 -1512
- {robotframework_openapitools-0.1.2.dist-info → robotframework_openapitools-0.1.3.dist-info}/METADATA +1 -1
- {robotframework_openapitools-0.1.2.dist-info → robotframework_openapitools-0.1.3.dist-info}/RECORD +8 -8
- {robotframework_openapitools-0.1.2.dist-info → robotframework_openapitools-0.1.3.dist-info}/LICENSE +0 -0
- {robotframework_openapitools-0.1.2.dist-info → robotframework_openapitools-0.1.3.dist-info}/WHEEL +0 -0
@@ -1,744 +1,748 @@
|
|
1
|
-
"""Module containing the classes to perform automatic OpenAPI contract validation."""
|
2
|
-
|
3
|
-
import json as _json
|
4
|
-
from enum import Enum
|
5
|
-
from logging import getLogger
|
6
|
-
from pathlib import Path
|
7
|
-
from random import choice
|
8
|
-
from typing import Any, Dict, List, Optional, Tuple, Union
|
9
|
-
|
10
|
-
from openapi_core.contrib.requests import (
|
11
|
-
RequestsOpenAPIRequest,
|
12
|
-
RequestsOpenAPIResponse,
|
13
|
-
)
|
14
|
-
from openapi_core.exceptions import OpenAPIError
|
15
|
-
from openapi_core.validation.exceptions import ValidationError
|
16
|
-
from openapi_core.validation.response.exceptions import InvalidData
|
17
|
-
from openapi_core.validation.schemas.exceptions import InvalidSchemaValue
|
18
|
-
from requests import Response
|
19
|
-
from requests.auth import AuthBase
|
20
|
-
from requests.cookies import RequestsCookieJar as CookieJar
|
21
|
-
from robot.api import Failure, SkipExecution
|
22
|
-
from robot.api.deco import keyword, library
|
23
|
-
from robot.libraries.BuiltIn import BuiltIn
|
24
|
-
|
25
|
-
from OpenApiLibCore import OpenApiLibCore, RequestData, RequestValues, resolve_schema
|
26
|
-
|
27
|
-
run_keyword = BuiltIn().run_keyword
|
28
|
-
|
29
|
-
|
30
|
-
logger = getLogger(__name__)
|
31
|
-
|
32
|
-
|
33
|
-
class ValidationLevel(str, Enum):
|
34
|
-
"""The available levels for the response_validation parameter."""
|
35
|
-
|
36
|
-
DISABLED = "DISABLED"
|
37
|
-
INFO = "INFO"
|
38
|
-
WARN = "WARN"
|
39
|
-
STRICT = "STRICT"
|
40
|
-
|
41
|
-
|
42
|
-
@library(scope="TEST SUITE", doc_format="ROBOT")
|
43
|
-
class OpenApiExecutors(OpenApiLibCore): # pylint: disable=too-many-instance-attributes
|
44
|
-
"""Main class providing the keywords and core logic to perform endpoint validations."""
|
45
|
-
|
46
|
-
def __init__( # pylint: disable=too-many-arguments
|
47
|
-
self,
|
48
|
-
source: str,
|
49
|
-
origin: str = "",
|
50
|
-
base_path: str = "",
|
51
|
-
response_validation: ValidationLevel = ValidationLevel.WARN,
|
52
|
-
disable_server_validation: bool = True,
|
53
|
-
mappings_path: Union[str, Path] = "",
|
54
|
-
invalid_property_default_response: int = 422,
|
55
|
-
default_id_property_name: str = "id",
|
56
|
-
faker_locale: Optional[Union[str, List[str]]] = None,
|
57
|
-
require_body_for_invalid_url: bool = False,
|
58
|
-
recursion_limit: int = 1,
|
59
|
-
recursion_default: Any = {},
|
60
|
-
username: str = "",
|
61
|
-
password: str = "",
|
62
|
-
security_token: str = "",
|
63
|
-
auth: Optional[AuthBase] = None,
|
64
|
-
cert: Optional[Union[str, Tuple[str, str]]] = None,
|
65
|
-
verify_tls: Optional[Union[bool, str]] = True,
|
66
|
-
extra_headers: Optional[Dict[str, str]] = None,
|
67
|
-
cookies: Optional[Union[Dict[str, str], CookieJar]] = None,
|
68
|
-
proxies: Optional[Dict[str, str]] = None,
|
69
|
-
) -> None:
|
70
|
-
super().__init__(
|
71
|
-
source=source,
|
72
|
-
origin=origin,
|
73
|
-
base_path=base_path,
|
74
|
-
mappings_path=mappings_path,
|
75
|
-
default_id_property_name=default_id_property_name,
|
76
|
-
faker_locale=faker_locale,
|
77
|
-
recursion_limit=recursion_limit,
|
78
|
-
recursion_default=recursion_default,
|
79
|
-
username=username,
|
80
|
-
password=password,
|
81
|
-
security_token=security_token,
|
82
|
-
auth=auth,
|
83
|
-
cert=cert,
|
84
|
-
verify_tls=verify_tls,
|
85
|
-
extra_headers=extra_headers,
|
86
|
-
cookies=cookies,
|
87
|
-
proxies=proxies,
|
88
|
-
)
|
89
|
-
self.response_validation = response_validation
|
90
|
-
self.disable_server_validation = disable_server_validation
|
91
|
-
self.require_body_for_invalid_url = require_body_for_invalid_url
|
92
|
-
self.invalid_property_default_response = invalid_property_default_response
|
93
|
-
|
94
|
-
@keyword
|
95
|
-
def test_unauthorized(self, path: str, method: str) -> None:
|
96
|
-
"""
|
97
|
-
Perform a request for `method` on the `path`, with no authorization.
|
98
|
-
|
99
|
-
This keyword only passes if the response code is 401: Unauthorized.
|
100
|
-
|
101
|
-
Any authorization parameters used to initialize the library are
|
102
|
-
ignored for this request.
|
103
|
-
> Note: No headers or (json) body are send with the request. For security
|
104
|
-
reasons, the authorization validation should be checked first.
|
105
|
-
"""
|
106
|
-
url: str = run_keyword("get_valid_url", path, method)
|
107
|
-
response = self.session.request(
|
108
|
-
method=method,
|
109
|
-
url=url,
|
110
|
-
verify=False,
|
111
|
-
)
|
112
|
-
assert response.status_code == 401
|
113
|
-
|
114
|
-
@keyword
|
115
|
-
def test_invalid_url(
|
116
|
-
self, path: str, method: str, expected_status_code: int = 404
|
117
|
-
) -> None:
|
118
|
-
"""
|
119
|
-
Perform a request for the provided 'path' and 'method' where the url for
|
120
|
-
the `path` is invalidated.
|
121
|
-
|
122
|
-
This keyword will be `SKIPPED` if the path contains no parts that
|
123
|
-
can be invalidated.
|
124
|
-
|
125
|
-
The optional `expected_status_code` parameter (default: 404) can be set to the
|
126
|
-
expected status code for APIs that do not return a 404 on invalid urls.
|
127
|
-
|
128
|
-
> Note: Depending on API design, the url may be validated before or after
|
129
|
-
validation of headers, query parameters and / or (json) body. By default, no
|
130
|
-
parameters are send with the request. The `require_body_for_invalid_url`
|
131
|
-
parameter can be set to `True` if needed.
|
132
|
-
"""
|
133
|
-
valid_url: str = run_keyword("get_valid_url", path, method)
|
134
|
-
|
135
|
-
if not (url := run_keyword("get_invalidated_url", valid_url)):
|
136
|
-
raise SkipExecution(
|
137
|
-
f"Path {path} does not contain resource references that "
|
138
|
-
f"can be invalidated."
|
139
|
-
)
|
140
|
-
|
141
|
-
params, headers, json_data = None, None, None
|
142
|
-
if self.require_body_for_invalid_url:
|
143
|
-
request_data = self.get_request_data(method=method, endpoint=path)
|
144
|
-
params = request_data.params
|
145
|
-
headers = request_data.headers
|
146
|
-
dto = request_data.dto
|
147
|
-
json_data = dto.as_dict()
|
148
|
-
response: Response = run_keyword(
|
149
|
-
"authorized_request", url, method, params, headers, json_data
|
150
|
-
)
|
151
|
-
if response.status_code != expected_status_code:
|
152
|
-
raise AssertionError(
|
153
|
-
f"Response {response.status_code} was not {expected_status_code}"
|
154
|
-
)
|
155
|
-
|
156
|
-
@keyword
|
157
|
-
def test_endpoint(self, path: str, method: str, status_code: int) -> None:
|
158
|
-
"""
|
159
|
-
Validate that performing the `method` operation on `path` results in a
|
160
|
-
`status_code` response.
|
161
|
-
|
162
|
-
This is the main keyword to be used in the `Test Template` keyword when using
|
163
|
-
the OpenApiDriver.
|
164
|
-
|
165
|
-
The keyword calls other keywords to generate the neccesary data to perform
|
166
|
-
the desired operation and validate the response against the openapi document.
|
167
|
-
"""
|
168
|
-
json_data: Optional[Dict[str, Any]] = None
|
169
|
-
original_data = None
|
170
|
-
|
171
|
-
url: str = run_keyword("get_valid_url", path, method)
|
172
|
-
request_data: RequestData = self.get_request_data(method=method, endpoint=path)
|
173
|
-
params = request_data.params
|
174
|
-
headers = request_data.headers
|
175
|
-
if request_data.has_body:
|
176
|
-
json_data = request_data.dto.as_dict()
|
177
|
-
# when patching, get the original data to check only patched data has changed
|
178
|
-
if method == "PATCH":
|
179
|
-
original_data = self.get_original_data(url=url)
|
180
|
-
# in case of a status code indicating an error, ensure the error occurs
|
181
|
-
if status_code >= 400:
|
182
|
-
invalidation_keyword_data = {
|
183
|
-
"get_invalid_json_data": [
|
184
|
-
"get_invalid_json_data",
|
185
|
-
url,
|
186
|
-
method,
|
187
|
-
status_code,
|
188
|
-
request_data,
|
189
|
-
],
|
190
|
-
"get_invalidated_parameters": [
|
191
|
-
"get_invalidated_parameters",
|
192
|
-
status_code,
|
193
|
-
request_data,
|
194
|
-
],
|
195
|
-
}
|
196
|
-
invalidation_keywords = []
|
197
|
-
|
198
|
-
if request_data.dto.get_relations_for_error_code(status_code):
|
199
|
-
invalidation_keywords.append("get_invalid_json_data")
|
200
|
-
if request_data.dto.get_parameter_relations_for_error_code(status_code):
|
201
|
-
invalidation_keywords.append("get_invalidated_parameters")
|
202
|
-
if invalidation_keywords:
|
203
|
-
if (
|
204
|
-
invalidation_keyword := choice(invalidation_keywords)
|
205
|
-
) == "get_invalid_json_data":
|
206
|
-
json_data = run_keyword(
|
207
|
-
*invalidation_keyword_data[invalidation_keyword]
|
208
|
-
)
|
209
|
-
else:
|
210
|
-
params, headers = run_keyword(
|
211
|
-
*invalidation_keyword_data[invalidation_keyword]
|
212
|
-
)
|
213
|
-
# if there are no relations to invalide and the status_code is the default
|
214
|
-
# response_code for invalid properties, invalidate properties instead
|
215
|
-
elif status_code == self.invalid_property_default_response:
|
216
|
-
if (
|
217
|
-
request_data.params_that_can_be_invalidated
|
218
|
-
or request_data.headers_that_can_be_invalidated
|
219
|
-
):
|
220
|
-
params, headers = run_keyword(
|
221
|
-
*invalidation_keyword_data["get_invalidated_parameters"]
|
222
|
-
)
|
223
|
-
if request_data.dto_schema:
|
224
|
-
json_data = run_keyword(
|
225
|
-
*invalidation_keyword_data["get_invalid_json_data"]
|
226
|
-
)
|
227
|
-
elif request_data.dto_schema:
|
228
|
-
json_data = run_keyword(
|
229
|
-
*invalidation_keyword_data["get_invalid_json_data"]
|
230
|
-
)
|
231
|
-
else:
|
232
|
-
raise SkipExecution(
|
233
|
-
"No properties or parameters can be invalidated."
|
234
|
-
)
|
235
|
-
else:
|
236
|
-
raise AssertionError(
|
237
|
-
f"No Dto mapping found to cause status_code {status_code}."
|
238
|
-
)
|
239
|
-
run_keyword(
|
240
|
-
"perform_validated_request",
|
241
|
-
path,
|
242
|
-
status_code,
|
243
|
-
RequestValues(
|
244
|
-
url=url,
|
245
|
-
method=method,
|
246
|
-
params=params,
|
247
|
-
headers=headers,
|
248
|
-
json_data=json_data,
|
249
|
-
),
|
250
|
-
original_data,
|
251
|
-
)
|
252
|
-
if status_code < 300 and (
|
253
|
-
request_data.has_optional_properties
|
254
|
-
or request_data.has_optional_params
|
255
|
-
or request_data.has_optional_headers
|
256
|
-
):
|
257
|
-
logger.info("Performing request without optional properties and parameters")
|
258
|
-
url = run_keyword("get_valid_url", path, method)
|
259
|
-
request_data = self.get_request_data(method=method, endpoint=path)
|
260
|
-
params = request_data.get_required_params()
|
261
|
-
headers = request_data.get_required_headers()
|
262
|
-
json_data =
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
"""
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
"""
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
request_values.
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
f"
|
340
|
-
|
341
|
-
|
342
|
-
f"
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
- validate that
|
387
|
-
- validate that a
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
470
|
-
#
|
471
|
-
if
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
481
|
-
|
482
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
|
510
|
-
|
511
|
-
|
512
|
-
|
513
|
-
|
514
|
-
|
515
|
-
|
516
|
-
|
517
|
-
|
518
|
-
|
519
|
-
|
520
|
-
|
521
|
-
|
522
|
-
|
523
|
-
|
524
|
-
|
525
|
-
|
526
|
-
|
527
|
-
|
528
|
-
|
529
|
-
|
530
|
-
|
531
|
-
|
532
|
-
|
533
|
-
|
534
|
-
|
535
|
-
|
536
|
-
|
537
|
-
|
538
|
-
|
539
|
-
|
540
|
-
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
|
545
|
-
|
546
|
-
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
551
|
-
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
|
556
|
-
|
557
|
-
|
558
|
-
|
559
|
-
|
560
|
-
|
561
|
-
|
562
|
-
|
563
|
-
|
564
|
-
|
565
|
-
|
566
|
-
|
567
|
-
|
568
|
-
|
569
|
-
|
570
|
-
|
571
|
-
|
572
|
-
|
573
|
-
|
574
|
-
|
575
|
-
|
576
|
-
|
577
|
-
|
578
|
-
|
579
|
-
|
580
|
-
|
581
|
-
f"
|
582
|
-
f"
|
583
|
-
|
584
|
-
|
585
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
589
|
-
|
590
|
-
|
591
|
-
|
592
|
-
"
|
593
|
-
"
|
594
|
-
|
595
|
-
|
596
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
if
|
601
|
-
raise AssertionError(
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
610
|
-
|
611
|
-
|
612
|
-
"
|
613
|
-
"
|
614
|
-
|
615
|
-
|
616
|
-
|
617
|
-
|
618
|
-
|
619
|
-
|
620
|
-
|
621
|
-
|
622
|
-
|
623
|
-
|
624
|
-
|
625
|
-
|
626
|
-
|
627
|
-
|
628
|
-
|
629
|
-
|
630
|
-
|
631
|
-
|
632
|
-
|
633
|
-
|
634
|
-
|
635
|
-
|
636
|
-
|
637
|
-
|
638
|
-
|
639
|
-
|
640
|
-
|
641
|
-
|
642
|
-
|
643
|
-
|
644
|
-
|
645
|
-
|
646
|
-
|
647
|
-
|
648
|
-
|
649
|
-
|
650
|
-
|
651
|
-
|
652
|
-
|
653
|
-
|
654
|
-
|
655
|
-
|
656
|
-
|
657
|
-
|
658
|
-
|
659
|
-
|
660
|
-
|
661
|
-
|
662
|
-
|
663
|
-
|
664
|
-
|
665
|
-
|
666
|
-
|
667
|
-
|
668
|
-
|
669
|
-
|
670
|
-
|
671
|
-
#
|
672
|
-
#
|
673
|
-
|
674
|
-
|
675
|
-
|
676
|
-
|
677
|
-
|
678
|
-
|
679
|
-
|
680
|
-
|
681
|
-
|
682
|
-
|
683
|
-
|
684
|
-
|
685
|
-
|
686
|
-
|
687
|
-
|
688
|
-
|
689
|
-
|
690
|
-
|
691
|
-
|
692
|
-
|
693
|
-
|
694
|
-
|
695
|
-
|
696
|
-
|
697
|
-
|
698
|
-
|
699
|
-
|
700
|
-
|
701
|
-
|
702
|
-
|
703
|
-
|
704
|
-
|
705
|
-
|
706
|
-
|
707
|
-
|
708
|
-
|
709
|
-
|
710
|
-
|
711
|
-
|
712
|
-
|
713
|
-
|
714
|
-
|
715
|
-
|
716
|
-
|
717
|
-
[
|
718
|
-
|
719
|
-
]
|
720
|
-
|
721
|
-
|
722
|
-
|
723
|
-
|
724
|
-
|
725
|
-
|
726
|
-
|
727
|
-
|
728
|
-
|
729
|
-
|
730
|
-
|
731
|
-
|
732
|
-
|
733
|
-
|
734
|
-
|
735
|
-
|
736
|
-
|
737
|
-
|
738
|
-
|
739
|
-
|
740
|
-
|
741
|
-
|
742
|
-
|
743
|
-
|
744
|
-
|
1
|
+
"""Module containing the classes to perform automatic OpenAPI contract validation."""
|
2
|
+
|
3
|
+
import json as _json
|
4
|
+
from enum import Enum
|
5
|
+
from logging import getLogger
|
6
|
+
from pathlib import Path
|
7
|
+
from random import choice
|
8
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
9
|
+
|
10
|
+
from openapi_core.contrib.requests import (
|
11
|
+
RequestsOpenAPIRequest,
|
12
|
+
RequestsOpenAPIResponse,
|
13
|
+
)
|
14
|
+
from openapi_core.exceptions import OpenAPIError
|
15
|
+
from openapi_core.validation.exceptions import ValidationError
|
16
|
+
from openapi_core.validation.response.exceptions import InvalidData
|
17
|
+
from openapi_core.validation.schemas.exceptions import InvalidSchemaValue
|
18
|
+
from requests import Response
|
19
|
+
from requests.auth import AuthBase
|
20
|
+
from requests.cookies import RequestsCookieJar as CookieJar
|
21
|
+
from robot.api import Failure, SkipExecution
|
22
|
+
from robot.api.deco import keyword, library
|
23
|
+
from robot.libraries.BuiltIn import BuiltIn
|
24
|
+
|
25
|
+
from OpenApiLibCore import OpenApiLibCore, RequestData, RequestValues, resolve_schema
|
26
|
+
|
27
|
+
run_keyword = BuiltIn().run_keyword
|
28
|
+
|
29
|
+
|
30
|
+
logger = getLogger(__name__)
|
31
|
+
|
32
|
+
|
33
|
+
class ValidationLevel(str, Enum):
|
34
|
+
"""The available levels for the response_validation parameter."""
|
35
|
+
|
36
|
+
DISABLED = "DISABLED"
|
37
|
+
INFO = "INFO"
|
38
|
+
WARN = "WARN"
|
39
|
+
STRICT = "STRICT"
|
40
|
+
|
41
|
+
|
42
|
+
@library(scope="TEST SUITE", doc_format="ROBOT")
|
43
|
+
class OpenApiExecutors(OpenApiLibCore): # pylint: disable=too-many-instance-attributes
|
44
|
+
"""Main class providing the keywords and core logic to perform endpoint validations."""
|
45
|
+
|
46
|
+
def __init__( # pylint: disable=too-many-arguments
|
47
|
+
self,
|
48
|
+
source: str,
|
49
|
+
origin: str = "",
|
50
|
+
base_path: str = "",
|
51
|
+
response_validation: ValidationLevel = ValidationLevel.WARN,
|
52
|
+
disable_server_validation: bool = True,
|
53
|
+
mappings_path: Union[str, Path] = "",
|
54
|
+
invalid_property_default_response: int = 422,
|
55
|
+
default_id_property_name: str = "id",
|
56
|
+
faker_locale: Optional[Union[str, List[str]]] = None,
|
57
|
+
require_body_for_invalid_url: bool = False,
|
58
|
+
recursion_limit: int = 1,
|
59
|
+
recursion_default: Any = {},
|
60
|
+
username: str = "",
|
61
|
+
password: str = "",
|
62
|
+
security_token: str = "",
|
63
|
+
auth: Optional[AuthBase] = None,
|
64
|
+
cert: Optional[Union[str, Tuple[str, str]]] = None,
|
65
|
+
verify_tls: Optional[Union[bool, str]] = True,
|
66
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
67
|
+
cookies: Optional[Union[Dict[str, str], CookieJar]] = None,
|
68
|
+
proxies: Optional[Dict[str, str]] = None,
|
69
|
+
) -> None:
|
70
|
+
super().__init__(
|
71
|
+
source=source,
|
72
|
+
origin=origin,
|
73
|
+
base_path=base_path,
|
74
|
+
mappings_path=mappings_path,
|
75
|
+
default_id_property_name=default_id_property_name,
|
76
|
+
faker_locale=faker_locale,
|
77
|
+
recursion_limit=recursion_limit,
|
78
|
+
recursion_default=recursion_default,
|
79
|
+
username=username,
|
80
|
+
password=password,
|
81
|
+
security_token=security_token,
|
82
|
+
auth=auth,
|
83
|
+
cert=cert,
|
84
|
+
verify_tls=verify_tls,
|
85
|
+
extra_headers=extra_headers,
|
86
|
+
cookies=cookies,
|
87
|
+
proxies=proxies,
|
88
|
+
)
|
89
|
+
self.response_validation = response_validation
|
90
|
+
self.disable_server_validation = disable_server_validation
|
91
|
+
self.require_body_for_invalid_url = require_body_for_invalid_url
|
92
|
+
self.invalid_property_default_response = invalid_property_default_response
|
93
|
+
|
94
|
+
@keyword
|
95
|
+
def test_unauthorized(self, path: str, method: str) -> None:
|
96
|
+
"""
|
97
|
+
Perform a request for `method` on the `path`, with no authorization.
|
98
|
+
|
99
|
+
This keyword only passes if the response code is 401: Unauthorized.
|
100
|
+
|
101
|
+
Any authorization parameters used to initialize the library are
|
102
|
+
ignored for this request.
|
103
|
+
> Note: No headers or (json) body are send with the request. For security
|
104
|
+
reasons, the authorization validation should be checked first.
|
105
|
+
"""
|
106
|
+
url: str = run_keyword("get_valid_url", path, method)
|
107
|
+
response = self.session.request(
|
108
|
+
method=method,
|
109
|
+
url=url,
|
110
|
+
verify=False,
|
111
|
+
)
|
112
|
+
assert response.status_code == 401
|
113
|
+
|
114
|
+
@keyword
|
115
|
+
def test_invalid_url(
|
116
|
+
self, path: str, method: str, expected_status_code: int = 404
|
117
|
+
) -> None:
|
118
|
+
"""
|
119
|
+
Perform a request for the provided 'path' and 'method' where the url for
|
120
|
+
the `path` is invalidated.
|
121
|
+
|
122
|
+
This keyword will be `SKIPPED` if the path contains no parts that
|
123
|
+
can be invalidated.
|
124
|
+
|
125
|
+
The optional `expected_status_code` parameter (default: 404) can be set to the
|
126
|
+
expected status code for APIs that do not return a 404 on invalid urls.
|
127
|
+
|
128
|
+
> Note: Depending on API design, the url may be validated before or after
|
129
|
+
validation of headers, query parameters and / or (json) body. By default, no
|
130
|
+
parameters are send with the request. The `require_body_for_invalid_url`
|
131
|
+
parameter can be set to `True` if needed.
|
132
|
+
"""
|
133
|
+
valid_url: str = run_keyword("get_valid_url", path, method)
|
134
|
+
|
135
|
+
if not (url := run_keyword("get_invalidated_url", valid_url)):
|
136
|
+
raise SkipExecution(
|
137
|
+
f"Path {path} does not contain resource references that "
|
138
|
+
f"can be invalidated."
|
139
|
+
)
|
140
|
+
|
141
|
+
params, headers, json_data = None, None, None
|
142
|
+
if self.require_body_for_invalid_url:
|
143
|
+
request_data = self.get_request_data(method=method, endpoint=path)
|
144
|
+
params = request_data.params
|
145
|
+
headers = request_data.headers
|
146
|
+
dto = request_data.dto
|
147
|
+
json_data = dto.as_dict()
|
148
|
+
response: Response = run_keyword(
|
149
|
+
"authorized_request", url, method, params, headers, json_data
|
150
|
+
)
|
151
|
+
if response.status_code != expected_status_code:
|
152
|
+
raise AssertionError(
|
153
|
+
f"Response {response.status_code} was not {expected_status_code}"
|
154
|
+
)
|
155
|
+
|
156
|
+
@keyword
|
157
|
+
def test_endpoint(self, path: str, method: str, status_code: int) -> None:
|
158
|
+
"""
|
159
|
+
Validate that performing the `method` operation on `path` results in a
|
160
|
+
`status_code` response.
|
161
|
+
|
162
|
+
This is the main keyword to be used in the `Test Template` keyword when using
|
163
|
+
the OpenApiDriver.
|
164
|
+
|
165
|
+
The keyword calls other keywords to generate the neccesary data to perform
|
166
|
+
the desired operation and validate the response against the openapi document.
|
167
|
+
"""
|
168
|
+
json_data: Optional[Dict[str, Any]] = None
|
169
|
+
original_data = None
|
170
|
+
|
171
|
+
url: str = run_keyword("get_valid_url", path, method)
|
172
|
+
request_data: RequestData = self.get_request_data(method=method, endpoint=path)
|
173
|
+
params = request_data.params
|
174
|
+
headers = request_data.headers
|
175
|
+
if request_data.has_body:
|
176
|
+
json_data = request_data.dto.as_dict()
|
177
|
+
# when patching, get the original data to check only patched data has changed
|
178
|
+
if method == "PATCH":
|
179
|
+
original_data = self.get_original_data(url=url)
|
180
|
+
# in case of a status code indicating an error, ensure the error occurs
|
181
|
+
if status_code >= 400:
|
182
|
+
invalidation_keyword_data = {
|
183
|
+
"get_invalid_json_data": [
|
184
|
+
"get_invalid_json_data",
|
185
|
+
url,
|
186
|
+
method,
|
187
|
+
status_code,
|
188
|
+
request_data,
|
189
|
+
],
|
190
|
+
"get_invalidated_parameters": [
|
191
|
+
"get_invalidated_parameters",
|
192
|
+
status_code,
|
193
|
+
request_data,
|
194
|
+
],
|
195
|
+
}
|
196
|
+
invalidation_keywords = []
|
197
|
+
|
198
|
+
if request_data.dto.get_relations_for_error_code(status_code):
|
199
|
+
invalidation_keywords.append("get_invalid_json_data")
|
200
|
+
if request_data.dto.get_parameter_relations_for_error_code(status_code):
|
201
|
+
invalidation_keywords.append("get_invalidated_parameters")
|
202
|
+
if invalidation_keywords:
|
203
|
+
if (
|
204
|
+
invalidation_keyword := choice(invalidation_keywords)
|
205
|
+
) == "get_invalid_json_data":
|
206
|
+
json_data = run_keyword(
|
207
|
+
*invalidation_keyword_data[invalidation_keyword]
|
208
|
+
)
|
209
|
+
else:
|
210
|
+
params, headers = run_keyword(
|
211
|
+
*invalidation_keyword_data[invalidation_keyword]
|
212
|
+
)
|
213
|
+
# if there are no relations to invalide and the status_code is the default
|
214
|
+
# response_code for invalid properties, invalidate properties instead
|
215
|
+
elif status_code == self.invalid_property_default_response:
|
216
|
+
if (
|
217
|
+
request_data.params_that_can_be_invalidated
|
218
|
+
or request_data.headers_that_can_be_invalidated
|
219
|
+
):
|
220
|
+
params, headers = run_keyword(
|
221
|
+
*invalidation_keyword_data["get_invalidated_parameters"]
|
222
|
+
)
|
223
|
+
if request_data.dto_schema:
|
224
|
+
json_data = run_keyword(
|
225
|
+
*invalidation_keyword_data["get_invalid_json_data"]
|
226
|
+
)
|
227
|
+
elif request_data.dto_schema:
|
228
|
+
json_data = run_keyword(
|
229
|
+
*invalidation_keyword_data["get_invalid_json_data"]
|
230
|
+
)
|
231
|
+
else:
|
232
|
+
raise SkipExecution(
|
233
|
+
"No properties or parameters can be invalidated."
|
234
|
+
)
|
235
|
+
else:
|
236
|
+
raise AssertionError(
|
237
|
+
f"No Dto mapping found to cause status_code {status_code}."
|
238
|
+
)
|
239
|
+
run_keyword(
|
240
|
+
"perform_validated_request",
|
241
|
+
path,
|
242
|
+
status_code,
|
243
|
+
RequestValues(
|
244
|
+
url=url,
|
245
|
+
method=method,
|
246
|
+
params=params,
|
247
|
+
headers=headers,
|
248
|
+
json_data=json_data,
|
249
|
+
),
|
250
|
+
original_data,
|
251
|
+
)
|
252
|
+
if status_code < 300 and (
|
253
|
+
request_data.has_optional_properties
|
254
|
+
or request_data.has_optional_params
|
255
|
+
or request_data.has_optional_headers
|
256
|
+
):
|
257
|
+
logger.info("Performing request without optional properties and parameters")
|
258
|
+
url = run_keyword("get_valid_url", path, method)
|
259
|
+
request_data = self.get_request_data(method=method, endpoint=path)
|
260
|
+
params = request_data.get_required_params()
|
261
|
+
headers = request_data.get_required_headers()
|
262
|
+
json_data = (
|
263
|
+
request_data.get_required_properties_dict()
|
264
|
+
if request_data.has_body
|
265
|
+
else None
|
266
|
+
)
|
267
|
+
original_data = None
|
268
|
+
if method == "PATCH":
|
269
|
+
original_data = self.get_original_data(url=url)
|
270
|
+
run_keyword(
|
271
|
+
"perform_validated_request",
|
272
|
+
path,
|
273
|
+
status_code,
|
274
|
+
RequestValues(
|
275
|
+
url=url,
|
276
|
+
method=method,
|
277
|
+
params=params,
|
278
|
+
headers=headers,
|
279
|
+
json_data=json_data,
|
280
|
+
),
|
281
|
+
original_data,
|
282
|
+
)
|
283
|
+
|
284
|
+
def get_original_data(self, url: str) -> Optional[Dict[str, Any]]:
|
285
|
+
"""
|
286
|
+
Attempt to GET the current data for the given url and return it.
|
287
|
+
|
288
|
+
If the GET request fails, None is returned.
|
289
|
+
"""
|
290
|
+
original_data = None
|
291
|
+
path = self.get_parameterized_endpoint_from_url(url)
|
292
|
+
get_request_data = self.get_request_data(endpoint=path, method="GET")
|
293
|
+
get_params = get_request_data.params
|
294
|
+
get_headers = get_request_data.headers
|
295
|
+
response: Response = run_keyword(
|
296
|
+
"authorized_request", url, "GET", get_params, get_headers
|
297
|
+
)
|
298
|
+
if response.ok:
|
299
|
+
original_data = response.json()
|
300
|
+
return original_data
|
301
|
+
|
302
|
+
@keyword
|
303
|
+
def perform_validated_request(
|
304
|
+
self,
|
305
|
+
path: str,
|
306
|
+
status_code: int,
|
307
|
+
request_values: RequestValues,
|
308
|
+
original_data: Optional[Dict[str, Any]] = None,
|
309
|
+
) -> None:
|
310
|
+
"""
|
311
|
+
This keyword first calls the Authorized Request keyword, then the Validate
|
312
|
+
Response keyword and finally validates, for `DELETE` operations, whether
|
313
|
+
the target resource was indeed deleted (OK response) or not (error responses).
|
314
|
+
"""
|
315
|
+
response = run_keyword(
|
316
|
+
"authorized_request",
|
317
|
+
request_values.url,
|
318
|
+
request_values.method,
|
319
|
+
request_values.params,
|
320
|
+
request_values.headers,
|
321
|
+
request_values.json_data,
|
322
|
+
)
|
323
|
+
if response.status_code != status_code:
|
324
|
+
try:
|
325
|
+
response_json = response.json()
|
326
|
+
except Exception as _: # pylint: disable=broad-except
|
327
|
+
logger.info(
|
328
|
+
f"Failed to get json content from response. "
|
329
|
+
f"Response text was: {response.text}"
|
330
|
+
)
|
331
|
+
response_json = {}
|
332
|
+
if not response.ok:
|
333
|
+
if description := response_json.get("detail"):
|
334
|
+
pass
|
335
|
+
else:
|
336
|
+
description = response_json.get(
|
337
|
+
"message", "response contains no message or detail."
|
338
|
+
)
|
339
|
+
logger.error(f"{response.reason}: {description}")
|
340
|
+
|
341
|
+
logger.debug(
|
342
|
+
f"\nSend: {_json.dumps(request_values.json_data, indent=4, sort_keys=True)}"
|
343
|
+
f"\nGot: {_json.dumps(response_json, indent=4, sort_keys=True)}"
|
344
|
+
)
|
345
|
+
raise AssertionError(
|
346
|
+
f"Response status_code {response.status_code} was not {status_code}"
|
347
|
+
)
|
348
|
+
|
349
|
+
run_keyword("validate_response", path, response, original_data)
|
350
|
+
|
351
|
+
if request_values.method == "DELETE":
|
352
|
+
get_request_data = self.get_request_data(endpoint=path, method="GET")
|
353
|
+
get_params = get_request_data.params
|
354
|
+
get_headers = get_request_data.headers
|
355
|
+
get_response = run_keyword(
|
356
|
+
"authorized_request", request_values.url, "GET", get_params, get_headers
|
357
|
+
)
|
358
|
+
if response.ok:
|
359
|
+
if get_response.ok:
|
360
|
+
raise AssertionError(
|
361
|
+
f"Resource still exists after deletion. Url was {request_values.url}"
|
362
|
+
)
|
363
|
+
# if the path supports GET, 404 is expected, if not 405 is expected
|
364
|
+
if get_response.status_code not in [404, 405]:
|
365
|
+
logger.warning(
|
366
|
+
f"Unexpected response after deleting resource: Status_code "
|
367
|
+
f"{get_response.status_code} was received after trying to get {request_values.url} "
|
368
|
+
f"after sucessfully deleting it."
|
369
|
+
)
|
370
|
+
elif not get_response.ok:
|
371
|
+
raise AssertionError(
|
372
|
+
f"Resource could not be retrieved after failed deletion. "
|
373
|
+
f"Url was {request_values.url}, status_code was {get_response.status_code}"
|
374
|
+
)
|
375
|
+
|
376
|
+
@keyword
|
377
|
+
def validate_response(
|
378
|
+
self,
|
379
|
+
path: str,
|
380
|
+
response: Response,
|
381
|
+
original_data: Optional[Dict[str, Any]] = None,
|
382
|
+
) -> None:
|
383
|
+
"""
|
384
|
+
Validate the `response` by performing the following validations:
|
385
|
+
- validate the `response` against the openapi schema for the `endpoint`
|
386
|
+
- validate that the response does not contain extra properties
|
387
|
+
- validate that a href, if present, refers to the correct resource
|
388
|
+
- validate that the value for a property that is in the response is equal to
|
389
|
+
the property value that was send
|
390
|
+
- validate that no `original_data` is preserved when performing a PUT operation
|
391
|
+
- validate that a PATCH operation only updates the provided properties
|
392
|
+
"""
|
393
|
+
if response.status_code == 204:
|
394
|
+
assert not response.content
|
395
|
+
return None
|
396
|
+
|
397
|
+
try:
|
398
|
+
self._validate_response_against_spec(response)
|
399
|
+
except OpenAPIError:
|
400
|
+
raise Failure("Response did not pass schema validation.")
|
401
|
+
|
402
|
+
request_method = response.request.method
|
403
|
+
if request_method is None:
|
404
|
+
logger.warning(
|
405
|
+
f"Could not validate response for path {path}; no method found "
|
406
|
+
f"on the request property of the provided response."
|
407
|
+
)
|
408
|
+
return None
|
409
|
+
|
410
|
+
response_spec = self._get_response_spec(
|
411
|
+
path=path,
|
412
|
+
method=request_method,
|
413
|
+
status_code=response.status_code,
|
414
|
+
)
|
415
|
+
|
416
|
+
content_type_from_response = response.headers.get("Content-Type", "unknown")
|
417
|
+
mime_type_from_response, _, _ = content_type_from_response.partition(";")
|
418
|
+
|
419
|
+
if not response_spec.get("content"):
|
420
|
+
logger.warning(
|
421
|
+
"The response cannot be validated: 'content' not specified in the OAS."
|
422
|
+
)
|
423
|
+
return None
|
424
|
+
|
425
|
+
# multiple content types can be specified in the OAS
|
426
|
+
content_types = list(response_spec["content"].keys())
|
427
|
+
supported_types = [
|
428
|
+
ct for ct in content_types if ct.partition(";")[0].endswith("json")
|
429
|
+
]
|
430
|
+
if not supported_types:
|
431
|
+
raise NotImplementedError(
|
432
|
+
f"The content_types '{content_types}' are not supported. "
|
433
|
+
f"Only json types are currently supported."
|
434
|
+
)
|
435
|
+
content_type = supported_types[0]
|
436
|
+
mime_type = content_type.partition(";")[0]
|
437
|
+
|
438
|
+
if mime_type != mime_type_from_response:
|
439
|
+
raise ValueError(
|
440
|
+
f"Content-Type '{content_type_from_response}' of the response "
|
441
|
+
f"does not match '{mime_type}' as specified in the OpenAPI document."
|
442
|
+
)
|
443
|
+
|
444
|
+
json_response = response.json()
|
445
|
+
response_schema = resolve_schema(
|
446
|
+
response_spec["content"][content_type]["schema"]
|
447
|
+
)
|
448
|
+
if list_item_schema := response_schema.get("items"):
|
449
|
+
if not isinstance(json_response, list):
|
450
|
+
raise AssertionError(
|
451
|
+
f"Response schema violation: the schema specifies an array as "
|
452
|
+
f"response type but the response was of type {type(json_response)}."
|
453
|
+
)
|
454
|
+
type_of_list_items = list_item_schema.get("type")
|
455
|
+
if type_of_list_items == "object":
|
456
|
+
for resource in json_response:
|
457
|
+
run_keyword(
|
458
|
+
"validate_resource_properties", resource, list_item_schema
|
459
|
+
)
|
460
|
+
else:
|
461
|
+
for item in json_response:
|
462
|
+
self._validate_value_type(
|
463
|
+
value=item, expected_type=type_of_list_items
|
464
|
+
)
|
465
|
+
# no further validation; value validation of individual resources should
|
466
|
+
# be performed on the endpoints for the specific resource
|
467
|
+
return None
|
468
|
+
|
469
|
+
run_keyword("validate_resource_properties", json_response, response_schema)
|
470
|
+
# ensure the href is valid if present in the response
|
471
|
+
if href := json_response.get("href"):
|
472
|
+
self._assert_href_is_valid(href, json_response)
|
473
|
+
# every property that was sucessfully send and that is in the response
|
474
|
+
# schema must have the value that was send
|
475
|
+
if response.ok and response.request.method in ["POST", "PUT", "PATCH"]:
|
476
|
+
run_keyword("validate_send_response", response, original_data)
|
477
|
+
return None
|
478
|
+
|
479
|
+
def _assert_href_is_valid(self, href: str, json_response: Dict[str, Any]) -> None:
|
480
|
+
url = f"{self.origin}{href}"
|
481
|
+
path = url.replace(self.base_url, "")
|
482
|
+
request_data = self.get_request_data(endpoint=path, method="GET")
|
483
|
+
params = request_data.params
|
484
|
+
headers = request_data.headers
|
485
|
+
get_response = run_keyword("authorized_request", url, "GET", params, headers)
|
486
|
+
assert (
|
487
|
+
get_response.json() == json_response
|
488
|
+
), f"{get_response.json()} not equal to original {json_response}"
|
489
|
+
|
490
|
+
def _validate_response_against_spec(self, response: Response) -> None:
|
491
|
+
try:
|
492
|
+
self.validate_response_vs_spec(
|
493
|
+
request=RequestsOpenAPIRequest(response.request),
|
494
|
+
response=RequestsOpenAPIResponse(response),
|
495
|
+
)
|
496
|
+
except InvalidData as exception:
|
497
|
+
errors: List[InvalidSchemaValue] = exception.__cause__
|
498
|
+
validation_errors: Optional[List[ValidationError]] = getattr(
|
499
|
+
errors, "schema_errors", None
|
500
|
+
)
|
501
|
+
if validation_errors:
|
502
|
+
error_message = "\n".join(
|
503
|
+
[
|
504
|
+
f"{list(error.schema_path)}: {error.message}"
|
505
|
+
for error in validation_errors
|
506
|
+
]
|
507
|
+
)
|
508
|
+
else:
|
509
|
+
error_message = str(exception)
|
510
|
+
|
511
|
+
if response.status_code == self.invalid_property_default_response:
|
512
|
+
logger.debug(error_message)
|
513
|
+
return
|
514
|
+
if self.response_validation == ValidationLevel.STRICT:
|
515
|
+
logger.error(error_message)
|
516
|
+
raise exception
|
517
|
+
if self.response_validation == ValidationLevel.WARN:
|
518
|
+
logger.warning(error_message)
|
519
|
+
elif self.response_validation == ValidationLevel.INFO:
|
520
|
+
logger.info(error_message)
|
521
|
+
|
522
|
+
@keyword
|
523
|
+
def validate_resource_properties(
|
524
|
+
self, resource: Dict[str, Any], schema: Dict[str, Any]
|
525
|
+
) -> None:
|
526
|
+
"""
|
527
|
+
Validate that the `resource` does not contain any properties that are not
|
528
|
+
defined in the `schema_properties`.
|
529
|
+
"""
|
530
|
+
schema_properties = schema.get("properties", {})
|
531
|
+
property_names_from_schema = set(schema_properties.keys())
|
532
|
+
property_names_in_resource = set(resource.keys())
|
533
|
+
|
534
|
+
if property_names_from_schema != property_names_in_resource:
|
535
|
+
# The additionalProperties property determines whether properties with
|
536
|
+
# unspecified names are allowed. This property can be boolean or an object
|
537
|
+
# (dict) that specifies the type of any additional properties.
|
538
|
+
additional_properties = schema.get("additionalProperties", True)
|
539
|
+
if isinstance(additional_properties, bool):
|
540
|
+
allow_additional_properties = additional_properties
|
541
|
+
allowed_additional_properties_type = None
|
542
|
+
else:
|
543
|
+
allow_additional_properties = True
|
544
|
+
allowed_additional_properties_type = additional_properties["type"]
|
545
|
+
|
546
|
+
extra_property_names = property_names_in_resource.difference(
|
547
|
+
property_names_from_schema
|
548
|
+
)
|
549
|
+
if allow_additional_properties:
|
550
|
+
# If a type is defined for extra properties, validate them
|
551
|
+
if allowed_additional_properties_type:
|
552
|
+
extra_properties = {
|
553
|
+
key: value
|
554
|
+
for key, value in resource.items()
|
555
|
+
if key in extra_property_names
|
556
|
+
}
|
557
|
+
self._validate_type_of_extra_properties(
|
558
|
+
extra_properties=extra_properties,
|
559
|
+
expected_type=allowed_additional_properties_type,
|
560
|
+
)
|
561
|
+
# If allowed, validation should not fail on extra properties
|
562
|
+
extra_property_names = set()
|
563
|
+
|
564
|
+
required_properties = set(schema.get("required", []))
|
565
|
+
missing_properties = required_properties.difference(
|
566
|
+
property_names_in_resource
|
567
|
+
)
|
568
|
+
|
569
|
+
if extra_property_names or missing_properties:
|
570
|
+
extra = (
|
571
|
+
f"\n\tExtra properties in response: {extra_property_names}"
|
572
|
+
if extra_property_names
|
573
|
+
else ""
|
574
|
+
)
|
575
|
+
missing = (
|
576
|
+
f"\n\tRequired properties missing in response: {missing_properties}"
|
577
|
+
if missing_properties
|
578
|
+
else ""
|
579
|
+
)
|
580
|
+
raise AssertionError(
|
581
|
+
f"Response schema violation: the response contains properties that are "
|
582
|
+
f"not specified in the schema or does not contain properties that are "
|
583
|
+
f"required according to the schema."
|
584
|
+
f"\n\tReceived in the response: {property_names_in_resource}"
|
585
|
+
f"\n\tDefined in the schema: {property_names_from_schema}"
|
586
|
+
f"{extra}{missing}"
|
587
|
+
)
|
588
|
+
|
589
|
+
@staticmethod
|
590
|
+
def _validate_value_type(value: Any, expected_type: str) -> None:
|
591
|
+
type_mapping = {
|
592
|
+
"string": str,
|
593
|
+
"number": float,
|
594
|
+
"integer": int,
|
595
|
+
"boolean": bool,
|
596
|
+
"array": list,
|
597
|
+
"object": dict,
|
598
|
+
}
|
599
|
+
python_type = type_mapping.get(expected_type, None)
|
600
|
+
if python_type is None:
|
601
|
+
raise AssertionError(
|
602
|
+
f"Validation of type '{expected_type}' is not supported."
|
603
|
+
)
|
604
|
+
if not isinstance(value, python_type):
|
605
|
+
raise AssertionError(f"{value} is not of type {expected_type}")
|
606
|
+
|
607
|
+
@staticmethod
|
608
|
+
def _validate_type_of_extra_properties(
|
609
|
+
extra_properties: Dict[str, Any], expected_type: str
|
610
|
+
) -> None:
|
611
|
+
type_mapping = {
|
612
|
+
"string": str,
|
613
|
+
"number": float,
|
614
|
+
"integer": int,
|
615
|
+
"boolean": bool,
|
616
|
+
"array": list,
|
617
|
+
"object": dict,
|
618
|
+
}
|
619
|
+
|
620
|
+
python_type = type_mapping.get(expected_type, None)
|
621
|
+
if python_type is None:
|
622
|
+
logger.warning(
|
623
|
+
f"Additonal properties were not validated: "
|
624
|
+
f"type '{expected_type}' is not supported."
|
625
|
+
)
|
626
|
+
return
|
627
|
+
|
628
|
+
invalid_extra_properties = {
|
629
|
+
key: value
|
630
|
+
for key, value in extra_properties.items()
|
631
|
+
if not isinstance(value, python_type)
|
632
|
+
}
|
633
|
+
if invalid_extra_properties:
|
634
|
+
raise AssertionError(
|
635
|
+
f"Response contains invalid additionalProperties: "
|
636
|
+
f"{invalid_extra_properties} are not of type {expected_type}."
|
637
|
+
)
|
638
|
+
|
639
|
+
@staticmethod
|
640
|
+
@keyword
|
641
|
+
def validate_send_response(
|
642
|
+
response: Response, original_data: Optional[Dict[str, Any]] = None
|
643
|
+
) -> None:
|
644
|
+
"""
|
645
|
+
Validate that each property that was send that is in the response has the value
|
646
|
+
that was send.
|
647
|
+
In case a PATCH request, validate that only the properties that were patched
|
648
|
+
have changed and that other properties are still at their pre-patch values.
|
649
|
+
"""
|
650
|
+
|
651
|
+
def validate_list_response(
|
652
|
+
send_list: List[Any], received_list: List[Any]
|
653
|
+
) -> None:
|
654
|
+
for item in send_list:
|
655
|
+
if item not in received_list:
|
656
|
+
raise AssertionError(
|
657
|
+
f"Received value '{received_list}' does "
|
658
|
+
f"not contain '{item}' in the {response.request.method} request."
|
659
|
+
f"\nSend: {_json.dumps(send_json, indent=4, sort_keys=True)}"
|
660
|
+
f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
|
661
|
+
)
|
662
|
+
|
663
|
+
def validate_dict_response(
|
664
|
+
send_dict: Dict[str, Any], received_dict: Dict[str, Any]
|
665
|
+
) -> None:
|
666
|
+
for send_property_name, send_property_value in send_dict.items():
|
667
|
+
# sometimes, a property in the request is not in the response, e.g. a password
|
668
|
+
if send_property_name not in received_dict.keys():
|
669
|
+
continue
|
670
|
+
if send_property_value is not None:
|
671
|
+
# if a None value is send, the target property should be cleared or
|
672
|
+
# reverted to the default value (which cannot be specified in the
|
673
|
+
# openapi document)
|
674
|
+
received_value = received_dict[send_property_name]
|
675
|
+
# In case of lists / arrays, the send values are often appended to
|
676
|
+
# existing data
|
677
|
+
if isinstance(received_value, list):
|
678
|
+
validate_list_response(
|
679
|
+
send_list=send_property_value, received_list=received_value
|
680
|
+
)
|
681
|
+
continue
|
682
|
+
|
683
|
+
# when dealing with objects, we'll need to iterate the properties
|
684
|
+
if isinstance(received_value, dict):
|
685
|
+
validate_dict_response(
|
686
|
+
send_dict=send_property_value, received_dict=received_value
|
687
|
+
)
|
688
|
+
continue
|
689
|
+
|
690
|
+
assert received_value == send_property_value, (
|
691
|
+
f"Received value for {send_property_name} '{received_value}' does not "
|
692
|
+
f"match '{send_property_value}' in the {response.request.method} request."
|
693
|
+
f"\nSend: {_json.dumps(send_json, indent=4, sort_keys=True)}"
|
694
|
+
f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
|
695
|
+
)
|
696
|
+
|
697
|
+
if response.request.body is None:
|
698
|
+
logger.warning(
|
699
|
+
"Could not validate send response; the body of the request property "
|
700
|
+
"on the provided response was None."
|
701
|
+
)
|
702
|
+
return None
|
703
|
+
if isinstance(response.request.body, bytes):
|
704
|
+
send_json = _json.loads(response.request.body.decode("UTF-8"))
|
705
|
+
else:
|
706
|
+
send_json = _json.loads(response.request.body)
|
707
|
+
|
708
|
+
response_data = response.json()
|
709
|
+
# POST on /resource_type/{id}/array_item/ will return the updated {id} resource
|
710
|
+
# instead of a newly created resource. In this case, the send_json must be
|
711
|
+
# in the array of the 'array_item' property on {id}
|
712
|
+
send_path: str = response.request.path_url
|
713
|
+
response_path = response_data.get("href", None)
|
714
|
+
if response_path and send_path not in response_path:
|
715
|
+
property_to_check = send_path.replace(response_path, "")[1:]
|
716
|
+
if response_data.get(property_to_check) and isinstance(
|
717
|
+
response_data[property_to_check], list
|
718
|
+
):
|
719
|
+
item_list: List[Dict[str, Any]] = response_data[property_to_check]
|
720
|
+
# Use the (mandatory) id to get the POSTed resource from the list
|
721
|
+
[response_data] = [
|
722
|
+
item for item in item_list if item["id"] == send_json["id"]
|
723
|
+
]
|
724
|
+
|
725
|
+
# incoming arguments are dictionaries, so they can be validated as such
|
726
|
+
validate_dict_response(send_dict=send_json, received_dict=response_data)
|
727
|
+
|
728
|
+
# In case of PATCH requests, ensure that only send properties have changed
|
729
|
+
if original_data:
|
730
|
+
for send_property_name, send_value in original_data.items():
|
731
|
+
if send_property_name not in send_json.keys():
|
732
|
+
assert send_value == response_data[send_property_name], (
|
733
|
+
f"Received value for {send_property_name} '{response_data[send_property_name]}' does not "
|
734
|
+
f"match '{send_value}' in the pre-patch data"
|
735
|
+
f"\nPre-patch: {_json.dumps(original_data, indent=4, sort_keys=True)}"
|
736
|
+
f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
|
737
|
+
)
|
738
|
+
return None
|
739
|
+
|
740
|
+
def _get_response_spec(
|
741
|
+
self, path: str, method: str, status_code: int
|
742
|
+
) -> Dict[str, Any]:
|
743
|
+
method = method.lower()
|
744
|
+
status = str(status_code)
|
745
|
+
spec: Dict[str, Any] = {**self.openapi_spec}["paths"][path][method][
|
746
|
+
"responses"
|
747
|
+
][status]
|
748
|
+
return spec
|