robotframework-openapitools 0.4.0__py3-none-any.whl → 1.0.0b1__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 +350 -193
  16. OpenApiLibCore/openapi_libcore.py +392 -1698
  17. OpenApiLibCore/parameter_utils.py +89 -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 +275 -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 +212 -0
  29. openapi_libgen/templates/__init__.jinja +3 -0
  30. openapi_libgen/templates/library.jinja +30 -0
  31. robotframework_openapitools-1.0.0b1.dist-info/METADATA +237 -0
  32. robotframework_openapitools-1.0.0b1.dist-info/RECORD +37 -0
  33. {robotframework_openapitools-0.4.0.dist-info → robotframework_openapitools-1.0.0b1.dist-info}/WHEEL +1 -1
  34. robotframework_openapitools-1.0.0b1.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.0b1.dist-info}/LICENSE +0 -0
@@ -0,0 +1,54 @@
1
+ """Module holding the functions related to relations between resources."""
2
+
3
+ from typing import Any
4
+
5
+ from requests import Response
6
+ from robot.api import logger
7
+ from robot.libraries.BuiltIn import BuiltIn
8
+
9
+ import OpenApiLibCore.path_functions as pf
10
+ from OpenApiLibCore.dto_base import IdReference
11
+ from OpenApiLibCore.request_data import RequestData
12
+
13
+ run_keyword = BuiltIn().run_keyword
14
+
15
+
16
+ def ensure_in_use(
17
+ url: str,
18
+ base_url: str,
19
+ openapi_spec: dict[str, Any],
20
+ resource_relation: IdReference,
21
+ ) -> None:
22
+ resource_id = ""
23
+
24
+ path = url.replace(base_url, "")
25
+ path_parts = path.split("/")
26
+ parameterized_path = pf.get_parametrized_path(path=path, openapi_spec=openapi_spec)
27
+ parameterized_path_parts = parameterized_path.split("/")
28
+ for part, param_part in zip(
29
+ reversed(path_parts), reversed(parameterized_path_parts)
30
+ ):
31
+ if param_part.endswith("}"):
32
+ resource_id = part
33
+ break
34
+ if not resource_id:
35
+ raise ValueError(f"The provided url ({url}) does not contain an id.")
36
+ request_data: RequestData = run_keyword(
37
+ "get_request_data", resource_relation.post_path, "post"
38
+ )
39
+ json_data = request_data.dto.as_dict()
40
+ json_data[resource_relation.property_name] = resource_id
41
+ post_url: str = run_keyword("get_valid_url", resource_relation.post_path)
42
+ response: Response = run_keyword(
43
+ "authorized_request",
44
+ post_url,
45
+ "post",
46
+ request_data.params,
47
+ request_data.headers,
48
+ json_data,
49
+ )
50
+ if not response.ok:
51
+ logger.debug(
52
+ f"POST on {post_url} with json {json_data} failed: {response.json()}"
53
+ )
54
+ response.raise_for_status()
@@ -0,0 +1,497 @@
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.dto_base import resolve_schema
22
+ from OpenApiLibCore.protocols import ResponseValidatorType
23
+ from OpenApiLibCore.request_data import RequestData, RequestValues
24
+
25
+ run_keyword = BuiltIn().run_keyword
26
+
27
+
28
+ class ValidationLevel(str, Enum):
29
+ """The available levels for the response_validation parameter."""
30
+
31
+ DISABLED = "DISABLED"
32
+ INFO = "INFO"
33
+ WARN = "WARN"
34
+ STRICT = "STRICT"
35
+
36
+
37
+ def perform_validated_request(
38
+ path: str,
39
+ status_code: int,
40
+ request_values: RequestValues,
41
+ original_data: Mapping[str, Any],
42
+ ) -> None:
43
+ response = run_keyword(
44
+ "authorized_request",
45
+ request_values.url,
46
+ request_values.method,
47
+ request_values.params,
48
+ request_values.headers,
49
+ request_values.json_data,
50
+ )
51
+ if response.status_code != status_code:
52
+ try:
53
+ response_json = response.json()
54
+ except Exception as _: # pylint: disable=broad-except
55
+ logger.info(
56
+ f"Failed to get json content from response. "
57
+ f"Response text was: {response.text}"
58
+ )
59
+ response_json = {}
60
+ if not response.ok:
61
+ if description := response_json.get("detail"):
62
+ pass
63
+ else:
64
+ description = response_json.get(
65
+ "message", "response contains no message or detail."
66
+ )
67
+ logger.error(f"{response.reason}: {description}")
68
+
69
+ logger.debug(
70
+ f"\nSend: {_json.dumps(request_values.json_data, indent=4, sort_keys=True)}"
71
+ f"\nGot: {_json.dumps(response_json, indent=4, sort_keys=True)}"
72
+ )
73
+ raise AssertionError(
74
+ f"Response status_code {response.status_code} was not {status_code}"
75
+ )
76
+
77
+ run_keyword("validate_response", path, response, original_data)
78
+
79
+ if request_values.method == "DELETE":
80
+ request_data: RequestData = run_keyword("get_request_data", path, "GET")
81
+ get_params = request_data.params
82
+ get_headers = request_data.headers
83
+ get_response = run_keyword(
84
+ "authorized_request", request_values.url, "GET", get_params, get_headers
85
+ )
86
+ if response.ok:
87
+ if get_response.ok:
88
+ raise AssertionError(
89
+ f"Resource still exists after deletion. Url was {request_values.url}"
90
+ )
91
+ # if the path supports GET, 404 is expected, if not 405 is expected
92
+ if get_response.status_code not in [404, 405]:
93
+ logger.warn(
94
+ f"Unexpected response after deleting resource: Status_code "
95
+ f"{get_response.status_code} was received after trying to get "
96
+ f"{request_values.url} after sucessfully deleting it."
97
+ )
98
+ elif not get_response.ok:
99
+ raise AssertionError(
100
+ f"Resource could not be retrieved after failed deletion. "
101
+ f"Url was {request_values.url}, status_code was {get_response.status_code}"
102
+ )
103
+
104
+
105
+ def assert_href_to_resource_is_valid(
106
+ href: str, origin: str, base_url: str, referenced_resource: dict[str, Any]
107
+ ) -> None:
108
+ url = f"{origin}{href}"
109
+ path = url.replace(base_url, "")
110
+ request_data: RequestData = run_keyword("get_request_data", path, "GET")
111
+ params = request_data.params
112
+ headers = request_data.headers
113
+ get_response = run_keyword("authorized_request", url, "GET", params, headers)
114
+ assert get_response.json() == referenced_resource, (
115
+ f"{get_response.json()} not equal to original {referenced_resource}"
116
+ )
117
+
118
+
119
+ def validate_response(
120
+ path: str,
121
+ response: Response,
122
+ response_validator: ResponseValidatorType,
123
+ server_validation_warning_logged: bool,
124
+ disable_server_validation: bool,
125
+ invalid_property_default_response: int,
126
+ response_validation: str,
127
+ openapi_spec: dict[str, Any],
128
+ original_data: Mapping[str, Any],
129
+ ) -> None:
130
+ if response.status_code == int(HTTPStatus.NO_CONTENT):
131
+ assert not response.content
132
+ return None
133
+
134
+ try:
135
+ _validate_response(
136
+ response=response,
137
+ response_validator=response_validator,
138
+ server_validation_warning_logged=server_validation_warning_logged,
139
+ disable_server_validation=disable_server_validation,
140
+ invalid_property_default_response=invalid_property_default_response,
141
+ response_validation=response_validation,
142
+ )
143
+ except OpenAPIError as exception:
144
+ raise Failure(
145
+ f"Response did not pass schema validation: {exception}"
146
+ ) from exception
147
+
148
+ request_method = response.request.method
149
+ if request_method is None:
150
+ logger.warn(
151
+ f"Could not validate response for path {path}; no method found "
152
+ f"on the request property of the provided response."
153
+ )
154
+ return None
155
+
156
+ response_spec = _get_response_spec(
157
+ path=path,
158
+ method=request_method,
159
+ status_code=response.status_code,
160
+ openapi_spec=openapi_spec,
161
+ )
162
+
163
+ content_type_from_response = response.headers.get("Content-Type", "unknown")
164
+ mime_type_from_response, _, _ = content_type_from_response.partition(";")
165
+
166
+ if not response_spec.get("content"):
167
+ logger.warn(
168
+ "The response cannot be validated: 'content' not specified in the OAS."
169
+ )
170
+ return None
171
+
172
+ # multiple content types can be specified in the OAS
173
+ content_types = list(response_spec["content"].keys())
174
+ supported_types = [
175
+ ct for ct in content_types if ct.partition(";")[0].endswith("json")
176
+ ]
177
+ if not supported_types:
178
+ raise NotImplementedError(
179
+ f"The content_types '{content_types}' are not supported. "
180
+ f"Only json types are currently supported."
181
+ )
182
+ content_type = supported_types[0]
183
+ mime_type = content_type.partition(";")[0]
184
+
185
+ if mime_type != mime_type_from_response:
186
+ raise ValueError(
187
+ f"Content-Type '{content_type_from_response}' of the response "
188
+ f"does not match '{mime_type}' as specified in the OpenAPI document."
189
+ )
190
+
191
+ json_response = response.json()
192
+ response_schema = resolve_schema(response_spec["content"][content_type]["schema"])
193
+
194
+ response_types = response_schema.get("types")
195
+ if response_types:
196
+ # In case of oneOf / anyOf there can be multiple possible response types
197
+ # which makes generic validation too complex
198
+ return None
199
+ response_type = response_schema.get("type", "undefined")
200
+ if response_type not in ["object", "array"]:
201
+ _validate_value_type(value=json_response, expected_type=response_type)
202
+ return None
203
+
204
+ if list_item_schema := response_schema.get("items"):
205
+ if not isinstance(json_response, list):
206
+ raise AssertionError(
207
+ f"Response schema violation: the schema specifies an array as "
208
+ f"response type but the response was of type {type(json_response)}."
209
+ )
210
+ type_of_list_items = list_item_schema.get("type")
211
+ if type_of_list_items == "object":
212
+ for resource in json_response:
213
+ run_keyword("validate_resource_properties", resource, list_item_schema)
214
+ else:
215
+ for item in json_response:
216
+ _validate_value_type(value=item, expected_type=type_of_list_items)
217
+ # no further validation; value validation of individual resources should
218
+ # be performed on the path for the specific resources
219
+ return None
220
+
221
+ run_keyword("validate_resource_properties", json_response, response_schema)
222
+ # ensure the href is valid if present in the response
223
+ if href := json_response.get("href"):
224
+ run_keyword("assert_href_to_resource_is_valid", href, json_response)
225
+ # every property that was sucessfully send and that is in the response
226
+ # schema must have the value that was send
227
+ if response.ok and response.request.method in ["POST", "PUT", "PATCH"]:
228
+ run_keyword("validate_send_response", response, original_data)
229
+ return None
230
+
231
+
232
+ def validate_resource_properties(
233
+ resource: dict[str, Any], schema: dict[str, Any]
234
+ ) -> None:
235
+ schema_properties = schema.get("properties", {})
236
+ property_names_from_schema = set(schema_properties.keys())
237
+ property_names_in_resource = set(resource.keys())
238
+
239
+ if property_names_from_schema != property_names_in_resource:
240
+ # The additionalProperties property determines whether properties with
241
+ # unspecified names are allowed. This property can be boolean or an object
242
+ # (dict) that specifies the type of any additional properties.
243
+ additional_properties = schema.get("additionalProperties", True)
244
+ if isinstance(additional_properties, bool):
245
+ allow_additional_properties = additional_properties
246
+ allowed_additional_properties_type = None
247
+ else:
248
+ allow_additional_properties = True
249
+ allowed_additional_properties_type = additional_properties["type"]
250
+
251
+ extra_property_names = property_names_in_resource.difference(
252
+ property_names_from_schema
253
+ )
254
+ if allow_additional_properties:
255
+ # If a type is defined for extra properties, validate them
256
+ if allowed_additional_properties_type:
257
+ extra_properties = {
258
+ key: value
259
+ for key, value in resource.items()
260
+ if key in extra_property_names
261
+ }
262
+ _validate_type_of_extra_properties(
263
+ extra_properties=extra_properties,
264
+ expected_type=allowed_additional_properties_type,
265
+ )
266
+ # If allowed, validation should not fail on extra properties
267
+ extra_property_names = set()
268
+
269
+ required_properties = set(schema.get("required", []))
270
+ missing_properties = required_properties.difference(property_names_in_resource)
271
+
272
+ if extra_property_names or missing_properties:
273
+ extra = (
274
+ f"\n\tExtra properties in response: {extra_property_names}"
275
+ if extra_property_names
276
+ else ""
277
+ )
278
+ missing = (
279
+ f"\n\tRequired properties missing in response: {missing_properties}"
280
+ if missing_properties
281
+ else ""
282
+ )
283
+ raise AssertionError(
284
+ f"Response schema violation: the response contains properties that are "
285
+ f"not specified in the schema or does not contain properties that are "
286
+ f"required according to the schema."
287
+ f"\n\tReceived in the response: {property_names_in_resource}"
288
+ f"\n\tDefined in the schema: {property_names_from_schema}"
289
+ f"{extra}{missing}"
290
+ )
291
+
292
+
293
+ def validate_send_response(
294
+ response: Response,
295
+ original_data: Mapping[str, Any],
296
+ ) -> None:
297
+ def validate_list_response(send_list: list[Any], received_list: list[Any]) -> None:
298
+ for item in send_list:
299
+ if item not in received_list:
300
+ raise AssertionError(
301
+ f"Received value '{received_list}' does "
302
+ f"not contain '{item}' in the {response.request.method} request."
303
+ f"\nSend: {_json.dumps(send_json, indent=4, sort_keys=True)}"
304
+ f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
305
+ )
306
+
307
+ def validate_dict_response(
308
+ send_dict: dict[str, Any], received_dict: dict[str, Any]
309
+ ) -> None:
310
+ for send_property_name, send_property_value in send_dict.items():
311
+ # sometimes, a property in the request is not in the response, e.g. a password
312
+ if send_property_name not in received_dict.keys():
313
+ continue
314
+ if send_property_value is not None:
315
+ # if a None value is send, the target property should be cleared or
316
+ # reverted to the default value (which cannot be specified in the
317
+ # openapi document)
318
+ received_value = received_dict[send_property_name]
319
+ # In case of lists / arrays, the send values are often appended to
320
+ # existing data
321
+ if isinstance(received_value, list):
322
+ validate_list_response(
323
+ send_list=send_property_value, received_list=received_value
324
+ )
325
+ continue
326
+
327
+ # when dealing with objects, we'll need to iterate the properties
328
+ if isinstance(received_value, dict):
329
+ validate_dict_response(
330
+ send_dict=send_property_value, received_dict=received_value
331
+ )
332
+ continue
333
+
334
+ assert received_value == send_property_value, (
335
+ f"Received value for {send_property_name} '{received_value}' does not "
336
+ f"match '{send_property_value}' in the {response.request.method} request."
337
+ f"\nSend: {_json.dumps(send_json, indent=4, sort_keys=True)}"
338
+ f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
339
+ )
340
+
341
+ if response.request.body is None:
342
+ logger.warn(
343
+ "Could not validate send response; the body of the request property "
344
+ "on the provided response was None."
345
+ )
346
+ return None
347
+ if isinstance(response.request.body, bytes):
348
+ send_json = _json.loads(response.request.body.decode("UTF-8"))
349
+ else:
350
+ send_json = _json.loads(response.request.body)
351
+
352
+ response_data = response.json()
353
+ # POST on /resource_type/{id}/array_item/ will return the updated {id} resource
354
+ # instead of a newly created resource. In this case, the send_json must be
355
+ # in the array of the 'array_item' property on {id}
356
+ send_path: str = response.request.path_url
357
+ response_path = response_data.get("href", None)
358
+ if response_path and send_path not in response_path:
359
+ property_to_check = send_path.replace(response_path, "")[1:]
360
+ if response_data.get(property_to_check) and isinstance(
361
+ response_data[property_to_check], list
362
+ ):
363
+ item_list: list[dict[str, Any]] = response_data[property_to_check]
364
+ # Use the (mandatory) id to get the POSTed resource from the list
365
+ [response_data] = [
366
+ item for item in item_list if item["id"] == send_json["id"]
367
+ ]
368
+
369
+ # incoming arguments are dictionaries, so they can be validated as such
370
+ validate_dict_response(send_dict=send_json, received_dict=response_data)
371
+
372
+ # In case of PATCH requests, ensure that only send properties have changed
373
+ if original_data:
374
+ for send_property_name, send_value in original_data.items():
375
+ if send_property_name not in send_json.keys():
376
+ assert send_value == response_data[send_property_name], (
377
+ f"Received value for {send_property_name} '{response_data[send_property_name]}' does not "
378
+ f"match '{send_value}' in the pre-patch data"
379
+ f"\nPre-patch: {_json.dumps(original_data, indent=4, sort_keys=True)}"
380
+ f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
381
+ )
382
+ return None
383
+
384
+
385
+ def validate_response_using_validator(
386
+ request: RequestsOpenAPIRequest,
387
+ response: RequestsOpenAPIResponse,
388
+ response_validator: ResponseValidatorType,
389
+ ) -> None:
390
+ response_validator(request=request, response=response)
391
+
392
+
393
+ def _validate_response(
394
+ response: Response,
395
+ response_validator: ResponseValidatorType,
396
+ server_validation_warning_logged: bool,
397
+ disable_server_validation: bool,
398
+ invalid_property_default_response: int,
399
+ response_validation: str,
400
+ ) -> None:
401
+ try:
402
+ validate_response_using_validator(
403
+ RequestsOpenAPIRequest(response.request),
404
+ RequestsOpenAPIResponse(response),
405
+ response_validator=response_validator,
406
+ )
407
+ except (ResponseValidationError, ServerNotFound) as exception:
408
+ error: BaseException | None = exception.__cause__
409
+ validation_errors: list[ValidationError] = getattr(error, "schema_errors", [])
410
+ if validation_errors:
411
+ error_message = "\n".join(
412
+ [
413
+ f"{list(getattr(error, 'schema_path', ''))}: {getattr(error, 'message', '')}"
414
+ for error in validation_errors
415
+ ]
416
+ )
417
+ else:
418
+ error_message = str(exception)
419
+
420
+ if isinstance(exception, ServerNotFound):
421
+ if not server_validation_warning_logged:
422
+ logger.warn(
423
+ f"ServerNotFound was raised during response validation. "
424
+ f"Due to this, no full response validation will be performed."
425
+ f"\nThe original error was: {error_message}"
426
+ )
427
+ server_validation_warning_logged = True
428
+ if disable_server_validation:
429
+ return
430
+
431
+ if response.status_code == invalid_property_default_response:
432
+ logger.debug(error_message)
433
+ return
434
+ if response_validation == ValidationLevel.STRICT:
435
+ logger.error(error_message)
436
+ raise exception
437
+ if response_validation == ValidationLevel.WARN:
438
+ logger.warn(error_message)
439
+ elif response_validation == ValidationLevel.INFO:
440
+ logger.info(error_message)
441
+
442
+
443
+ def _validate_value_type(value: Any, expected_type: str) -> None:
444
+ type_mapping = {
445
+ "string": str,
446
+ "number": float,
447
+ "integer": int,
448
+ "boolean": bool,
449
+ "array": list,
450
+ "object": dict,
451
+ }
452
+ python_type = type_mapping.get(expected_type, None)
453
+ if python_type is None:
454
+ raise AssertionError(f"Validation of type '{expected_type}' is not supported.")
455
+ if not isinstance(value, python_type):
456
+ raise AssertionError(f"{value} is not of type {expected_type}")
457
+
458
+
459
+ def _validate_type_of_extra_properties(
460
+ extra_properties: dict[str, Any], expected_type: str
461
+ ) -> None:
462
+ type_mapping = {
463
+ "string": str,
464
+ "number": float,
465
+ "integer": int,
466
+ "boolean": bool,
467
+ "array": list,
468
+ "object": dict,
469
+ }
470
+
471
+ python_type = type_mapping.get(expected_type, None)
472
+ if python_type is None:
473
+ logger.warn(
474
+ f"Additonal properties were not validated: "
475
+ f"type '{expected_type}' is not supported."
476
+ )
477
+ return
478
+
479
+ invalid_extra_properties = {
480
+ key: value
481
+ for key, value in extra_properties.items()
482
+ if not isinstance(value, python_type)
483
+ }
484
+ if invalid_extra_properties:
485
+ raise AssertionError(
486
+ f"Response contains invalid additionalProperties: "
487
+ f"{invalid_extra_properties} are not of type {expected_type}."
488
+ )
489
+
490
+
491
+ def _get_response_spec(
492
+ path: str, method: str, status_code: int, openapi_spec: dict[str, Any]
493
+ ) -> dict[str, Any]:
494
+ method = method.lower()
495
+ status = str(status_code)
496
+ spec: dict[str, Any] = {**openapi_spec}["paths"][path][method]["responses"][status]
497
+ return spec