robotframework-openapitools 0.4.0__py3-none-any.whl → 1.0.0b2__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 (60) hide show
  1. OpenApiDriver/__init__.py +44 -41
  2. OpenApiDriver/openapi_executors.py +40 -39
  3. OpenApiDriver/openapi_reader.py +115 -116
  4. OpenApiDriver/openapidriver.libspec +71 -61
  5. OpenApiDriver/openapidriver.py +25 -19
  6. OpenApiLibCore/__init__.py +13 -11
  7. OpenApiLibCore/annotations.py +3 -0
  8. OpenApiLibCore/data_generation/__init__.py +12 -0
  9. OpenApiLibCore/data_generation/body_data_generation.py +269 -0
  10. OpenApiLibCore/data_generation/data_generation_core.py +240 -0
  11. OpenApiLibCore/data_invalidation.py +281 -0
  12. OpenApiLibCore/dto_base.py +29 -35
  13. OpenApiLibCore/dto_utils.py +97 -85
  14. OpenApiLibCore/oas_cache.py +14 -13
  15. OpenApiLibCore/openapi_libcore.libspec +346 -193
  16. OpenApiLibCore/openapi_libcore.py +389 -1702
  17. OpenApiLibCore/parameter_utils.py +91 -0
  18. OpenApiLibCore/path_functions.py +215 -0
  19. OpenApiLibCore/path_invalidation.py +44 -0
  20. OpenApiLibCore/protocols.py +30 -0
  21. OpenApiLibCore/request_data.py +281 -0
  22. OpenApiLibCore/resource_relations.py +54 -0
  23. OpenApiLibCore/validation.py +497 -0
  24. OpenApiLibCore/value_utils.py +528 -481
  25. openapi_libgen/__init__.py +46 -0
  26. openapi_libgen/command_line.py +87 -0
  27. openapi_libgen/parsing_utils.py +26 -0
  28. openapi_libgen/spec_parser.py +221 -0
  29. openapi_libgen/templates/__init__.jinja +3 -0
  30. openapi_libgen/templates/library.jinja +30 -0
  31. robotframework_openapitools-1.0.0b2.dist-info/METADATA +237 -0
  32. robotframework_openapitools-1.0.0b2.dist-info/RECORD +37 -0
  33. {robotframework_openapitools-0.4.0.dist-info → robotframework_openapitools-1.0.0b2.dist-info}/WHEEL +1 -1
  34. robotframework_openapitools-1.0.0b2.dist-info/entry_points.txt +3 -0
  35. roboswag/__init__.py +0 -9
  36. roboswag/__main__.py +0 -3
  37. roboswag/auth.py +0 -44
  38. roboswag/cli.py +0 -80
  39. roboswag/core.py +0 -85
  40. roboswag/generate/__init__.py +0 -1
  41. roboswag/generate/generate.py +0 -121
  42. roboswag/generate/models/__init__.py +0 -0
  43. roboswag/generate/models/api.py +0 -219
  44. roboswag/generate/models/definition.py +0 -28
  45. roboswag/generate/models/endpoint.py +0 -68
  46. roboswag/generate/models/parameter.py +0 -25
  47. roboswag/generate/models/response.py +0 -8
  48. roboswag/generate/models/tag.py +0 -16
  49. roboswag/generate/models/utils.py +0 -60
  50. roboswag/generate/templates/api_init.jinja +0 -15
  51. roboswag/generate/templates/models.jinja +0 -7
  52. roboswag/generate/templates/paths.jinja +0 -68
  53. roboswag/logger.py +0 -33
  54. roboswag/validate/__init__.py +0 -6
  55. roboswag/validate/core.py +0 -3
  56. roboswag/validate/schema.py +0 -21
  57. roboswag/validate/text_response.py +0 -14
  58. robotframework_openapitools-0.4.0.dist-info/METADATA +0 -42
  59. robotframework_openapitools-0.4.0.dist-info/RECORD +0 -41
  60. {robotframework_openapitools-0.4.0.dist-info → robotframework_openapitools-1.0.0b2.dist-info}/LICENSE +0 -0
@@ -0,0 +1,91 @@
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.annotations import JSON
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, dict[str, JSON]]) -> None:
84
+ for operations_data in paths_data.values():
85
+ for method_data in operations_data.values():
86
+ parameters_data: list[dict[str, str]] = method_data.get("parameters", [])
87
+ path_parameter_names = [
88
+ p["name"] for p in parameters_data if p["in"] == "path"
89
+ ]
90
+ for name in path_parameter_names:
91
+ _ = get_safe_name_for_oas_name(name)
@@ -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.dto_base import PathPropertiesConstraint
12
+ from OpenApiLibCore.protocols import GetDtoClassType, GetIdPropertyNameType
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: dict[str, Any]) -> 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] = {**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_dto_class: GetDtoClassType,
67
+ openapi_spec: dict[str, Any],
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_dto_class(path=path, method="get")
78
+ relations = dto_class.get_relations()
79
+ paths = [p.path for p in relations if isinstance(p, PathPropertiesConstraint)]
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,44 @@
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.dto_base import PathPropertiesConstraint
9
+ from OpenApiLibCore.protocols import GetDtoClassType
10
+
11
+ run_keyword = BuiltIn().run_keyword
12
+
13
+
14
+ def get_invalidated_url(
15
+ valid_url: str,
16
+ path: str,
17
+ base_url: str,
18
+ get_dto_class: GetDtoClassType,
19
+ expected_status_code: int,
20
+ ) -> str:
21
+ dto_class = get_dto_class(path=path, method="get")
22
+ relations = dto_class.get_relations()
23
+ paths = [
24
+ p.invalid_value
25
+ for p in relations
26
+ if isinstance(p, PathPropertiesConstraint)
27
+ and p.invalid_value_error_code == expected_status_code
28
+ ]
29
+ if paths:
30
+ url = f"{base_url}{choice(paths)}"
31
+ return url
32
+ parameterized_path: str = run_keyword("get_parameterized_path_from_url", valid_url)
33
+ parameterized_url = base_url + parameterized_path
34
+ valid_url_parts = list(reversed(valid_url.split("/")))
35
+ parameterized_parts = reversed(parameterized_url.split("/"))
36
+ for index, (parameterized_part, _) in enumerate(
37
+ zip(parameterized_parts, valid_url_parts)
38
+ ):
39
+ if parameterized_part.startswith("{") and parameterized_part.endswith("}"):
40
+ valid_url_parts[index] = uuid4().hex
41
+ valid_url_parts.reverse()
42
+ invalid_url = "/".join(valid_url_parts)
43
+ return invalid_url
44
+ raise ValueError(f"{parameterized_path} could not be invalidated.")
@@ -0,0 +1,30 @@
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: ...
17
+
18
+
19
+ class GetDtoClassType(Protocol):
20
+ def __init__(self, mappings_module_name: str) -> None: ...
21
+
22
+ def __call__(self, path: str, method: str) -> Type[Dto]: ...
23
+
24
+
25
+ class GetIdPropertyNameType(Protocol):
26
+ def __init__(self, mappings_module_name: str) -> None: ...
27
+
28
+ def __call__(
29
+ self, path: str
30
+ ) -> tuple[str, Callable[[str], str] | Callable[[int], int]]: ...
@@ -0,0 +1,281 @@
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.dto_base import (
10
+ Dto,
11
+ resolve_schema,
12
+ )
13
+ from OpenApiLibCore.dto_utils import DefaultDto
14
+
15
+
16
+ @dataclass
17
+ class RequestValues:
18
+ """Helper class to hold parameter values needed to make a request."""
19
+
20
+ url: str
21
+ method: str
22
+ params: dict[str, Any] = field(default_factory=dict)
23
+ headers: dict[str, str] = field(default_factory=dict)
24
+ json_data: dict[str, Any] = field(default_factory=dict)
25
+
26
+ def override_body_value(self, name: str, value: Any) -> None:
27
+ if name in self.json_data:
28
+ self.json_data[name] = value
29
+
30
+ def override_header_value(self, name: str, value: Any) -> None:
31
+ if name in self.headers:
32
+ self.headers[name] = value
33
+
34
+ def override_param_value(self, name: str, value: str) -> None:
35
+ if name in self.params:
36
+ self.params[name] = str(value)
37
+
38
+ def override_request_value(self, name: str, value: Any) -> None:
39
+ self.override_body_value(name=name, value=value)
40
+ self.override_header_value(name=name, value=value)
41
+ self.override_param_value(name=name, value=value)
42
+
43
+ def remove_parameters(self, parameters: list[str]) -> None:
44
+ for parameter in parameters:
45
+ _ = self.params.pop(parameter, None)
46
+ _ = self.headers.pop(parameter, None)
47
+ _ = self.json_data.pop(parameter, None)
48
+
49
+
50
+ @dataclass
51
+ class RequestData:
52
+ """Helper class to manage parameters used when making requests."""
53
+
54
+ dto: Dto | DefaultDto = field(default_factory=DefaultDto)
55
+ dto_schema: dict[str, Any] = field(default_factory=dict)
56
+ parameters: list[dict[str, Any]] = field(default_factory=list)
57
+ params: dict[str, Any] = field(default_factory=dict)
58
+ headers: dict[str, Any] = field(default_factory=dict)
59
+ has_body: bool = True
60
+
61
+ def __post_init__(self) -> None:
62
+ # prevent modification by reference
63
+ self.dto_schema = deepcopy(self.dto_schema)
64
+ self.parameters = deepcopy(self.parameters)
65
+ self.params = deepcopy(self.params)
66
+ self.headers = deepcopy(self.headers)
67
+
68
+ @property
69
+ def has_optional_properties(self) -> bool:
70
+ """Whether or not the dto data (json data) contains optional properties."""
71
+
72
+ def is_required_property(property_name: str) -> bool:
73
+ return property_name in self.dto_schema.get("required", [])
74
+
75
+ properties = (self.dto.as_dict()).keys()
76
+ return not all(map(is_required_property, properties))
77
+
78
+ @property
79
+ def has_optional_params(self) -> bool:
80
+ """Whether or not any of the query parameters are optional."""
81
+
82
+ def is_optional_param(query_param: str) -> bool:
83
+ optional_params = [
84
+ p.get("name")
85
+ for p in self.parameters
86
+ if p.get("in") == "query" and not p.get("required")
87
+ ]
88
+ return query_param in optional_params
89
+
90
+ return any(map(is_optional_param, self.params))
91
+
92
+ @cached_property
93
+ def params_that_can_be_invalidated(self) -> set[str]:
94
+ """
95
+ The query parameters that can be invalidated by violating data
96
+ restrictions, data type or by not providing them in a request.
97
+ """
98
+ result = set()
99
+ params = [h for h in self.parameters if h.get("in") == "query"]
100
+ for param in params:
101
+ # required params can be omitted to invalidate a request
102
+ if param["required"]:
103
+ result.add(param["name"])
104
+ continue
105
+
106
+ schema = resolve_schema(param["schema"])
107
+ if schema.get("type", None):
108
+ param_types = [schema]
109
+ else:
110
+ param_types = schema["types"]
111
+ for param_type in param_types:
112
+ # any basic non-string type except "null" can be invalidated by
113
+ # replacing it with a string
114
+ if param_type["type"] not in ["string", "array", "object", "null"]:
115
+ result.add(param["name"])
116
+ continue
117
+ # enums, strings and arrays with boundaries can be invalidated
118
+ if set(param_type.keys()).intersection(
119
+ {
120
+ "enum",
121
+ "minLength",
122
+ "maxLength",
123
+ "minItems",
124
+ "maxItems",
125
+ }
126
+ ):
127
+ result.add(param["name"])
128
+ continue
129
+ # an array of basic non-string type can be invalidated by replacing the
130
+ # items in the array with strings
131
+ if param_type["type"] == "array" and param_type["items"][
132
+ "type"
133
+ ] not in [
134
+ "string",
135
+ "array",
136
+ "object",
137
+ "null",
138
+ ]:
139
+ result.add(param["name"])
140
+ return result
141
+
142
+ @property
143
+ def has_optional_headers(self) -> bool:
144
+ """Whether or not any of the headers are optional."""
145
+
146
+ def is_optional_header(header: str) -> bool:
147
+ optional_headers = [
148
+ p.get("name")
149
+ for p in self.parameters
150
+ if p.get("in") == "header" and not p.get("required")
151
+ ]
152
+ return header in optional_headers
153
+
154
+ return any(map(is_optional_header, self.headers))
155
+
156
+ @cached_property
157
+ def headers_that_can_be_invalidated(self) -> set[str]:
158
+ """
159
+ The header parameters that can be invalidated by violating data
160
+ restrictions or by not providing them in a request.
161
+ """
162
+ result = set()
163
+ headers = [h for h in self.parameters if h.get("in") == "header"]
164
+ for header in headers:
165
+ # required headers can be omitted to invalidate a request
166
+ if header["required"]:
167
+ result.add(header["name"])
168
+ continue
169
+
170
+ schema = resolve_schema(header["schema"])
171
+ if schema.get("type", None):
172
+ header_types = [schema]
173
+ else:
174
+ header_types = schema["types"]
175
+ for header_type in header_types:
176
+ # any basic non-string type except "null" can be invalidated by
177
+ # replacing it with a string
178
+ if header_type["type"] not in ["string", "array", "object", "null"]:
179
+ result.add(header["name"])
180
+ continue
181
+ # enums, strings and arrays with boundaries can be invalidated
182
+ if set(header_type.keys()).intersection(
183
+ {
184
+ "enum",
185
+ "minLength",
186
+ "maxLength",
187
+ "minItems",
188
+ "maxItems",
189
+ }
190
+ ):
191
+ result.add(header["name"])
192
+ continue
193
+ # an array of basic non-string type can be invalidated by replacing the
194
+ # items in the array with strings
195
+ if header_type["type"] == "array" and header_type["items"][
196
+ "type"
197
+ ] not in [
198
+ "string",
199
+ "array",
200
+ "object",
201
+ "null",
202
+ ]:
203
+ result.add(header["name"])
204
+ return result
205
+
206
+ def get_required_properties_dict(self) -> dict[str, Any]:
207
+ """Get the json-compatible dto data containing only the required properties."""
208
+ relations = self.dto.get_relations()
209
+ mandatory_properties = [
210
+ relation.property_name
211
+ for relation in relations
212
+ if getattr(relation, "treat_as_mandatory", False)
213
+ ]
214
+ required_properties: list[str] = self.dto_schema.get("required", [])
215
+ required_properties.extend(mandatory_properties)
216
+
217
+ required_properties_dict: dict[str, Any] = {}
218
+ for key, value in (self.dto.as_dict()).items():
219
+ if key in required_properties:
220
+ required_properties_dict[key] = value
221
+ return required_properties_dict
222
+
223
+ def get_minimal_body_dict(self) -> dict[str, Any]:
224
+ required_properties_dict = self.get_required_properties_dict()
225
+
226
+ min_properties = self.dto_schema.get("minProperties", 0)
227
+ number_of_optional_properties_to_add = min_properties - len(
228
+ required_properties_dict
229
+ )
230
+
231
+ if number_of_optional_properties_to_add < 1:
232
+ return required_properties_dict
233
+
234
+ optional_properties_dict = {
235
+ k: v
236
+ for k, v in self.dto.as_dict().items()
237
+ if k not in required_properties_dict
238
+ }
239
+ optional_properties_to_keep = sample(
240
+ sorted(optional_properties_dict), number_of_optional_properties_to_add
241
+ )
242
+ optional_properties_dict = {
243
+ k: v
244
+ for k, v in optional_properties_dict.items()
245
+ if k in optional_properties_to_keep
246
+ }
247
+
248
+ return {**required_properties_dict, **optional_properties_dict}
249
+
250
+ def get_required_params(self) -> dict[str, str]:
251
+ """Get the params dict containing only the required query parameters."""
252
+ return {
253
+ k: v for k, v in self.params.items() if k in self.required_parameter_names
254
+ }
255
+
256
+ def get_required_headers(self) -> dict[str, str]:
257
+ """Get the headers dict containing only the required headers."""
258
+ return {
259
+ k: v for k, v in self.headers.items() if k in self.required_parameter_names
260
+ }
261
+
262
+ @property
263
+ def required_parameter_names(self) -> list[str]:
264
+ """
265
+ The names of the mandatory parameters, including the parameters configured to be
266
+ treated as mandatory using a PropertyValueConstraint.
267
+ """
268
+ relations = self.dto.get_parameter_relations()
269
+ mandatory_property_names = [
270
+ relation.property_name
271
+ for relation in relations
272
+ if getattr(relation, "treat_as_mandatory", False)
273
+ ]
274
+ parameter_names = [p["name"] for p in self.parameters]
275
+ mandatory_parameters = [
276
+ p for p in mandatory_property_names if p in parameter_names
277
+ ]
278
+
279
+ required_parameters = [p["name"] for p in self.parameters if p.get("required")]
280
+ required_parameters.extend(mandatory_parameters)
281
+ return required_parameters