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.
- OpenApiDriver/__init__.py +45 -41
- OpenApiDriver/openapi_executors.py +83 -49
- OpenApiDriver/openapi_reader.py +114 -116
- OpenApiDriver/openapidriver.libspec +209 -133
- OpenApiDriver/openapidriver.py +31 -296
- OpenApiLibCore/__init__.py +39 -13
- OpenApiLibCore/annotations.py +10 -0
- OpenApiLibCore/data_generation/__init__.py +10 -0
- OpenApiLibCore/data_generation/body_data_generation.py +250 -0
- OpenApiLibCore/data_generation/data_generation_core.py +233 -0
- OpenApiLibCore/data_invalidation.py +294 -0
- OpenApiLibCore/dto_base.py +75 -129
- OpenApiLibCore/dto_utils.py +125 -85
- OpenApiLibCore/localized_faker.py +88 -0
- OpenApiLibCore/models.py +723 -0
- OpenApiLibCore/oas_cache.py +14 -13
- OpenApiLibCore/openapi_libcore.libspec +363 -322
- OpenApiLibCore/openapi_libcore.py +388 -1903
- OpenApiLibCore/parameter_utils.py +97 -0
- OpenApiLibCore/path_functions.py +215 -0
- OpenApiLibCore/path_invalidation.py +42 -0
- OpenApiLibCore/protocols.py +38 -0
- OpenApiLibCore/request_data.py +246 -0
- OpenApiLibCore/resource_relations.py +55 -0
- OpenApiLibCore/validation.py +380 -0
- OpenApiLibCore/value_utils.py +216 -481
- openapi_libgen/__init__.py +3 -0
- openapi_libgen/command_line.py +75 -0
- openapi_libgen/generator.py +82 -0
- openapi_libgen/parsing_utils.py +30 -0
- openapi_libgen/spec_parser.py +154 -0
- openapi_libgen/templates/__init__.jinja +3 -0
- openapi_libgen/templates/library.jinja +30 -0
- robotframework_openapitools-1.0.0.dist-info/METADATA +249 -0
- robotframework_openapitools-1.0.0.dist-info/RECORD +40 -0
- {robotframework_openapitools-0.3.0.dist-info → robotframework_openapitools-1.0.0.dist-info}/WHEEL +1 -1
- robotframework_openapitools-1.0.0.dist-info/entry_points.txt +3 -0
- roboswag/__init__.py +0 -9
- roboswag/__main__.py +0 -3
- roboswag/auth.py +0 -44
- roboswag/cli.py +0 -80
- roboswag/core.py +0 -85
- roboswag/generate/__init__.py +0 -1
- roboswag/generate/generate.py +0 -121
- roboswag/generate/models/__init__.py +0 -0
- roboswag/generate/models/api.py +0 -219
- roboswag/generate/models/definition.py +0 -28
- roboswag/generate/models/endpoint.py +0 -68
- roboswag/generate/models/parameter.py +0 -25
- roboswag/generate/models/response.py +0 -8
- roboswag/generate/models/tag.py +0 -16
- roboswag/generate/models/utils.py +0 -60
- roboswag/generate/templates/api_init.jinja +0 -15
- roboswag/generate/templates/models.jinja +0 -7
- roboswag/generate/templates/paths.jinja +0 -68
- roboswag/logger.py +0 -33
- roboswag/validate/__init__.py +0 -6
- roboswag/validate/core.py +0 -3
- roboswag/validate/schema.py +0 -21
- roboswag/validate/text_response.py +0 -14
- robotframework_openapitools-0.3.0.dist-info/METADATA +0 -41
- robotframework_openapitools-0.3.0.dist-info/RECORD +0 -41
- {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]
|