robotframework-openapitools 0.3.0__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. OpenApiDriver/__init__.py +45 -41
  2. OpenApiDriver/openapi_executors.py +83 -49
  3. OpenApiDriver/openapi_reader.py +114 -116
  4. OpenApiDriver/openapidriver.libspec +209 -133
  5. OpenApiDriver/openapidriver.py +31 -296
  6. OpenApiLibCore/__init__.py +39 -13
  7. OpenApiLibCore/annotations.py +10 -0
  8. OpenApiLibCore/data_generation/__init__.py +10 -0
  9. OpenApiLibCore/data_generation/body_data_generation.py +250 -0
  10. OpenApiLibCore/data_generation/data_generation_core.py +233 -0
  11. OpenApiLibCore/data_invalidation.py +294 -0
  12. OpenApiLibCore/dto_base.py +75 -129
  13. OpenApiLibCore/dto_utils.py +125 -85
  14. OpenApiLibCore/localized_faker.py +88 -0
  15. OpenApiLibCore/models.py +723 -0
  16. OpenApiLibCore/oas_cache.py +14 -13
  17. OpenApiLibCore/openapi_libcore.libspec +363 -322
  18. OpenApiLibCore/openapi_libcore.py +388 -1903
  19. OpenApiLibCore/parameter_utils.py +97 -0
  20. OpenApiLibCore/path_functions.py +215 -0
  21. OpenApiLibCore/path_invalidation.py +42 -0
  22. OpenApiLibCore/protocols.py +38 -0
  23. OpenApiLibCore/request_data.py +246 -0
  24. OpenApiLibCore/resource_relations.py +55 -0
  25. OpenApiLibCore/validation.py +380 -0
  26. OpenApiLibCore/value_utils.py +216 -481
  27. openapi_libgen/__init__.py +3 -0
  28. openapi_libgen/command_line.py +75 -0
  29. openapi_libgen/generator.py +82 -0
  30. openapi_libgen/parsing_utils.py +30 -0
  31. openapi_libgen/spec_parser.py +154 -0
  32. openapi_libgen/templates/__init__.jinja +3 -0
  33. openapi_libgen/templates/library.jinja +30 -0
  34. robotframework_openapitools-1.0.0.dist-info/METADATA +249 -0
  35. robotframework_openapitools-1.0.0.dist-info/RECORD +40 -0
  36. {robotframework_openapitools-0.3.0.dist-info → robotframework_openapitools-1.0.0.dist-info}/WHEEL +1 -1
  37. robotframework_openapitools-1.0.0.dist-info/entry_points.txt +3 -0
  38. roboswag/__init__.py +0 -9
  39. roboswag/__main__.py +0 -3
  40. roboswag/auth.py +0 -44
  41. roboswag/cli.py +0 -80
  42. roboswag/core.py +0 -85
  43. roboswag/generate/__init__.py +0 -1
  44. roboswag/generate/generate.py +0 -121
  45. roboswag/generate/models/__init__.py +0 -0
  46. roboswag/generate/models/api.py +0 -219
  47. roboswag/generate/models/definition.py +0 -28
  48. roboswag/generate/models/endpoint.py +0 -68
  49. roboswag/generate/models/parameter.py +0 -25
  50. roboswag/generate/models/response.py +0 -8
  51. roboswag/generate/models/tag.py +0 -16
  52. roboswag/generate/models/utils.py +0 -60
  53. roboswag/generate/templates/api_init.jinja +0 -15
  54. roboswag/generate/templates/models.jinja +0 -7
  55. roboswag/generate/templates/paths.jinja +0 -68
  56. roboswag/logger.py +0 -33
  57. roboswag/validate/__init__.py +0 -6
  58. roboswag/validate/core.py +0 -3
  59. roboswag/validate/schema.py +0 -21
  60. roboswag/validate/text_response.py +0 -14
  61. robotframework_openapitools-0.3.0.dist-info/METADATA +0 -41
  62. robotframework_openapitools-0.3.0.dist-info/RECORD +0 -41
  63. {robotframework_openapitools-0.3.0.dist-info → robotframework_openapitools-1.0.0.dist-info}/LICENSE +0 -0
@@ -0,0 +1,380 @@
1
+ """Module holding the functions related to validation of requests and responses."""
2
+
3
+ import json as _json
4
+ from enum import Enum
5
+ from http import HTTPStatus
6
+ from typing import Any, Mapping
7
+
8
+ from openapi_core.contrib.requests import (
9
+ RequestsOpenAPIRequest,
10
+ RequestsOpenAPIResponse,
11
+ )
12
+ from openapi_core.exceptions import OpenAPIError
13
+ from openapi_core.templating.paths.exceptions import ServerNotFound
14
+ from openapi_core.validation.exceptions import ValidationError
15
+ from openapi_core.validation.response.exceptions import ResponseValidationError
16
+ from requests import Response
17
+ from robot.api import logger
18
+ from robot.api.exceptions import Failure
19
+ from robot.libraries.BuiltIn import BuiltIn
20
+
21
+ from OpenApiLibCore.models import (
22
+ OpenApiObject,
23
+ ResponseObject,
24
+ UnionTypeSchema,
25
+ )
26
+ from OpenApiLibCore.protocols import ResponseValidatorType
27
+ from OpenApiLibCore.request_data import RequestData, RequestValues
28
+
29
+ run_keyword = BuiltIn().run_keyword
30
+
31
+
32
+ class ValidationLevel(str, Enum):
33
+ """The available levels for the response_validation parameter."""
34
+
35
+ DISABLED = "DISABLED"
36
+ INFO = "INFO"
37
+ WARN = "WARN"
38
+ STRICT = "STRICT"
39
+
40
+
41
+ def perform_validated_request(
42
+ path: str,
43
+ status_code: int,
44
+ request_values: RequestValues,
45
+ original_data: Mapping[str, Any],
46
+ ) -> None:
47
+ response = run_keyword(
48
+ "authorized_request",
49
+ request_values.url,
50
+ request_values.method,
51
+ request_values.params,
52
+ request_values.headers,
53
+ request_values.json_data,
54
+ )
55
+ if response.status_code != status_code:
56
+ try:
57
+ response_json = response.json()
58
+ except Exception as _: # pylint: disable=broad-except
59
+ logger.info(
60
+ f"Failed to get json content from response. "
61
+ f"Response text was: {response.text}"
62
+ )
63
+ response_json = {}
64
+ if not response.ok:
65
+ if description := response_json.get("detail"):
66
+ pass
67
+ else:
68
+ description = response_json.get(
69
+ "message", "response contains no message or detail."
70
+ )
71
+ logger.error(f"{response.reason}: {description}")
72
+
73
+ logger.debug(
74
+ f"\nSend: {_json.dumps(request_values.json_data, indent=4, sort_keys=True)}"
75
+ f"\nGot: {_json.dumps(response_json, indent=4, sort_keys=True)}"
76
+ )
77
+ raise AssertionError(
78
+ f"Response status_code {response.status_code} was not {status_code}."
79
+ )
80
+
81
+ run_keyword("validate_response", path, response, original_data)
82
+
83
+ if request_values.method == "DELETE":
84
+ request_data: RequestData = run_keyword("get_request_data", path, "GET")
85
+ get_params = request_data.params
86
+ get_headers = request_data.headers
87
+ get_response = run_keyword(
88
+ "authorized_request", request_values.url, "GET", get_params, get_headers
89
+ )
90
+ if response.ok:
91
+ if get_response.ok:
92
+ raise AssertionError(
93
+ f"Resource still exists after deletion. Url was {request_values.url}."
94
+ )
95
+ # if the path supports GET, 404 is expected, if not 405 is expected
96
+ if get_response.status_code not in [404, 405]:
97
+ logger.warn(
98
+ f"Unexpected response after deleting resource: Status_code "
99
+ f"{get_response.status_code} was received after trying to get "
100
+ f"{request_values.url} after sucessfully deleting it."
101
+ )
102
+ elif not get_response.ok:
103
+ raise AssertionError(
104
+ f"Resource could not be retrieved after failed deletion. "
105
+ f"Url was {request_values.url}, status_code was {get_response.status_code}."
106
+ )
107
+
108
+
109
+ def validate_response(
110
+ path: str,
111
+ response: Response,
112
+ response_validator: ResponseValidatorType,
113
+ server_validation_warning_logged: bool,
114
+ disable_server_validation: bool,
115
+ invalid_property_default_response: int,
116
+ response_validation: str,
117
+ openapi_spec: OpenApiObject,
118
+ original_data: Mapping[str, Any],
119
+ ) -> None:
120
+ if response.status_code == int(HTTPStatus.NO_CONTENT):
121
+ assert not response.content
122
+ return None
123
+
124
+ try:
125
+ _validate_response(
126
+ response=response,
127
+ response_validator=response_validator,
128
+ server_validation_warning_logged=server_validation_warning_logged,
129
+ disable_server_validation=disable_server_validation,
130
+ invalid_property_default_response=invalid_property_default_response,
131
+ response_validation=response_validation,
132
+ )
133
+ except OpenAPIError as exception:
134
+ raise Failure(
135
+ f"Response did not pass schema validation: {exception}"
136
+ ) from exception
137
+
138
+ request_method = response.request.method
139
+ if request_method is None:
140
+ logger.warn(
141
+ f"Could not validate response for path {path}; no method found "
142
+ f"on the request property of the provided response."
143
+ )
144
+ return None
145
+
146
+ response_object = _get_response_object(
147
+ path=path,
148
+ method=request_method,
149
+ status_code=response.status_code,
150
+ openapi_spec=openapi_spec,
151
+ )
152
+
153
+ content_type_from_response = response.headers.get("Content-Type", "unknown")
154
+ mime_type_from_response, _, _ = content_type_from_response.partition(";")
155
+
156
+ if not response_object.content:
157
+ logger.warn(
158
+ "The response cannot be validated: 'content' not specified in the OAS."
159
+ )
160
+ return None
161
+
162
+ # multiple content types can be specified in the OAS
163
+ content_types = list(response_object.content.keys())
164
+ supported_types = [
165
+ ct for ct in content_types if ct.partition(";")[0].endswith("json")
166
+ ]
167
+ if not supported_types:
168
+ raise NotImplementedError(
169
+ f"The content_types '{content_types}' are not supported. "
170
+ f"Only json types are currently supported."
171
+ )
172
+ content_type = supported_types[0]
173
+ mime_type = content_type.partition(";")[0]
174
+
175
+ if mime_type != mime_type_from_response:
176
+ raise ValueError(
177
+ f"Content-Type '{content_type_from_response}' of the response "
178
+ f"does not match '{mime_type}' as specified in the OpenAPI document."
179
+ )
180
+
181
+ json_response = response.json()
182
+ response_schema = response_object.content[content_type].schema_
183
+ # No additional validations if schema is missing or when multiple responses
184
+ # are possible.
185
+ if not response_schema or isinstance(response_schema, UnionTypeSchema):
186
+ return None
187
+
188
+ # ensure the href is valid if the response is an object that contains a href
189
+ if isinstance(json_response, dict):
190
+ if href := json_response.get("href"):
191
+ run_keyword("assert_href_to_resource_is_valid", href, json_response)
192
+
193
+ # every property that was sucessfully send and that is in the response
194
+ # schema must have the value that was send
195
+ if response.ok and response.request.method in ["POST", "PUT", "PATCH"]:
196
+ run_keyword("validate_send_response", response, original_data)
197
+ return None
198
+
199
+
200
+ def assert_href_to_resource_is_valid(
201
+ href: str, origin: str, base_url: str, referenced_resource: dict[str, Any]
202
+ ) -> None:
203
+ url = f"{origin}{href}"
204
+ path = url.replace(base_url, "")
205
+ request_data: RequestData = run_keyword("get_request_data", path, "GET")
206
+ params = request_data.params
207
+ headers = request_data.headers
208
+ get_response = run_keyword("authorized_request", url, "GET", params, headers)
209
+ assert get_response.json() == referenced_resource, (
210
+ f"{get_response.json()} not equal to original {referenced_resource}"
211
+ )
212
+
213
+
214
+ def validate_send_response(
215
+ response: Response,
216
+ original_data: Mapping[str, Any],
217
+ ) -> None:
218
+ def validate_list_response(send_list: list[Any], received_list: list[Any]) -> None:
219
+ for item in send_list:
220
+ if item not in received_list:
221
+ raise AssertionError(
222
+ f"Received value '{received_list}' does "
223
+ f"not contain '{item}' in the {response.request.method} request."
224
+ f"\nSend: {_json.dumps(send_json, indent=4, sort_keys=True)}"
225
+ f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
226
+ )
227
+
228
+ def validate_dict_response(
229
+ send_dict: dict[str, Any], received_dict: dict[str, Any]
230
+ ) -> None:
231
+ for send_property_name, send_property_value in send_dict.items():
232
+ # sometimes, a property in the request is not in the response, e.g. a password
233
+ if send_property_name not in received_dict.keys():
234
+ continue
235
+ if send_property_value is not None:
236
+ # if a None value is send, the target property should be cleared or
237
+ # reverted to the default value (which cannot be specified in the
238
+ # openapi document)
239
+ received_value = received_dict[send_property_name]
240
+ # In case of lists / arrays, the send values are often appended to
241
+ # existing data
242
+ if isinstance(received_value, list):
243
+ validate_list_response(
244
+ send_list=send_property_value, received_list=received_value
245
+ )
246
+ continue
247
+
248
+ # when dealing with objects, we'll need to iterate the properties
249
+ if isinstance(received_value, dict):
250
+ validate_dict_response(
251
+ send_dict=send_property_value, received_dict=received_value
252
+ )
253
+ continue
254
+
255
+ assert received_value == send_property_value, (
256
+ f"Received value for {send_property_name} '{received_value}' does not "
257
+ f"match '{send_property_value}' in the {response.request.method} request."
258
+ f"\nSend: {_json.dumps(send_json, indent=4, sort_keys=True)}"
259
+ f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
260
+ )
261
+
262
+ if response.request.body is None:
263
+ logger.warn(
264
+ "Could not validate send response; the body of the request property "
265
+ "on the provided response was None."
266
+ )
267
+ return None
268
+
269
+ if isinstance(response.request.body, bytes):
270
+ send_json = _json.loads(response.request.body.decode("UTF-8"))
271
+ else:
272
+ send_json = _json.loads(response.request.body)
273
+
274
+ response_data = response.json()
275
+ if not isinstance(response_data, dict):
276
+ logger.info(
277
+ "Could not validate send data against the response; "
278
+ "the received response was not a representation of a resource."
279
+ )
280
+ return None
281
+
282
+ send_path: str = response.request.path_url
283
+ response_path = response_data.get("href", None)
284
+ if response_path and send_path not in response_path:
285
+ property_to_check = send_path.replace(response_path, "")[1:]
286
+ if response_data.get(property_to_check) and isinstance(
287
+ response_data[property_to_check], list
288
+ ):
289
+ item_list: list[dict[str, Any]] = response_data[property_to_check]
290
+ # Use the (mandatory) id to get the POSTed resource from the list
291
+ [response_data] = [
292
+ item for item in item_list if item["id"] == send_json["id"]
293
+ ]
294
+
295
+ # TODO: add support for non-dict bodies
296
+ validate_dict_response(send_dict=send_json, received_dict=response_data)
297
+
298
+ # In case of PATCH requests, ensure that only send properties have changed
299
+ if original_data:
300
+ for send_property_name, send_value in original_data.items():
301
+ if send_property_name not in send_json.keys():
302
+ assert send_value == response_data[send_property_name], (
303
+ f"Received value for {send_property_name} '{response_data[send_property_name]}' does not "
304
+ f"match '{send_value}' in the pre-patch data"
305
+ f"\nPre-patch: {_json.dumps(original_data, indent=4, sort_keys=True)}"
306
+ f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
307
+ )
308
+ return None
309
+
310
+
311
+ def validate_response_using_validator(
312
+ response: Response,
313
+ response_validator: ResponseValidatorType,
314
+ ) -> None:
315
+ openapi_request = RequestsOpenAPIRequest(response.request)
316
+ openapi_response = RequestsOpenAPIResponse(response)
317
+ response_validator(request=openapi_request, response=openapi_response)
318
+
319
+
320
+ def _validate_response(
321
+ response: Response,
322
+ response_validator: ResponseValidatorType,
323
+ server_validation_warning_logged: bool,
324
+ disable_server_validation: bool,
325
+ invalid_property_default_response: int,
326
+ response_validation: str,
327
+ ) -> None:
328
+ try:
329
+ validate_response_using_validator(
330
+ response=response,
331
+ response_validator=response_validator,
332
+ )
333
+ except (ResponseValidationError, ServerNotFound) as exception:
334
+ error: BaseException | None = exception.__cause__
335
+ validation_errors: list[ValidationError] = getattr(error, "schema_errors", [])
336
+ if validation_errors:
337
+ error_message = "\n".join(
338
+ [
339
+ f"{list(getattr(error, 'schema_path', ''))}: {getattr(error, 'message', '')}"
340
+ for error in validation_errors
341
+ ]
342
+ )
343
+ else:
344
+ error_message = str(exception)
345
+
346
+ if isinstance(exception, ServerNotFound):
347
+ if not server_validation_warning_logged:
348
+ logger.warn(
349
+ f"ServerNotFound was raised during response validation. "
350
+ f"Due to this, no full response validation will be performed."
351
+ f"\nThe original error was: {error_message}"
352
+ )
353
+ server_validation_warning_logged = True
354
+ if disable_server_validation:
355
+ return
356
+
357
+ if response.status_code == invalid_property_default_response:
358
+ logger.debug(error_message)
359
+ return
360
+ if response_validation == ValidationLevel.STRICT:
361
+ logger.error(error_message)
362
+ raise exception
363
+ if response_validation == ValidationLevel.WARN:
364
+ logger.warn(error_message)
365
+ elif response_validation == ValidationLevel.INFO:
366
+ logger.info(error_message)
367
+
368
+
369
+ def _get_response_object(
370
+ path: str, method: str, status_code: int, openapi_spec: OpenApiObject
371
+ ) -> ResponseObject:
372
+ method = method.lower()
373
+ status = str(status_code)
374
+ path_item = openapi_spec.paths[path]
375
+ path_operations = path_item.get_operations()
376
+ operation_data = path_operations.get(method)
377
+ if operation_data is None:
378
+ raise ValueError(f"method '{method}' not supported for {path}.")
379
+
380
+ return operation_data.responses[status]