robotframework-openapitools 0.1.1__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.
@@ -1,743 +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
- json_data = request_data.dto.as_dict()
176
- # when patching, get the original data to check only patched data has changed
177
- if method == "PATCH":
178
- original_data = self.get_original_data(url=url)
179
- # in case of a status code indicating an error, ensure the error occurs
180
- if status_code >= 400:
181
- invalidation_keyword_data = {
182
- "get_invalid_json_data": [
183
- "get_invalid_json_data",
184
- url,
185
- method,
186
- status_code,
187
- request_data,
188
- ],
189
- "get_invalidated_parameters": [
190
- "get_invalidated_parameters",
191
- status_code,
192
- request_data,
193
- ],
194
- }
195
- invalidation_keywords = []
196
-
197
- if request_data.dto.get_relations_for_error_code(status_code):
198
- invalidation_keywords.append("get_invalid_json_data")
199
- if request_data.dto.get_parameter_relations_for_error_code(status_code):
200
- invalidation_keywords.append("get_invalidated_parameters")
201
- if invalidation_keywords:
202
- if (
203
- invalidation_keyword := choice(invalidation_keywords)
204
- ) == "get_invalid_json_data":
205
- json_data = run_keyword(
206
- *invalidation_keyword_data[invalidation_keyword]
207
- )
208
- else:
209
- params, headers = run_keyword(
210
- *invalidation_keyword_data[invalidation_keyword]
211
- )
212
- # if there are no relations to invalide and the status_code is the default
213
- # response_code for invalid properties, invalidate properties instead
214
- elif status_code == self.invalid_property_default_response:
215
- if (
216
- request_data.params_that_can_be_invalidated
217
- or request_data.headers_that_can_be_invalidated
218
- ):
219
- params, headers = run_keyword(
220
- *invalidation_keyword_data["get_invalidated_parameters"]
221
- )
222
- if request_data.dto_schema:
223
- json_data = run_keyword(
224
- *invalidation_keyword_data["get_invalid_json_data"]
225
- )
226
- elif request_data.dto_schema:
227
- json_data = run_keyword(
228
- *invalidation_keyword_data["get_invalid_json_data"]
229
- )
230
- else:
231
- raise SkipExecution(
232
- "No properties or parameters can be invalidated."
233
- )
234
- else:
235
- raise AssertionError(
236
- f"No Dto mapping found to cause status_code {status_code}."
237
- )
238
- run_keyword(
239
- "perform_validated_request",
240
- path,
241
- status_code,
242
- RequestValues(
243
- url=url,
244
- method=method,
245
- params=params,
246
- headers=headers,
247
- json_data=json_data,
248
- ),
249
- original_data,
250
- )
251
- if status_code < 300 and (
252
- request_data.has_optional_properties
253
- or request_data.has_optional_params
254
- or request_data.has_optional_headers
255
- ):
256
- logger.info("Performing request without optional properties and parameters")
257
- url = run_keyword("get_valid_url", path, method)
258
- request_data = self.get_request_data(method=method, endpoint=path)
259
- params = request_data.get_required_params()
260
- headers = request_data.get_required_headers()
261
- json_data = request_data.get_required_properties_dict()
262
- original_data = None
263
- if method == "PATCH":
264
- original_data = self.get_original_data(url=url)
265
- run_keyword(
266
- "perform_validated_request",
267
- path,
268
- status_code,
269
- RequestValues(
270
- url=url,
271
- method=method,
272
- params=params,
273
- headers=headers,
274
- json_data=json_data,
275
- ),
276
- original_data,
277
- )
278
-
279
- def get_original_data(self, url: str) -> Optional[Dict[str, Any]]:
280
- """
281
- Attempt to GET the current data for the given url and return it.
282
-
283
- If the GET request fails, None is returned.
284
- """
285
- original_data = None
286
- path = self.get_parameterized_endpoint_from_url(url)
287
- get_request_data = self.get_request_data(endpoint=path, method="GET")
288
- get_params = get_request_data.params
289
- get_headers = get_request_data.headers
290
- response: Response = run_keyword(
291
- "authorized_request", url, "GET", get_params, get_headers
292
- )
293
- if response.ok:
294
- original_data = response.json()
295
- return original_data
296
-
297
- @keyword
298
- def perform_validated_request(
299
- self,
300
- path: str,
301
- status_code: int,
302
- request_values: RequestValues,
303
- original_data: Optional[Dict[str, Any]] = None,
304
- ) -> None:
305
- """
306
- This keyword first calls the Authorized Request keyword, then the Validate
307
- Response keyword and finally validates, for `DELETE` operations, whether
308
- the target resource was indeed deleted (OK response) or not (error responses).
309
- """
310
- response = run_keyword(
311
- "authorized_request",
312
- request_values.url,
313
- request_values.method,
314
- request_values.params,
315
- request_values.headers,
316
- request_values.json_data,
317
- )
318
- if response.status_code != status_code:
319
- try:
320
- response_json = response.json()
321
- except Exception as _: # pylint: disable=broad-except
322
- logger.info(
323
- f"Failed to get json content from response. "
324
- f"Response text was: {response.text}"
325
- )
326
- response_json = {}
327
- if not response.ok:
328
- if description := response_json.get("detail"):
329
- pass
330
- else:
331
- description = response_json.get(
332
- "message", "response contains no message or detail."
333
- )
334
- logger.error(f"{response.reason}: {description}")
335
-
336
- logger.debug(
337
- f"\nSend: {_json.dumps(request_values.json_data, indent=4, sort_keys=True)}"
338
- f"\nGot: {_json.dumps(response_json, indent=4, sort_keys=True)}"
339
- )
340
- raise AssertionError(
341
- f"Response status_code {response.status_code} was not {status_code}"
342
- )
343
-
344
- run_keyword("validate_response", path, response, original_data)
345
-
346
- if request_values.method == "DELETE":
347
- get_request_data = self.get_request_data(endpoint=path, method="GET")
348
- get_params = get_request_data.params
349
- get_headers = get_request_data.headers
350
- get_response = run_keyword(
351
- "authorized_request", request_values.url, "GET", get_params, get_headers
352
- )
353
- if response.ok:
354
- if get_response.ok:
355
- raise AssertionError(
356
- f"Resource still exists after deletion. Url was {request_values.url}"
357
- )
358
- # if the path supports GET, 404 is expected, if not 405 is expected
359
- if get_response.status_code not in [404, 405]:
360
- logger.warning(
361
- f"Unexpected response after deleting resource: Status_code "
362
- f"{get_response.status_code} was received after trying to get {request_values.url} "
363
- f"after sucessfully deleting it."
364
- )
365
- elif not get_response.ok:
366
- raise AssertionError(
367
- f"Resource could not be retrieved after failed deletion. "
368
- f"Url was {request_values.url}, status_code was {get_response.status_code}"
369
- )
370
-
371
- @keyword
372
- def validate_response(
373
- self,
374
- path: str,
375
- response: Response,
376
- original_data: Optional[Dict[str, Any]] = None,
377
- ) -> None:
378
- """
379
- Validate the `response` by performing the following validations:
380
- - validate the `response` against the openapi schema for the `endpoint`
381
- - validate that the response does not contain extra properties
382
- - validate that a href, if present, refers to the correct resource
383
- - validate that the value for a property that is in the response is equal to
384
- the property value that was send
385
- - validate that no `original_data` is preserved when performing a PUT operation
386
- - validate that a PATCH operation only updates the provided properties
387
- """
388
- if response.status_code == 204:
389
- assert not response.content
390
- return None
391
-
392
- try:
393
- self._validate_response_against_spec(response)
394
- except OpenAPIError:
395
- raise Failure("Response did not pass schema validation.")
396
-
397
- request_method = response.request.method
398
- if request_method is None:
399
- logger.warning(
400
- f"Could not validate response for path {path}; no method found "
401
- f"on the request property of the provided response."
402
- )
403
- return None
404
-
405
- response_spec = self._get_response_spec(
406
- path=path,
407
- method=request_method,
408
- status_code=response.status_code,
409
- )
410
-
411
- content_type_from_response = response.headers.get("Content-Type", "unknown")
412
- mime_type_from_response, _, _ = content_type_from_response.partition(";")
413
-
414
- if not response_spec.get("content"):
415
- logger.warning(
416
- "The response cannot be validated: 'content' not specified in the OAS."
417
- )
418
- return None
419
-
420
- # multiple content types can be specified in the OAS
421
- content_types = list(response_spec["content"].keys())
422
- supported_types = [
423
- ct for ct in content_types if ct.partition(";")[0].endswith("json")
424
- ]
425
- if not supported_types:
426
- raise NotImplementedError(
427
- f"The content_types '{content_types}' are not supported. "
428
- f"Only json types are currently supported."
429
- )
430
- content_type = supported_types[0]
431
- mime_type = content_type.partition(";")[0]
432
-
433
- if mime_type != mime_type_from_response:
434
- raise ValueError(
435
- f"Content-Type '{content_type_from_response}' of the response "
436
- f"does not match '{mime_type}' as specified in the OpenAPI document."
437
- )
438
-
439
- json_response = response.json()
440
- response_schema = resolve_schema(
441
- response_spec["content"][content_type]["schema"]
442
- )
443
- if list_item_schema := response_schema.get("items"):
444
- if not isinstance(json_response, list):
445
- raise AssertionError(
446
- f"Response schema violation: the schema specifies an array as "
447
- f"response type but the response was of type {type(json_response)}."
448
- )
449
- type_of_list_items = list_item_schema.get("type")
450
- if type_of_list_items == "object":
451
- for resource in json_response:
452
- run_keyword(
453
- "validate_resource_properties", resource, list_item_schema
454
- )
455
- else:
456
- for item in json_response:
457
- self._validate_value_type(
458
- value=item, expected_type=type_of_list_items
459
- )
460
- # no further validation; value validation of individual resources should
461
- # be performed on the endpoints for the specific resource
462
- return None
463
-
464
- run_keyword("validate_resource_properties", json_response, response_schema)
465
- # ensure the href is valid if present in the response
466
- if href := json_response.get("href"):
467
- self._assert_href_is_valid(href, json_response)
468
- # every property that was sucessfully send and that is in the response
469
- # schema must have the value that was send
470
- if response.ok and response.request.method in ["POST", "PUT", "PATCH"]:
471
- run_keyword("validate_send_response", response, original_data)
472
- return None
473
-
474
- def _assert_href_is_valid(self, href: str, json_response: Dict[str, Any]) -> None:
475
- url = f"{self.origin}{href}"
476
- path = url.replace(self.base_url, "")
477
- request_data = self.get_request_data(endpoint=path, method="GET")
478
- params = request_data.params
479
- headers = request_data.headers
480
- get_response = run_keyword("authorized_request", url, "GET", params, headers)
481
- assert (
482
- get_response.json() == json_response
483
- ), f"{get_response.json()} not equal to original {json_response}"
484
-
485
- def _validate_response_against_spec(self, response: Response) -> None:
486
- try:
487
- self.validate_response_vs_spec(
488
- request=RequestsOpenAPIRequest(response.request),
489
- response=RequestsOpenAPIResponse(response),
490
- )
491
- except InvalidData as exception:
492
- errors: List[InvalidSchemaValue] = exception.__cause__
493
- validation_errors: Optional[List[ValidationError]] = getattr(
494
- errors, "schema_errors", None
495
- )
496
- if validation_errors:
497
- error_message = "\n".join(
498
- [
499
- f"{list(error.schema_path)}: {error.message}"
500
- for error in validation_errors
501
- ]
502
- )
503
- else:
504
- error_message = str(exception)
505
-
506
- if response.status_code == self.invalid_property_default_response:
507
- logger.debug(error_message)
508
- return
509
- if self.response_validation == ValidationLevel.STRICT:
510
- logger.error(error_message)
511
- raise exception
512
- if self.response_validation == ValidationLevel.WARN:
513
- logger.warning(error_message)
514
- elif self.response_validation == ValidationLevel.INFO:
515
- logger.info(error_message)
516
-
517
- @keyword
518
- def validate_resource_properties(
519
- self, resource: Dict[str, Any], schema: Dict[str, Any]
520
- ) -> None:
521
- """
522
- Validate that the `resource` does not contain any properties that are not
523
- defined in the `schema_properties`.
524
- """
525
- schema_properties = schema.get("properties", {})
526
- property_names_from_schema = set(schema_properties.keys())
527
- property_names_in_resource = set(resource.keys())
528
-
529
- if property_names_from_schema != property_names_in_resource:
530
- # The additionalProperties property determines whether properties with
531
- # unspecified names are allowed. This property can be boolean or an object
532
- # (dict) that specifies the type of any additional properties.
533
- additional_properties = schema.get("additionalProperties", True)
534
- if isinstance(additional_properties, bool):
535
- allow_additional_properties = additional_properties
536
- allowed_additional_properties_type = None
537
- else:
538
- allow_additional_properties = True
539
- allowed_additional_properties_type = additional_properties["type"]
540
-
541
- extra_property_names = property_names_in_resource.difference(
542
- property_names_from_schema
543
- )
544
- if allow_additional_properties:
545
- # If a type is defined for extra properties, validate them
546
- if allowed_additional_properties_type:
547
- extra_properties = {
548
- key: value
549
- for key, value in resource.items()
550
- if key in extra_property_names
551
- }
552
- self._validate_type_of_extra_properties(
553
- extra_properties=extra_properties,
554
- expected_type=allowed_additional_properties_type,
555
- )
556
- # If allowed, validation should not fail on extra properties
557
- extra_property_names = set()
558
-
559
- required_properties = set(schema.get("required", []))
560
- missing_properties = required_properties.difference(
561
- property_names_in_resource
562
- )
563
-
564
- if extra_property_names or missing_properties:
565
- extra = (
566
- f"\n\tExtra properties in response: {extra_property_names}"
567
- if extra_property_names
568
- else ""
569
- )
570
- missing = (
571
- f"\n\tRequired properties missing in response: {missing_properties}"
572
- if missing_properties
573
- else ""
574
- )
575
- raise AssertionError(
576
- f"Response schema violation: the response contains properties that are "
577
- f"not specified in the schema or does not contain properties that are "
578
- f"required according to the schema."
579
- f"\n\tReceived in the response: {property_names_in_resource}"
580
- f"\n\tDefined in the schema: {property_names_from_schema}"
581
- f"{extra}{missing}"
582
- )
583
-
584
- @staticmethod
585
- def _validate_value_type(value: Any, expected_type: str) -> None:
586
- type_mapping = {
587
- "string": str,
588
- "number": float,
589
- "integer": int,
590
- "boolean": bool,
591
- "array": list,
592
- "object": dict,
593
- }
594
- python_type = type_mapping.get(expected_type, None)
595
- if python_type is None:
596
- raise AssertionError(
597
- f"Validation of type '{expected_type}' is not supported."
598
- )
599
- if not isinstance(value, python_type):
600
- raise AssertionError(f"{value} is not of type {expected_type}")
601
-
602
- @staticmethod
603
- def _validate_type_of_extra_properties(
604
- extra_properties: Dict[str, Any], expected_type: str
605
- ) -> None:
606
- type_mapping = {
607
- "string": str,
608
- "number": float,
609
- "integer": int,
610
- "boolean": bool,
611
- "array": list,
612
- "object": dict,
613
- }
614
-
615
- python_type = type_mapping.get(expected_type, None)
616
- if python_type is None:
617
- logger.warning(
618
- f"Additonal properties were not validated: "
619
- f"type '{expected_type}' is not supported."
620
- )
621
- return
622
-
623
- invalid_extra_properties = {
624
- key: value
625
- for key, value in extra_properties.items()
626
- if not isinstance(value, python_type)
627
- }
628
- if invalid_extra_properties:
629
- raise AssertionError(
630
- f"Response contains invalid additionalProperties: "
631
- f"{invalid_extra_properties} are not of type {expected_type}."
632
- )
633
-
634
- @staticmethod
635
- @keyword
636
- def validate_send_response(
637
- response: Response, original_data: Optional[Dict[str, Any]] = None
638
- ) -> None:
639
- """
640
- Validate that each property that was send that is in the response has the value
641
- that was send.
642
- In case a PATCH request, validate that only the properties that were patched
643
- have changed and that other properties are still at their pre-patch values.
644
- """
645
-
646
- def validate_list_response(
647
- send_list: List[Any], received_list: List[Any]
648
- ) -> None:
649
- for item in send_list:
650
- if item not in received_list:
651
- raise AssertionError(
652
- f"Received value '{received_list}' does "
653
- f"not contain '{item}' in the {response.request.method} request."
654
- f"\nSend: {_json.dumps(send_json, indent=4, sort_keys=True)}"
655
- f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
656
- )
657
-
658
- def validate_dict_response(
659
- send_dict: Dict[str, Any], received_dict: Dict[str, Any]
660
- ) -> None:
661
- for send_property_name, send_property_value in send_dict.items():
662
- # sometimes, a property in the request is not in the response, e.g. a password
663
- if send_property_name not in received_dict.keys():
664
- continue
665
- if send_property_value is not None:
666
- # if a None value is send, the target property should be cleared or
667
- # reverted to the default value (which cannot be specified in the
668
- # openapi document)
669
- received_value = received_dict[send_property_name]
670
- # In case of lists / arrays, the send values are often appended to
671
- # existing data
672
- if isinstance(received_value, list):
673
- validate_list_response(
674
- send_list=send_property_value, received_list=received_value
675
- )
676
- continue
677
-
678
- # when dealing with objects, we'll need to iterate the properties
679
- if isinstance(received_value, dict):
680
- validate_dict_response(
681
- send_dict=send_property_value, received_dict=received_value
682
- )
683
- continue
684
-
685
- assert received_value == send_property_value, (
686
- f"Received value for {send_property_name} '{received_value}' does not "
687
- f"match '{send_property_value}' in the {response.request.method} request."
688
- f"\nSend: {_json.dumps(send_json, indent=4, sort_keys=True)}"
689
- f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
690
- )
691
-
692
- if response.request.body is None:
693
- logger.warning(
694
- "Could not validate send response; the body of the request property "
695
- "on the provided response was None."
696
- )
697
- return None
698
- if isinstance(response.request.body, bytes):
699
- send_json = _json.loads(response.request.body.decode("UTF-8"))
700
- else:
701
- send_json = _json.loads(response.request.body)
702
-
703
- response_data = response.json()
704
- # POST on /resource_type/{id}/array_item/ will return the updated {id} resource
705
- # instead of a newly created resource. In this case, the send_json must be
706
- # in the array of the 'array_item' property on {id}
707
- send_path: str = response.request.path_url
708
- response_path = response_data.get("href", None)
709
- if response_path and send_path not in response_path:
710
- property_to_check = send_path.replace(response_path, "")[1:]
711
- if response_data.get(property_to_check) and isinstance(
712
- response_data[property_to_check], list
713
- ):
714
- item_list: List[Dict[str, Any]] = response_data[property_to_check]
715
- # Use the (mandatory) id to get the POSTed resource from the list
716
- [response_data] = [
717
- item for item in item_list if item["id"] == send_json["id"]
718
- ]
719
-
720
- # incoming arguments are dictionaries, so they can be validated as such
721
- validate_dict_response(send_dict=send_json, received_dict=response_data)
722
-
723
- # In case of PATCH requests, ensure that only send properties have changed
724
- if original_data:
725
- for send_property_name, send_value in original_data.items():
726
- if send_property_name not in send_json.keys():
727
- assert send_value == response_data[send_property_name], (
728
- f"Received value for {send_property_name} '{response_data[send_property_name]}' does not "
729
- f"match '{send_value}' in the pre-patch data"
730
- f"\nPre-patch: {_json.dumps(original_data, indent=4, sort_keys=True)}"
731
- f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
732
- )
733
- return None
734
-
735
- def _get_response_spec(
736
- self, path: str, method: str, status_code: int
737
- ) -> Dict[str, Any]:
738
- method = method.lower()
739
- status = str(status_code)
740
- spec: Dict[str, Any] = {**self.openapi_spec}["paths"][path][method][
741
- "responses"
742
- ][status]
743
- return spec
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