robotframework-openapitools 0.4.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 +78 -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 +67 -130
- 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 +355 -330
- OpenApiLibCore/openapi_libcore.py +385 -1953
- 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.4.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.4.0.dist-info/METADATA +0 -42
- robotframework_openapitools-0.4.0.dist-info/RECORD +0 -41
- {robotframework_openapitools-0.4.0.dist-info → robotframework_openapitools-1.0.0.dist-info}/LICENSE +0 -0
@@ -0,0 +1,97 @@
|
|
1
|
+
"""
|
2
|
+
Module holding the functions to support mapping between Python-safe parameter
|
3
|
+
names and the original names in the parsed OpenApi Specification document.
|
4
|
+
"""
|
5
|
+
|
6
|
+
from typing import Generator
|
7
|
+
|
8
|
+
from OpenApiLibCore.models import ParameterObject, PathItemObject
|
9
|
+
|
10
|
+
PARAMETER_REGISTRY: dict[str, str] = {
|
11
|
+
"body": "body",
|
12
|
+
}
|
13
|
+
|
14
|
+
|
15
|
+
def get_oas_name_from_safe_name(safe_name: str) -> str:
|
16
|
+
oas_name = PARAMETER_REGISTRY.get(safe_name)
|
17
|
+
if not oas_name:
|
18
|
+
raise ValueError(f"No entry for '{safe_name}' registered {PARAMETER_REGISTRY}.")
|
19
|
+
return oas_name
|
20
|
+
|
21
|
+
|
22
|
+
def get_safe_name_for_oas_name(oas_name: str) -> str:
|
23
|
+
if _is_python_safe(oas_name):
|
24
|
+
PARAMETER_REGISTRY[oas_name] = oas_name
|
25
|
+
return oas_name
|
26
|
+
|
27
|
+
safe_name = convert_string_to_python_identifier(oas_name)
|
28
|
+
|
29
|
+
if safe_name not in PARAMETER_REGISTRY:
|
30
|
+
PARAMETER_REGISTRY[safe_name] = oas_name
|
31
|
+
return safe_name
|
32
|
+
|
33
|
+
registered_oas_name = PARAMETER_REGISTRY[safe_name]
|
34
|
+
if registered_oas_name == oas_name:
|
35
|
+
return safe_name
|
36
|
+
# We're dealing with multiple oas_names that convert to the same safe_name.
|
37
|
+
# To resolve this, a more verbose safe_name is generated. This is less user-friendly
|
38
|
+
# but necessary to ensure an one-to-one mapping.
|
39
|
+
verbose_safe_name = convert_string_to_python_identifier(oas_name, verbose=True)
|
40
|
+
if verbose_safe_name not in PARAMETER_REGISTRY:
|
41
|
+
PARAMETER_REGISTRY[verbose_safe_name] = oas_name
|
42
|
+
return verbose_safe_name
|
43
|
+
|
44
|
+
|
45
|
+
def _is_python_safe(name: str) -> bool:
|
46
|
+
return name.isidentifier()
|
47
|
+
|
48
|
+
|
49
|
+
def convert_string_to_python_identifier(string: str, verbose: bool = False) -> str:
|
50
|
+
def _convert_string_to_python_identifier() -> Generator[str, None, None]:
|
51
|
+
string_iterator = iter(string)
|
52
|
+
|
53
|
+
# The first character must be A-z or _
|
54
|
+
first_character = next(string_iterator, "_")
|
55
|
+
if first_character.isalpha() or first_character == "_":
|
56
|
+
yield first_character
|
57
|
+
elif first_character.isnumeric():
|
58
|
+
yield "_" + first_character
|
59
|
+
elif not verbose:
|
60
|
+
yield "_"
|
61
|
+
else:
|
62
|
+
ascii_code = ord(first_character)
|
63
|
+
yield f"_{ascii_code}_"
|
64
|
+
# Further characters must be A-z, 0-9 or _
|
65
|
+
for character in string_iterator:
|
66
|
+
if character.isalnum() or character == "_":
|
67
|
+
yield character
|
68
|
+
elif not verbose:
|
69
|
+
yield "_"
|
70
|
+
else:
|
71
|
+
ascii_code = ord(character)
|
72
|
+
yield f"_{ascii_code}_"
|
73
|
+
|
74
|
+
if _is_python_safe(string):
|
75
|
+
return string
|
76
|
+
|
77
|
+
converted_string = "".join(_convert_string_to_python_identifier())
|
78
|
+
if not _is_python_safe(converted_string):
|
79
|
+
raise ValueError(f"Failed to convert '{string}' to Python identifier.")
|
80
|
+
return converted_string
|
81
|
+
|
82
|
+
|
83
|
+
def register_path_parameters(paths_data: dict[str, PathItemObject]) -> None:
|
84
|
+
def _register_path_parameter(parameter_object: ParameterObject) -> None:
|
85
|
+
if parameter_object.in_ == "path":
|
86
|
+
_ = get_safe_name_for_oas_name(parameter_object.name)
|
87
|
+
|
88
|
+
for path_item in paths_data.values():
|
89
|
+
if parameters := path_item.parameters:
|
90
|
+
for parameter in path_item.parameters:
|
91
|
+
_register_path_parameter(parameter_object=parameter)
|
92
|
+
|
93
|
+
operations = path_item.get_operations()
|
94
|
+
for operation in operations.values():
|
95
|
+
if parameters := operation.parameters:
|
96
|
+
for parameter in parameters:
|
97
|
+
_register_path_parameter(parameter_object=parameter)
|
@@ -0,0 +1,215 @@
|
|
1
|
+
"""Module holding the functions related to paths and urls."""
|
2
|
+
|
3
|
+
import json as _json
|
4
|
+
from itertools import zip_longest
|
5
|
+
from random import choice
|
6
|
+
from typing import Any
|
7
|
+
|
8
|
+
from requests import Response
|
9
|
+
from robot.libraries.BuiltIn import BuiltIn
|
10
|
+
|
11
|
+
from OpenApiLibCore.models import OpenApiObject
|
12
|
+
from OpenApiLibCore.protocols import GetIdPropertyNameType, GetPathDtoClassType
|
13
|
+
from OpenApiLibCore.request_data import RequestData
|
14
|
+
|
15
|
+
run_keyword = BuiltIn().run_keyword
|
16
|
+
|
17
|
+
|
18
|
+
def match_parts(parts: list[str], spec_parts: list[str]) -> bool:
|
19
|
+
for part, spec_part in zip_longest(parts, spec_parts, fillvalue="Filler"):
|
20
|
+
if part == "Filler" or spec_part == "Filler":
|
21
|
+
return False
|
22
|
+
if part != spec_part and not spec_part.startswith("{"):
|
23
|
+
return False
|
24
|
+
return True
|
25
|
+
|
26
|
+
|
27
|
+
def get_parametrized_path(path: str, openapi_spec: OpenApiObject) -> str:
|
28
|
+
path_parts = path.split("/")
|
29
|
+
# if the last part is empty, the path has a trailing `/` that
|
30
|
+
# should be ignored during matching
|
31
|
+
if path_parts[-1] == "":
|
32
|
+
_ = path_parts.pop(-1)
|
33
|
+
|
34
|
+
spec_paths: list[str] = list(openapi_spec.paths.keys())
|
35
|
+
|
36
|
+
candidates: list[str] = []
|
37
|
+
|
38
|
+
for spec_path in spec_paths:
|
39
|
+
spec_path_parts = spec_path.split("/")
|
40
|
+
# ignore trailing `/` the same way as for path_parts
|
41
|
+
if spec_path_parts[-1] == "":
|
42
|
+
_ = spec_path_parts.pop(-1)
|
43
|
+
if match_parts(path_parts, spec_path_parts):
|
44
|
+
candidates.append(spec_path)
|
45
|
+
|
46
|
+
if not candidates:
|
47
|
+
raise ValueError(f"{path} not found in paths section of the OpenAPI document.")
|
48
|
+
|
49
|
+
if len(candidates) == 1:
|
50
|
+
return candidates[0]
|
51
|
+
# Multiple matches can happen in APIs with overloaded paths, e.g.
|
52
|
+
# /users/me
|
53
|
+
# /users/${user_id}
|
54
|
+
# In this case, find the closest (or exact) match
|
55
|
+
exact_match = [c for c in candidates if c == path]
|
56
|
+
if exact_match:
|
57
|
+
return exact_match[0]
|
58
|
+
# TODO: Implement a decision mechanism when real-world examples become available
|
59
|
+
# In the face of ambiguity, refuse the temptation to guess.
|
60
|
+
raise ValueError(f"{path} matched to multiple paths: {candidates}")
|
61
|
+
|
62
|
+
|
63
|
+
def get_valid_url(
|
64
|
+
path: str,
|
65
|
+
base_url: str,
|
66
|
+
get_path_dto_class: GetPathDtoClassType,
|
67
|
+
openapi_spec: OpenApiObject,
|
68
|
+
) -> str:
|
69
|
+
try:
|
70
|
+
# path can be partially resolved or provided by a PathPropertiesConstraint
|
71
|
+
parametrized_path = get_parametrized_path(path=path, openapi_spec=openapi_spec)
|
72
|
+
_ = openapi_spec.paths[parametrized_path]
|
73
|
+
except KeyError:
|
74
|
+
raise ValueError(
|
75
|
+
f"{path} not found in paths section of the OpenAPI document."
|
76
|
+
) from None
|
77
|
+
dto_class = get_path_dto_class(path=path)
|
78
|
+
relations = dto_class.get_path_relations()
|
79
|
+
paths = [p.path for p in relations]
|
80
|
+
if paths:
|
81
|
+
url = f"{base_url}{choice(paths)}"
|
82
|
+
return url
|
83
|
+
path_parts = list(path.split("/"))
|
84
|
+
for index, part in enumerate(path_parts):
|
85
|
+
if part.startswith("{") and part.endswith("}"):
|
86
|
+
type_path_parts = path_parts[slice(index)]
|
87
|
+
type_path = "/".join(type_path_parts)
|
88
|
+
existing_id: str | int | float = run_keyword(
|
89
|
+
"get_valid_id_for_path", type_path
|
90
|
+
)
|
91
|
+
path_parts[index] = str(existing_id)
|
92
|
+
resolved_path = "/".join(path_parts)
|
93
|
+
url = f"{base_url}{resolved_path}"
|
94
|
+
return url
|
95
|
+
|
96
|
+
|
97
|
+
def get_valid_id_for_path(
|
98
|
+
path: str,
|
99
|
+
get_id_property_name: GetIdPropertyNameType,
|
100
|
+
) -> str | int:
|
101
|
+
url: str = run_keyword("get_valid_url", path)
|
102
|
+
# Try to create a new resource to prevent conflicts caused by
|
103
|
+
# operations performed on the same resource by other test cases
|
104
|
+
request_data: RequestData = run_keyword("get_request_data", path, "post")
|
105
|
+
|
106
|
+
response: Response = run_keyword(
|
107
|
+
"authorized_request",
|
108
|
+
url,
|
109
|
+
"post",
|
110
|
+
request_data.get_required_params(),
|
111
|
+
request_data.get_required_headers(),
|
112
|
+
request_data.get_required_properties_dict(),
|
113
|
+
)
|
114
|
+
|
115
|
+
id_property, id_transformer = get_id_property_name(path=path)
|
116
|
+
|
117
|
+
if not response.ok:
|
118
|
+
# If a new resource cannot be created using POST, try to retrieve a
|
119
|
+
# valid id using a GET request.
|
120
|
+
try:
|
121
|
+
valid_id = choice(run_keyword("get_ids_from_url", url))
|
122
|
+
return id_transformer(valid_id)
|
123
|
+
except Exception as exception:
|
124
|
+
raise AssertionError(
|
125
|
+
f"Failed to get a valid id using GET on {url}"
|
126
|
+
) from exception
|
127
|
+
|
128
|
+
response_data = response.json()
|
129
|
+
if prepared_body := response.request.body:
|
130
|
+
if isinstance(prepared_body, bytes):
|
131
|
+
send_json = _json.loads(prepared_body.decode("UTF-8"))
|
132
|
+
else:
|
133
|
+
send_json = _json.loads(prepared_body)
|
134
|
+
else:
|
135
|
+
send_json = None
|
136
|
+
|
137
|
+
# no support for retrieving an id from an array returned on a POST request
|
138
|
+
if isinstance(response_data, list):
|
139
|
+
raise NotImplementedError(
|
140
|
+
f"Unexpected response body for POST request: expected an object but "
|
141
|
+
f"received an array ({response_data})"
|
142
|
+
)
|
143
|
+
|
144
|
+
# POST on /resource_type/{id}/array_item/ will return the updated {id} resource
|
145
|
+
# instead of a newly created resource. In this case, the send_json must be
|
146
|
+
# in the array of the 'array_item' property on {id}
|
147
|
+
send_path: str = response.request.path_url
|
148
|
+
response_href: str = response_data.get("href", "")
|
149
|
+
if response_href and (send_path not in response_href) and send_json:
|
150
|
+
try:
|
151
|
+
property_to_check = send_path.replace(response_href, "")[1:]
|
152
|
+
item_list: list[dict[str, Any]] = response_data[property_to_check]
|
153
|
+
# Use the (mandatory) id to get the POSTed resource from the list
|
154
|
+
[valid_id] = [
|
155
|
+
item[id_property]
|
156
|
+
for item in item_list
|
157
|
+
if item[id_property] == send_json[id_property]
|
158
|
+
]
|
159
|
+
except Exception as exception:
|
160
|
+
raise AssertionError(
|
161
|
+
f"Failed to get a valid id from {response_href}"
|
162
|
+
) from exception
|
163
|
+
else:
|
164
|
+
try:
|
165
|
+
valid_id = response_data[id_property]
|
166
|
+
except KeyError:
|
167
|
+
raise AssertionError(
|
168
|
+
f"Failed to get a valid id from {response_data}"
|
169
|
+
) from None
|
170
|
+
return id_transformer(valid_id)
|
171
|
+
|
172
|
+
|
173
|
+
def get_ids_from_url(
|
174
|
+
url: str,
|
175
|
+
get_id_property_name: GetIdPropertyNameType,
|
176
|
+
) -> list[str]:
|
177
|
+
path: str = run_keyword("get_parameterized_path_from_url", url)
|
178
|
+
request_data: RequestData = run_keyword("get_request_data", path, "get")
|
179
|
+
response = run_keyword(
|
180
|
+
"authorized_request",
|
181
|
+
url,
|
182
|
+
"get",
|
183
|
+
request_data.get_required_params(),
|
184
|
+
request_data.get_required_headers(),
|
185
|
+
)
|
186
|
+
response.raise_for_status()
|
187
|
+
response_data: dict[str, Any] | list[dict[str, Any]] = response.json()
|
188
|
+
|
189
|
+
# determine the property name to use
|
190
|
+
mapping = get_id_property_name(path=path)
|
191
|
+
if isinstance(mapping, str):
|
192
|
+
id_property = mapping
|
193
|
+
else:
|
194
|
+
id_property, _ = mapping
|
195
|
+
|
196
|
+
if isinstance(response_data, list):
|
197
|
+
valid_ids: list[str] = [item[id_property] for item in response_data]
|
198
|
+
return valid_ids
|
199
|
+
# if the response is an object (dict), check if it's hal+json
|
200
|
+
if embedded := response_data.get("_embedded"):
|
201
|
+
# there should be 1 item in the dict that has a value that's a list
|
202
|
+
for value in embedded.values():
|
203
|
+
if isinstance(value, list):
|
204
|
+
valid_ids = [item[id_property] for item in value]
|
205
|
+
return valid_ids
|
206
|
+
if (valid_id := response_data.get(id_property)) is not None:
|
207
|
+
return [valid_id]
|
208
|
+
valid_ids = [item[id_property] for item in response_data["items"]]
|
209
|
+
return valid_ids
|
210
|
+
|
211
|
+
|
212
|
+
def substitute_path_parameters(path: str, substitution_dict: dict[str, str]) -> str:
|
213
|
+
for path_parameter, substitution_value in substitution_dict.items():
|
214
|
+
path = path.replace("{" + path_parameter + "}", str(substitution_value))
|
215
|
+
return path
|
@@ -0,0 +1,42 @@
|
|
1
|
+
"""Module holding functions related to invalidation of paths and urls."""
|
2
|
+
|
3
|
+
from random import choice
|
4
|
+
from uuid import uuid4
|
5
|
+
|
6
|
+
from robot.libraries.BuiltIn import BuiltIn
|
7
|
+
|
8
|
+
from OpenApiLibCore.protocols import GetPathDtoClassType
|
9
|
+
|
10
|
+
run_keyword = BuiltIn().run_keyword
|
11
|
+
|
12
|
+
|
13
|
+
def get_invalidated_url(
|
14
|
+
valid_url: str,
|
15
|
+
path: str,
|
16
|
+
base_url: str,
|
17
|
+
get_path_dto_class: GetPathDtoClassType,
|
18
|
+
expected_status_code: int,
|
19
|
+
) -> str:
|
20
|
+
dto_class = get_path_dto_class(path=path)
|
21
|
+
relations = dto_class.get_path_relations()
|
22
|
+
paths = [
|
23
|
+
p.invalid_value
|
24
|
+
for p in relations
|
25
|
+
if p.invalid_value_error_code == expected_status_code
|
26
|
+
]
|
27
|
+
if paths:
|
28
|
+
url = f"{base_url}{choice(paths)}"
|
29
|
+
return url
|
30
|
+
parameterized_path: str = run_keyword("get_parameterized_path_from_url", valid_url)
|
31
|
+
parameterized_url = base_url + parameterized_path
|
32
|
+
valid_url_parts = list(reversed(valid_url.split("/")))
|
33
|
+
parameterized_parts = reversed(parameterized_url.split("/"))
|
34
|
+
for index, (parameterized_part, _) in enumerate(
|
35
|
+
zip(parameterized_parts, valid_url_parts)
|
36
|
+
):
|
37
|
+
if parameterized_part.startswith("{") and parameterized_part.endswith("}"):
|
38
|
+
valid_url_parts[index] = uuid4().hex
|
39
|
+
valid_url_parts.reverse()
|
40
|
+
invalid_url = "/".join(valid_url_parts)
|
41
|
+
return invalid_url
|
42
|
+
raise ValueError(f"{parameterized_path} could not be invalidated.")
|
@@ -0,0 +1,38 @@
|
|
1
|
+
"""A module holding Protcols."""
|
2
|
+
|
3
|
+
from typing import Callable, Protocol, Type
|
4
|
+
|
5
|
+
from openapi_core.contrib.requests import (
|
6
|
+
RequestsOpenAPIRequest,
|
7
|
+
RequestsOpenAPIResponse,
|
8
|
+
)
|
9
|
+
|
10
|
+
from OpenApiLibCore.dto_base import Dto
|
11
|
+
|
12
|
+
|
13
|
+
class ResponseValidatorType(Protocol):
|
14
|
+
def __call__(
|
15
|
+
self, request: RequestsOpenAPIRequest, response: RequestsOpenAPIResponse
|
16
|
+
) -> None: ... # pragma: no cover
|
17
|
+
|
18
|
+
|
19
|
+
class GetDtoClassType(Protocol):
|
20
|
+
def __init__(self, mappings_module_name: str) -> None: ... # pragma: no cover
|
21
|
+
|
22
|
+
def __call__(self, path: str, method: str) -> Type[Dto]: ... # pragma: no cover
|
23
|
+
|
24
|
+
|
25
|
+
class GetIdPropertyNameType(Protocol):
|
26
|
+
def __init__(self, mappings_module_name: str) -> None: ... # pragma: no cover
|
27
|
+
|
28
|
+
def __call__(
|
29
|
+
self, path: str
|
30
|
+
) -> tuple[
|
31
|
+
str, Callable[[str], str] | Callable[[int], int]
|
32
|
+
]: ... # pragma: no cover
|
33
|
+
|
34
|
+
|
35
|
+
class GetPathDtoClassType(Protocol):
|
36
|
+
def __init__(self, mappings_module_name: str) -> None: ... # pragma: no cover
|
37
|
+
|
38
|
+
def __call__(self, path: str) -> Type[Dto]: ... # pragma: no cover
|
@@ -0,0 +1,246 @@
|
|
1
|
+
"""Module holding the classes used to manage request data."""
|
2
|
+
|
3
|
+
from copy import deepcopy
|
4
|
+
from dataclasses import dataclass, field
|
5
|
+
from functools import cached_property
|
6
|
+
from random import sample
|
7
|
+
from typing import Any
|
8
|
+
|
9
|
+
from OpenApiLibCore.annotations import JSON
|
10
|
+
from OpenApiLibCore.dto_base import Dto
|
11
|
+
from OpenApiLibCore.dto_utils import DefaultDto
|
12
|
+
from OpenApiLibCore.models import (
|
13
|
+
ObjectSchema,
|
14
|
+
ParameterObject,
|
15
|
+
ResolvedSchemaObjectTypes,
|
16
|
+
UnionTypeSchema,
|
17
|
+
)
|
18
|
+
|
19
|
+
|
20
|
+
@dataclass
|
21
|
+
class RequestValues:
|
22
|
+
"""Helper class to hold parameter values needed to make a request."""
|
23
|
+
|
24
|
+
url: str
|
25
|
+
method: str
|
26
|
+
params: dict[str, JSON] = field(default_factory=dict)
|
27
|
+
headers: dict[str, JSON] = field(default_factory=dict)
|
28
|
+
json_data: dict[str, JSON] = field(default_factory=dict)
|
29
|
+
|
30
|
+
def override_body_value(self, name: str, value: JSON) -> None:
|
31
|
+
if name in self.json_data:
|
32
|
+
self.json_data[name] = value
|
33
|
+
|
34
|
+
def override_header_value(self, name: str, value: JSON) -> None:
|
35
|
+
if name in self.headers:
|
36
|
+
self.headers[name] = value
|
37
|
+
|
38
|
+
def override_param_value(self, name: str, value: JSON) -> None:
|
39
|
+
if name in self.params:
|
40
|
+
self.params[name] = str(value)
|
41
|
+
|
42
|
+
def override_request_value(self, name: str, value: JSON) -> None:
|
43
|
+
self.override_body_value(name=name, value=value)
|
44
|
+
self.override_header_value(name=name, value=value)
|
45
|
+
self.override_param_value(name=name, value=value)
|
46
|
+
|
47
|
+
def remove_parameters(self, parameters: list[str]) -> None:
|
48
|
+
for parameter in parameters:
|
49
|
+
_ = self.params.pop(parameter, None)
|
50
|
+
_ = self.headers.pop(parameter, None)
|
51
|
+
_ = self.json_data.pop(parameter, None)
|
52
|
+
|
53
|
+
|
54
|
+
@dataclass
|
55
|
+
class RequestData:
|
56
|
+
"""Helper class to manage parameters used when making requests."""
|
57
|
+
|
58
|
+
dto: Dto | DefaultDto = field(default_factory=DefaultDto)
|
59
|
+
body_schema: ObjectSchema | None = None
|
60
|
+
parameters: list[ParameterObject] = field(default_factory=list)
|
61
|
+
params: dict[str, JSON] = field(default_factory=dict)
|
62
|
+
headers: dict[str, JSON] = field(default_factory=dict)
|
63
|
+
has_body: bool = True
|
64
|
+
|
65
|
+
def __post_init__(self) -> None:
|
66
|
+
# prevent modification by reference
|
67
|
+
self.params = deepcopy(self.params)
|
68
|
+
self.headers = deepcopy(self.headers)
|
69
|
+
|
70
|
+
@property
|
71
|
+
def has_optional_properties(self) -> bool:
|
72
|
+
"""Whether or not the dto data (json data) contains optional properties."""
|
73
|
+
|
74
|
+
def is_required_property(property_name: str) -> bool:
|
75
|
+
return property_name in self.required_property_names
|
76
|
+
|
77
|
+
properties = (self.dto.as_dict()).keys()
|
78
|
+
return not all(map(is_required_property, properties))
|
79
|
+
|
80
|
+
@property
|
81
|
+
def required_property_names(self) -> list[str]:
|
82
|
+
if self.body_schema:
|
83
|
+
return self.body_schema.required
|
84
|
+
return []
|
85
|
+
|
86
|
+
@property
|
87
|
+
def has_optional_params(self) -> bool:
|
88
|
+
"""Whether or not any of the query parameters are optional."""
|
89
|
+
|
90
|
+
def is_optional_param(query_param: str) -> bool:
|
91
|
+
optional_params = [
|
92
|
+
p.name for p in self.parameters if p.in_ == "query" and not p.required
|
93
|
+
]
|
94
|
+
return query_param in optional_params
|
95
|
+
|
96
|
+
return any(map(is_optional_param, self.params))
|
97
|
+
|
98
|
+
@cached_property
|
99
|
+
def params_that_can_be_invalidated(self) -> set[str]:
|
100
|
+
"""
|
101
|
+
The query parameters that can be invalidated by violating data
|
102
|
+
restrictions, data type or by not providing them in a request.
|
103
|
+
"""
|
104
|
+
result = set()
|
105
|
+
params = [h for h in self.parameters if h.in_ == "query"]
|
106
|
+
for param in params:
|
107
|
+
# required params can be omitted to invalidate a request
|
108
|
+
if param.required:
|
109
|
+
result.add(param.name)
|
110
|
+
continue
|
111
|
+
|
112
|
+
if param.schema_ is None:
|
113
|
+
continue
|
114
|
+
|
115
|
+
possible_schemas: list[ResolvedSchemaObjectTypes] = []
|
116
|
+
if isinstance(param.schema_, UnionTypeSchema):
|
117
|
+
possible_schemas = param.schema_.resolved_schemas
|
118
|
+
else:
|
119
|
+
possible_schemas = [param.schema_]
|
120
|
+
|
121
|
+
for param_schema in possible_schemas:
|
122
|
+
if param_schema.can_be_invalidated:
|
123
|
+
result.add(param.name)
|
124
|
+
|
125
|
+
return result
|
126
|
+
|
127
|
+
@property
|
128
|
+
def has_optional_headers(self) -> bool:
|
129
|
+
"""Whether or not any of the headers are optional."""
|
130
|
+
|
131
|
+
def is_optional_header(header: str) -> bool:
|
132
|
+
optional_headers = [
|
133
|
+
p.name for p in self.parameters if p.in_ == "header" and not p.required
|
134
|
+
]
|
135
|
+
return header in optional_headers
|
136
|
+
|
137
|
+
return any(map(is_optional_header, self.headers))
|
138
|
+
|
139
|
+
@cached_property
|
140
|
+
def headers_that_can_be_invalidated(self) -> set[str]:
|
141
|
+
"""
|
142
|
+
The header parameters that can be invalidated by violating data
|
143
|
+
restrictions or by not providing them in a request.
|
144
|
+
"""
|
145
|
+
result = set()
|
146
|
+
headers = [h for h in self.parameters if h.in_ == "header"]
|
147
|
+
for header in headers:
|
148
|
+
# required headers can be omitted to invalidate a request
|
149
|
+
if header.required:
|
150
|
+
result.add(header.name)
|
151
|
+
continue
|
152
|
+
|
153
|
+
if header.schema_ is None:
|
154
|
+
continue
|
155
|
+
|
156
|
+
possible_schemas: list[ResolvedSchemaObjectTypes] = []
|
157
|
+
if isinstance(header.schema_, UnionTypeSchema):
|
158
|
+
possible_schemas = header.schema_.resolved_schemas
|
159
|
+
else:
|
160
|
+
possible_schemas = [header.schema_]
|
161
|
+
|
162
|
+
for param_schema in possible_schemas:
|
163
|
+
if param_schema.can_be_invalidated:
|
164
|
+
result.add(header.name)
|
165
|
+
|
166
|
+
return result
|
167
|
+
|
168
|
+
def get_required_properties_dict(self) -> dict[str, Any]:
|
169
|
+
"""Get the json-compatible dto data containing only the required properties."""
|
170
|
+
relations = self.dto.get_relations()
|
171
|
+
mandatory_properties = [
|
172
|
+
relation.property_name
|
173
|
+
for relation in relations
|
174
|
+
if getattr(relation, "treat_as_mandatory", False)
|
175
|
+
]
|
176
|
+
required_properties = self.body_schema.required if self.body_schema else []
|
177
|
+
required_properties.extend(mandatory_properties)
|
178
|
+
|
179
|
+
required_properties_dict: dict[str, Any] = {}
|
180
|
+
for key, value in (self.dto.as_dict()).items():
|
181
|
+
if key in required_properties:
|
182
|
+
required_properties_dict[key] = value
|
183
|
+
return required_properties_dict
|
184
|
+
|
185
|
+
def get_minimal_body_dict(self) -> dict[str, Any]:
|
186
|
+
required_properties_dict = self.get_required_properties_dict()
|
187
|
+
|
188
|
+
min_properties = 0
|
189
|
+
if self.body_schema and self.body_schema.minProperties is not None:
|
190
|
+
min_properties = self.body_schema.minProperties
|
191
|
+
|
192
|
+
number_of_optional_properties_to_add = min_properties - len(
|
193
|
+
required_properties_dict
|
194
|
+
)
|
195
|
+
|
196
|
+
if number_of_optional_properties_to_add < 1:
|
197
|
+
return required_properties_dict
|
198
|
+
|
199
|
+
optional_properties_dict = {
|
200
|
+
k: v
|
201
|
+
for k, v in self.dto.as_dict().items()
|
202
|
+
if k not in required_properties_dict
|
203
|
+
}
|
204
|
+
optional_properties_to_keep = sample(
|
205
|
+
sorted(optional_properties_dict), number_of_optional_properties_to_add
|
206
|
+
)
|
207
|
+
optional_properties_dict = {
|
208
|
+
k: v
|
209
|
+
for k, v in optional_properties_dict.items()
|
210
|
+
if k in optional_properties_to_keep
|
211
|
+
}
|
212
|
+
|
213
|
+
return {**required_properties_dict, **optional_properties_dict}
|
214
|
+
|
215
|
+
def get_required_params(self) -> dict[str, JSON]:
|
216
|
+
"""Get the params dict containing only the required query parameters."""
|
217
|
+
return {
|
218
|
+
k: v for k, v in self.params.items() if k in self.required_parameter_names
|
219
|
+
}
|
220
|
+
|
221
|
+
def get_required_headers(self) -> dict[str, JSON]:
|
222
|
+
"""Get the headers dict containing only the required headers."""
|
223
|
+
return {
|
224
|
+
k: v for k, v in self.headers.items() if k in self.required_parameter_names
|
225
|
+
}
|
226
|
+
|
227
|
+
@property
|
228
|
+
def required_parameter_names(self) -> list[str]:
|
229
|
+
"""
|
230
|
+
The names of the mandatory parameters, including the parameters configured to be
|
231
|
+
treated as mandatory using a PropertyValueConstraint.
|
232
|
+
"""
|
233
|
+
relations = self.dto.get_parameter_relations()
|
234
|
+
mandatory_property_names = [
|
235
|
+
relation.property_name
|
236
|
+
for relation in relations
|
237
|
+
if getattr(relation, "treat_as_mandatory", False)
|
238
|
+
]
|
239
|
+
parameter_names = [p.name for p in self.parameters]
|
240
|
+
mandatory_parameters = [
|
241
|
+
p for p in mandatory_property_names if p in parameter_names
|
242
|
+
]
|
243
|
+
|
244
|
+
required_parameters = [p.name for p in self.parameters if p.required]
|
245
|
+
required_parameters.extend(mandatory_parameters)
|
246
|
+
return required_parameters
|
@@ -0,0 +1,55 @@
|
|
1
|
+
"""Module holding the functions related to relations between resources."""
|
2
|
+
|
3
|
+
from requests import Response
|
4
|
+
from robot.api import logger
|
5
|
+
from robot.libraries.BuiltIn import BuiltIn
|
6
|
+
|
7
|
+
import OpenApiLibCore.path_functions as _path_functions
|
8
|
+
from OpenApiLibCore.dto_base import IdReference
|
9
|
+
from OpenApiLibCore.models import OpenApiObject
|
10
|
+
from OpenApiLibCore.request_data import RequestData
|
11
|
+
|
12
|
+
run_keyword = BuiltIn().run_keyword
|
13
|
+
|
14
|
+
|
15
|
+
def ensure_in_use(
|
16
|
+
url: str,
|
17
|
+
base_url: str,
|
18
|
+
openapi_spec: OpenApiObject,
|
19
|
+
resource_relation: IdReference,
|
20
|
+
) -> None:
|
21
|
+
resource_id = ""
|
22
|
+
|
23
|
+
path = url.replace(base_url, "")
|
24
|
+
path_parts = path.split("/")
|
25
|
+
parameterized_path = _path_functions.get_parametrized_path(
|
26
|
+
path=path, openapi_spec=openapi_spec
|
27
|
+
)
|
28
|
+
parameterized_path_parts = parameterized_path.split("/")
|
29
|
+
for part, param_part in zip(
|
30
|
+
reversed(path_parts), reversed(parameterized_path_parts)
|
31
|
+
):
|
32
|
+
if param_part.endswith("}"):
|
33
|
+
resource_id = part
|
34
|
+
break
|
35
|
+
if not resource_id:
|
36
|
+
raise ValueError(f"The provided url ({url}) does not contain an id.")
|
37
|
+
request_data: RequestData = run_keyword(
|
38
|
+
"get_request_data", resource_relation.post_path, "post"
|
39
|
+
)
|
40
|
+
json_data = request_data.dto.as_dict()
|
41
|
+
json_data[resource_relation.property_name] = resource_id
|
42
|
+
post_url: str = run_keyword("get_valid_url", resource_relation.post_path)
|
43
|
+
response: Response = run_keyword(
|
44
|
+
"authorized_request",
|
45
|
+
post_url,
|
46
|
+
"post",
|
47
|
+
request_data.params,
|
48
|
+
request_data.headers,
|
49
|
+
json_data,
|
50
|
+
)
|
51
|
+
if not response.ok:
|
52
|
+
logger.debug(
|
53
|
+
f"POST on {post_url} with json {json_data} failed: {response.json()}"
|
54
|
+
)
|
55
|
+
response.raise_for_status()
|