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.
Files changed (63) hide show
  1. OpenApiDriver/__init__.py +45 -41
  2. OpenApiDriver/openapi_executors.py +78 -49
  3. OpenApiDriver/openapi_reader.py +114 -116
  4. OpenApiDriver/openapidriver.libspec +209 -133
  5. OpenApiDriver/openapidriver.py +31 -296
  6. OpenApiLibCore/__init__.py +39 -13
  7. OpenApiLibCore/annotations.py +10 -0
  8. OpenApiLibCore/data_generation/__init__.py +10 -0
  9. OpenApiLibCore/data_generation/body_data_generation.py +250 -0
  10. OpenApiLibCore/data_generation/data_generation_core.py +233 -0
  11. OpenApiLibCore/data_invalidation.py +294 -0
  12. OpenApiLibCore/dto_base.py +67 -130
  13. OpenApiLibCore/dto_utils.py +125 -85
  14. OpenApiLibCore/localized_faker.py +88 -0
  15. OpenApiLibCore/models.py +723 -0
  16. OpenApiLibCore/oas_cache.py +14 -13
  17. OpenApiLibCore/openapi_libcore.libspec +355 -330
  18. OpenApiLibCore/openapi_libcore.py +385 -1953
  19. OpenApiLibCore/parameter_utils.py +97 -0
  20. OpenApiLibCore/path_functions.py +215 -0
  21. OpenApiLibCore/path_invalidation.py +42 -0
  22. OpenApiLibCore/protocols.py +38 -0
  23. OpenApiLibCore/request_data.py +246 -0
  24. OpenApiLibCore/resource_relations.py +55 -0
  25. OpenApiLibCore/validation.py +380 -0
  26. OpenApiLibCore/value_utils.py +216 -481
  27. openapi_libgen/__init__.py +3 -0
  28. openapi_libgen/command_line.py +75 -0
  29. openapi_libgen/generator.py +82 -0
  30. openapi_libgen/parsing_utils.py +30 -0
  31. openapi_libgen/spec_parser.py +154 -0
  32. openapi_libgen/templates/__init__.jinja +3 -0
  33. openapi_libgen/templates/library.jinja +30 -0
  34. robotframework_openapitools-1.0.0.dist-info/METADATA +249 -0
  35. robotframework_openapitools-1.0.0.dist-info/RECORD +40 -0
  36. {robotframework_openapitools-0.4.0.dist-info → robotframework_openapitools-1.0.0.dist-info}/WHEEL +1 -1
  37. robotframework_openapitools-1.0.0.dist-info/entry_points.txt +3 -0
  38. roboswag/__init__.py +0 -9
  39. roboswag/__main__.py +0 -3
  40. roboswag/auth.py +0 -44
  41. roboswag/cli.py +0 -80
  42. roboswag/core.py +0 -85
  43. roboswag/generate/__init__.py +0 -1
  44. roboswag/generate/generate.py +0 -121
  45. roboswag/generate/models/__init__.py +0 -0
  46. roboswag/generate/models/api.py +0 -219
  47. roboswag/generate/models/definition.py +0 -28
  48. roboswag/generate/models/endpoint.py +0 -68
  49. roboswag/generate/models/parameter.py +0 -25
  50. roboswag/generate/models/response.py +0 -8
  51. roboswag/generate/models/tag.py +0 -16
  52. roboswag/generate/models/utils.py +0 -60
  53. roboswag/generate/templates/api_init.jinja +0 -15
  54. roboswag/generate/templates/models.jinja +0 -7
  55. roboswag/generate/templates/paths.jinja +0 -68
  56. roboswag/logger.py +0 -33
  57. roboswag/validate/__init__.py +0 -6
  58. roboswag/validate/core.py +0 -3
  59. roboswag/validate/schema.py +0 -21
  60. roboswag/validate/text_response.py +0 -14
  61. robotframework_openapitools-0.4.0.dist-info/METADATA +0 -42
  62. robotframework_openapitools-0.4.0.dist-info/RECORD +0 -41
  63. {robotframework_openapitools-0.4.0.dist-info → robotframework_openapitools-1.0.0.dist-info}/LICENSE +0 -0
@@ -1,629 +1,104 @@
1
- """
2
- # OpenApiLibCore for Robot Framework
3
-
4
- The OpenApiLibCore library is a utility library that is meant to simplify creation
5
- of other Robot Framework libraries for API testing based on the information in
6
- an OpenAPI document (also known as Swagger document).
7
- This document explains how to use the OpenApiLibCore library.
8
-
9
- My RoboCon 2022 talk about OpenApiDriver and OpenApiLibCore can be found
10
- [here](https://www.youtube.com/watch?v=7YWZEHxk9Ps)
11
-
12
- For more information about Robot Framework, see http://robotframework.org.
13
-
14
- ---
15
-
16
- > Note: OpenApiLibCore is still being developed so there are currently
17
- restrictions / limitations that you may encounter when using this library to run
18
- tests against an API. See [Limitations](#limitations) for details.
19
-
20
- ---
21
-
22
- ## Installation
23
-
24
- If you already have Python >= 3.8 with pip installed, you can simply run:
25
-
26
- `pip install --upgrade robotframework-openapi-libcore`
27
-
28
- ---
29
-
30
- ## OpenAPI (aka Swagger)
31
-
32
- The OpenAPI Specification (OAS) defines a standard, language-agnostic interface
33
- to RESTful APIs, see https://swagger.io/specification/
34
-
35
- The OpenApiLibCore implements a number of Robot Framework keywords that make it
36
- easy to interact with an OpenAPI implementation by using the information in the
37
- openapi document (Swagger file), for examply by automatic generation of valid values
38
- for requests based on the schema information in the document.
39
-
40
- > Note: OpenApiLibCore is designed for APIs based on the OAS v3
41
- The library has not been tested for APIs based on the OAS v2.
42
-
43
- ---
44
-
45
- ## Getting started
46
-
47
- Before trying to use the keywords exposed by OpenApiLibCore on the target API
48
- it's recommended to first ensure that the openapi document for the API is valid
49
- under the OpenAPI Specification.
50
-
51
- This can be done using the command line interface of a package that is installed as
52
- a prerequisite for OpenApiLibCore.
53
- Both a local openapi.json or openapi.yaml file or one hosted by the API server
54
- can be checked using the `prance validate <reference_to_file>` shell command:
55
-
56
- ```shell
57
- prance validate --backend=openapi-spec-validator http://localhost:8000/openapi.json
58
- Processing "http://localhost:8000/openapi.json"...
59
- -> Resolving external references.
60
- Validates OK as OpenAPI 3.0.2!
61
-
62
- prance validate --backend=openapi-spec-validator /tests/files/petstore_openapi.yaml
63
- Processing "/tests/files/petstore_openapi.yaml"...
64
- -> Resolving external references.
65
- Validates OK as OpenAPI 3.0.2!
66
- ```
67
-
68
- You'll have to change the url or file reference to the location of the openapi
69
- document for your API.
70
-
71
- > Note: Although recursion is technically allowed under the OAS, tool support is limited
72
- and changing the OAS to not use recursion is recommended.
73
- OpenApiLibCore has limited support for parsing OpenAPI documents with
74
- recursion in them. See the `recursion_limit` and `recursion_default` parameters.
75
-
76
- If the openapi document passes this validation, the next step is trying to do a test
77
- run with a minimal test suite.
78
- The example below can be used, with `source`, `origin` and 'endpoint' altered to
79
- fit your situation.
80
-
81
- ``` robotframework
82
- *** Settings ***
83
- Library OpenApiLibCore
84
- ... source=http://localhost:8000/openapi.json
85
- ... origin=http://localhost:8000
86
-
87
- *** Test Cases ***
88
- Getting Started
89
- ${url}= Get Valid Url endpoint=/employees/{employee_id} method=get
90
-
91
- ```
92
-
93
- Running the above suite for the first time may result in an error / failed test.
94
- You should look at the Robot Framework `log.html` to determine the reasons
95
- for the failing tests.
96
- Depending on the reasons for the failures, different solutions are possible.
97
-
98
- Details about the OpenApiLibCore library parameters and keywords that you may need can be found
99
- [here](https://marketsquare.github.io/robotframework-openapi-libcore/openapi_libcore.html).
100
-
101
- The OpenApiLibCore also support handling of relations between resources within the scope
102
- of the API being validated as well as handling dependencies on resources outside the
103
- scope of the API. In addition there is support for handling restrictions on the values
104
- of parameters and properties.
105
-
106
- Details about the `mappings_path` variable usage can be found
107
- [here](https://marketsquare.github.io/robotframework-openapi-libcore/advanced_use.html).
108
-
109
- ---
110
-
111
- ## Limitations
112
-
113
- There are currently a number of limitations to supported API structures, supported
114
- data types and properties. The following list details the most important ones:
115
- - Only JSON request and response bodies are supported.
116
- - No support for per-endpoint authorization levels.
117
- - Parsing of OAS 3.1 documents is supported by the parsing tools, but runtime behavior is untested.
118
-
119
- """
120
-
121
1
  import json as _json
122
- import re
123
2
  import sys
3
+ from collections.abc import Mapping, MutableMapping
124
4
  from copy import deepcopy
125
- from dataclasses import Field, dataclass, field, make_dataclass
126
- from enum import Enum
127
5
  from functools import cached_property
128
- from itertools import zip_longest
129
- from logging import getLogger
130
6
  from pathlib import Path
131
- from random import choice, sample
132
- from typing import (
133
- Any,
134
- Callable,
135
- Dict,
136
- Generator,
137
- List,
138
- Optional,
139
- Set,
140
- Tuple,
141
- Type,
142
- Union,
143
- )
144
- from uuid import uuid4
7
+ from types import MappingProxyType
8
+ from typing import Any, Generator
145
9
 
146
10
  from openapi_core import Config, OpenAPI, Spec
147
- from openapi_core.contrib.requests import (
148
- RequestsOpenAPIRequest,
149
- RequestsOpenAPIResponse,
150
- )
151
- from openapi_core.exceptions import OpenAPIError
152
- from openapi_core.templating.paths.exceptions import ServerNotFound
153
11
  from openapi_core.validation.exceptions import ValidationError
154
- from openapi_core.validation.response.exceptions import ResponseValidationError
155
- from openapi_core.validation.schemas.exceptions import InvalidSchemaValue
156
12
  from prance import ResolvingParser
157
13
  from prance.util.url import ResolutionError
158
14
  from requests import Response, Session
159
15
  from requests.auth import AuthBase, HTTPBasicAuth
160
16
  from requests.cookies import RequestsCookieJar as CookieJar
17
+ from robot.api import logger
161
18
  from robot.api.deco import keyword, library
162
- from robot.api.exceptions import Failure
19
+ from robot.api.exceptions import FatalError
163
20
  from robot.libraries.BuiltIn import BuiltIn
164
21
 
165
- from OpenApiLibCore import value_utils
166
- from OpenApiLibCore.dto_base import (
167
- NOT_SET,
168
- Dto,
169
- IdDependency,
170
- IdReference,
171
- PathPropertiesConstraint,
172
- PropertyValueConstraint,
173
- Relation,
174
- UniquePropertyValueConstraint,
175
- resolve_schema,
176
- )
22
+ import OpenApiLibCore.data_generation as _data_generation
23
+ import OpenApiLibCore.data_invalidation as _data_invalidation
24
+ import OpenApiLibCore.path_functions as _path_functions
25
+ import OpenApiLibCore.path_invalidation as _path_invalidation
26
+ import OpenApiLibCore.resource_relations as _resource_relations
27
+ import OpenApiLibCore.validation as _validation
28
+ from OpenApiLibCore.annotations import JSON
29
+ from OpenApiLibCore.dto_base import Dto, IdReference
177
30
  from OpenApiLibCore.dto_utils import (
178
31
  DEFAULT_ID_PROPERTY_NAME,
179
- DefaultDto,
180
32
  get_dto_class,
181
33
  get_id_property_name,
34
+ get_path_dto_class,
35
+ )
36
+ from OpenApiLibCore.localized_faker import FAKE
37
+ from OpenApiLibCore.models import (
38
+ OpenApiObject,
39
+ PathItemObject,
40
+ )
41
+ from OpenApiLibCore.oas_cache import PARSER_CACHE, CachedParser
42
+ from OpenApiLibCore.parameter_utils import (
43
+ get_oas_name_from_safe_name,
44
+ register_path_parameters,
45
+ )
46
+ from OpenApiLibCore.protocols import ResponseValidatorType
47
+ from OpenApiLibCore.request_data import RequestData, RequestValues
48
+ from openapitools_docs.docstrings import (
49
+ OPENAPILIBCORE_INIT_DOCSTRING,
50
+ OPENAPILIBCORE_LIBRARY_DOCSTRING,
182
51
  )
183
- from OpenApiLibCore.oas_cache import PARSER_CACHE
184
- from OpenApiLibCore.value_utils import FAKE, IGNORE, JSON
185
52
 
186
53
  run_keyword = BuiltIn().run_keyword
54
+ default_str_mapping: Mapping[str, str] = MappingProxyType({})
55
+ default_json_mapping: Mapping[str, JSON] = MappingProxyType({})
187
56
 
188
- logger = getLogger(__name__)
189
-
190
-
191
- class ValidationLevel(str, Enum):
192
- """The available levels for the response_validation parameter."""
193
-
194
- DISABLED = "DISABLED"
195
- INFO = "INFO"
196
- WARN = "WARN"
197
- STRICT = "STRICT"
198
-
199
-
200
- def get_safe_key(key: str) -> str:
201
- """
202
- Helper function to convert a valid JSON property name to a string that can be used
203
- as a Python variable or function / method name.
204
- """
205
- key = key.replace("-", "_")
206
- key = key.replace("@", "_")
207
- if key[0].isdigit():
208
- key = f"_{key}"
209
- return key
210
-
211
-
212
- @dataclass
213
- class RequestValues:
214
- """Helper class to hold parameter values needed to make a request."""
215
-
216
- url: str
217
- method: str
218
- params: Optional[Dict[str, Any]]
219
- headers: Optional[Dict[str, str]]
220
- json_data: Optional[Dict[str, Any]]
221
-
222
-
223
- @dataclass
224
- class RequestData:
225
- """Helper class to manage parameters used when making requests."""
226
-
227
- dto: Union[Dto, DefaultDto] = field(default_factory=DefaultDto)
228
- dto_schema: Dict[str, Any] = field(default_factory=dict)
229
- parameters: List[Dict[str, Any]] = field(default_factory=list)
230
- params: Dict[str, Any] = field(default_factory=dict)
231
- headers: Dict[str, Any] = field(default_factory=dict)
232
- has_body: bool = True
233
-
234
- def __post_init__(self) -> None:
235
- # prevent modification by reference
236
- self.dto_schema = deepcopy(self.dto_schema)
237
- self.parameters = deepcopy(self.parameters)
238
- self.params = deepcopy(self.params)
239
- self.headers = deepcopy(self.headers)
240
-
241
- @property
242
- def has_optional_properties(self) -> bool:
243
- """Whether or not the dto data (json data) contains optional properties."""
244
-
245
- def is_required_property(property_name: str) -> bool:
246
- return property_name in self.dto_schema.get("required", [])
247
-
248
- properties = (self.dto.as_dict()).keys()
249
- return not all(map(is_required_property, properties))
250
-
251
- @property
252
- def has_optional_params(self) -> bool:
253
- """Whether or not any of the query parameters are optional."""
254
-
255
- def is_optional_param(query_param: str) -> bool:
256
- optional_params = [
257
- p.get("name")
258
- for p in self.parameters
259
- if p.get("in") == "query" and not p.get("required")
260
- ]
261
- return query_param in optional_params
262
57
 
263
- return any(map(is_optional_param, self.params))
264
-
265
- @cached_property
266
- def params_that_can_be_invalidated(self) -> Set[str]:
267
- """
268
- The query parameters that can be invalidated by violating data
269
- restrictions, data type or by not providing them in a request.
270
- """
271
- result = set()
272
- params = [h for h in self.parameters if h.get("in") == "query"]
273
- for param in params:
274
- # required params can be omitted to invalidate a request
275
- if param["required"]:
276
- result.add(param["name"])
277
- continue
278
-
279
- schema = resolve_schema(param["schema"])
280
- if schema.get("type", None):
281
- param_types = [schema]
282
- else:
283
- param_types = schema["types"]
284
- for param_type in param_types:
285
- # any basic non-string type except "null" can be invalidated by
286
- # replacing it with a string
287
- if param_type["type"] not in ["string", "array", "object", "null"]:
288
- result.add(param["name"])
289
- continue
290
- # enums, strings and arrays with boundaries can be invalidated
291
- if set(param_type.keys()).intersection(
292
- {
293
- "enum",
294
- "minLength",
295
- "maxLength",
296
- "minItems",
297
- "maxItems",
298
- }
299
- ):
300
- result.add(param["name"])
301
- continue
302
- # an array of basic non-string type can be invalidated by replacing the
303
- # items in the array with strings
304
- if param_type["type"] == "array" and param_type["items"][
305
- "type"
306
- ] not in [
307
- "string",
308
- "array",
309
- "object",
310
- "null",
311
- ]:
312
- result.add(param["name"])
313
- return result
314
-
315
- @property
316
- def has_optional_headers(self) -> bool:
317
- """Whether or not any of the headers are optional."""
318
-
319
- def is_optional_header(header: str) -> bool:
320
- optional_headers = [
321
- p.get("name")
322
- for p in self.parameters
323
- if p.get("in") == "header" and not p.get("required")
324
- ]
325
- return header in optional_headers
326
-
327
- return any(map(is_optional_header, self.headers))
328
-
329
- @cached_property
330
- def headers_that_can_be_invalidated(self) -> Set[str]:
331
- """
332
- The header parameters that can be invalidated by violating data
333
- restrictions or by not providing them in a request.
334
- """
335
- result = set()
336
- headers = [h for h in self.parameters if h.get("in") == "header"]
337
- for header in headers:
338
- # required headers can be omitted to invalidate a request
339
- if header["required"]:
340
- result.add(header["name"])
341
- continue
342
-
343
- schema = resolve_schema(header["schema"])
344
- if schema.get("type", None):
345
- header_types = [schema]
346
- else:
347
- header_types = schema["types"]
348
- for header_type in header_types:
349
- # any basic non-string type except "null" can be invalidated by
350
- # replacing it with a string
351
- if header_type["type"] not in ["string", "array", "object", "null"]:
352
- result.add(header["name"])
353
- continue
354
- # enums, strings and arrays with boundaries can be invalidated
355
- if set(header_type.keys()).intersection(
356
- {
357
- "enum",
358
- "minLength",
359
- "maxLength",
360
- "minItems",
361
- "maxItems",
362
- }
363
- ):
364
- result.add(header["name"])
365
- continue
366
- # an array of basic non-string type can be invalidated by replacing the
367
- # items in the array with strings
368
- if header_type["type"] == "array" and header_type["items"][
369
- "type"
370
- ] not in [
371
- "string",
372
- "array",
373
- "object",
374
- "null",
375
- ]:
376
- result.add(header["name"])
377
- return result
378
-
379
- def get_required_properties_dict(self) -> Dict[str, Any]:
380
- """Get the json-compatible dto data containing only the required properties."""
381
- relations = self.dto.get_relations()
382
- mandatory_properties = [
383
- relation.property_name
384
- for relation in relations
385
- if getattr(relation, "treat_as_mandatory", False)
386
- ]
387
- required_properties: List[str] = self.dto_schema.get("required", [])
388
- required_properties.extend(mandatory_properties)
389
-
390
- required_properties_dict: Dict[str, Any] = {}
391
- for key, value in (self.dto.as_dict()).items():
392
- if key in required_properties:
393
- required_properties_dict[key] = value
394
- return required_properties_dict
395
-
396
- def get_minimal_body_dict(self) -> Dict[str, Any]:
397
- required_properties_dict = self.get_required_properties_dict()
398
-
399
- min_properties = self.dto_schema.get("minProperties", 0)
400
- number_of_optional_properties_to_add = min_properties - len(
401
- required_properties_dict
402
- )
403
-
404
- if number_of_optional_properties_to_add < 1:
405
- return required_properties_dict
406
-
407
- optional_properties_dict = {
408
- k: v
409
- for k, v in self.dto.as_dict().items()
410
- if k not in required_properties_dict
411
- }
412
- optional_properties_to_keep = sample(
413
- sorted(optional_properties_dict), number_of_optional_properties_to_add
414
- )
415
- optional_properties_dict = {
416
- k: v
417
- for k, v in optional_properties_dict.items()
418
- if k in optional_properties_to_keep
419
- }
420
-
421
- return {**required_properties_dict, **optional_properties_dict}
422
-
423
- def get_required_params(self) -> Dict[str, str]:
424
- """Get the params dict containing only the required query parameters."""
425
- relations = self.dto.get_parameter_relations()
426
- mandatory_properties = [
427
- relation.property_name
428
- for relation in relations
429
- if getattr(relation, "treat_as_mandatory", False)
430
- ]
431
- mandatory_parameters = [p for p in mandatory_properties if p in self.parameters]
432
-
433
- required_parameters = [
434
- p.get("name") for p in self.parameters if p.get("required")
435
- ]
436
- required_parameters.extend(mandatory_parameters)
437
- return {k: v for k, v in self.params.items() if k in required_parameters}
438
-
439
- def get_required_headers(self) -> Dict[str, str]:
440
- """Get the headers dict containing only the required headers."""
441
- relations = self.dto.get_parameter_relations()
442
- mandatory_properties = [
443
- relation.property_name
444
- for relation in relations
445
- if getattr(relation, "treat_as_mandatory", False)
446
- ]
447
- mandatory_parameters = [p for p in mandatory_properties if p in self.parameters]
448
-
449
- required_parameters = [
450
- p.get("name") for p in self.parameters if p.get("required")
451
- ]
452
- required_parameters.extend(mandatory_parameters)
453
- return {k: v for k, v in self.headers.items() if k in required_parameters}
454
-
455
-
456
- @library(scope="SUITE", doc_format="ROBOT")
457
- class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
458
- """
459
- Main class providing the keywords and core logic to interact with an OpenAPI server.
460
-
461
- Visit the [https://github.com/MarketSquare/robotframework-openapi-libcore | library page]
462
- for an introduction.
463
- """
464
-
465
- def __init__( # pylint: disable=too-many-arguments, too-many-locals, dangerous-default-value
58
+ @library(scope="SUITE", doc_format="HTML")
59
+ class OpenApiLibCore: # pylint: disable=too-many-public-methods
60
+ def __init__( # noqa: PLR0913, pylint: disable=dangerous-default-value
466
61
  self,
467
62
  source: str,
468
63
  origin: str = "",
469
64
  base_path: str = "",
470
- response_validation: ValidationLevel = ValidationLevel.WARN,
65
+ response_validation: _validation.ValidationLevel = _validation.ValidationLevel.WARN,
471
66
  disable_server_validation: bool = True,
472
- mappings_path: Union[str, Path] = "",
67
+ mappings_path: str | Path = "",
473
68
  invalid_property_default_response: int = 422,
474
69
  default_id_property_name: str = "id",
475
- faker_locale: Optional[Union[str, List[str]]] = None,
70
+ faker_locale: str | list[str] = "",
476
71
  require_body_for_invalid_url: bool = False,
477
72
  recursion_limit: int = 1,
478
- recursion_default: Any = {},
73
+ recursion_default: JSON = {},
479
74
  username: str = "",
480
75
  password: str = "",
481
76
  security_token: str = "",
482
- auth: Optional[AuthBase] = None,
483
- cert: Optional[Union[str, Tuple[str, str]]] = None,
484
- verify_tls: Optional[Union[bool, str]] = True,
485
- extra_headers: Optional[Dict[str, str]] = None,
486
- cookies: Optional[Union[Dict[str, str], CookieJar]] = None,
487
- proxies: Optional[Dict[str, str]] = None,
77
+ auth: AuthBase | None = None,
78
+ cert: str | tuple[str, str] = "",
79
+ verify_tls: bool | str = True,
80
+ extra_headers: Mapping[str, str] = default_str_mapping,
81
+ cookies: MutableMapping[str, str] | CookieJar | None = None,
82
+ proxies: MutableMapping[str, str] | None = None,
488
83
  ) -> None:
489
- """
490
- == Base parameters ==
491
-
492
- === source ===
493
- An absolute path to an openapi.json or openapi.yaml file or an url to such a file.
494
-
495
- === origin ===
496
- The server (and port) of the target server. E.g. ``https://localhost:8000``
497
-
498
- === base_path ===
499
- The routing between ``origin`` and the endpoints as found in the ``paths``
500
- section in the openapi document.
501
- E.g. ``/petshop/v2``.
502
-
503
- == Test case execution ==
504
-
505
- === response_validation ===
506
- By default, a ``WARN`` is logged when the Response received after a Request does not
507
- comply with the schema as defined in the openapi document for the given operation. The
508
- following values are supported:
509
-
510
- - ``DISABLED``: All Response validation errors will be ignored
511
- - ``INFO``: Any Response validation erros will be logged at ``INFO`` level
512
- - ``WARN``: Any Response validation erros will be logged at ``WARN`` level
513
- - ``STRICT``: The Test Case will fail on any Response validation errors
514
-
515
- === disable_server_validation ===
516
- If enabled by setting this parameter to ``True``, the Response validation will also
517
- include possible errors for Requests made to a server address that is not defined in
518
- the list of servers in the openapi document. This generally means that if there is a
519
- mismatch, every Test Case will raise this error. Note that ``localhost`` and
520
- ``127.0.0.1`` are not considered the same by Response validation.
521
-
522
- == API-specific configurations ==
523
-
524
- === mappings_path ===
525
- See [https://marketsquare.github.io/robotframework-openapi-libcore/advanced_use.html | this page]
526
- for an in-depth explanation.
527
-
528
- === invalid_property_default_response ===
529
- The default response code for requests with a JSON body that does not comply
530
- with the schema.
531
- Example: a value outside the specified range or a string value
532
- for a property defined as integer in the schema.
533
-
534
- === default_id_property_name ===
535
- The default name for the property that identifies a resource (i.e. a unique
536
- entity) within the API.
537
- The default value for this property name is ``id``.
538
- If the target API uses a different name for all the resources within the API,
539
- you can configure it globally using this property.
540
-
541
- If different property names are used for the unique identifier for different
542
- types of resources, an ``ID_MAPPING`` can be implemented using the ``mappings_path``.
543
-
544
- === faker_locale ===
545
- A locale string or list of locale strings to pass to the Faker library to be
546
- used in generation of string data for supported format types.
547
-
548
- === require_body_for_invalid_url ===
549
- When a request is made against an invalid url, this usually is because of a "404" request;
550
- a request for a resource that does not exist. Depending on API implementation, when a
551
- request with a missing or invalid request body is made on a non-existent resource,
552
- either a 404 or a 422 or 400 Response is normally returned. If the API being tested
553
- processes the request body before checking if the requested resource exists, set
554
- this parameter to True.
555
-
556
- == Parsing parameters ==
557
-
558
- === recursion_limit ===
559
- The recursion depth to which to fully parse recursive references before the
560
- `recursion_default` is used to end the recursion.
561
-
562
- === recursion_default ===
563
- The value that is used instead of the referenced schema when the
564
- `recursion_limit` has been reached.
565
- The default `{}` represents an empty object in JSON.
566
- Depending on schema definitions, this may cause schema validation errors.
567
- If this is the case, 'None' (``${NONE}`` in Robot Framework) or an empty list
568
- can be tried as an alternative.
569
-
570
- == Security-related parameters ==
571
- _Note: these parameters are equivalent to those in the ``requests`` library._
572
-
573
- === username ===
574
- The username to be used for Basic Authentication.
575
-
576
- === password ===
577
- The password to be used for Basic Authentication.
578
-
579
- === security_token ===
580
- The token to be used for token based security using the ``Authorization`` header.
581
-
582
- === auth ===
583
- A [https://requests.readthedocs.io/en/latest/api/#authentication | requests ``AuthBase`` instance]
584
- to be used for authentication instead of the ``username`` and ``password``.
585
-
586
- === cert ===
587
- The SSL certificate to use with all requests.
588
- If string: the path to ssl client cert file (.pem).
589
- If tuple: the ('cert', 'key') pair.
590
-
591
- === verify_tls ===
592
- Whether or not to verify the TLS / SSL certificate of the server.
593
- If boolean: whether or not to verify the server TLS certificate.
594
- If string: path to a CA bundle to use for verification.
595
-
596
- === extra_headers ===
597
- A dictionary with extra / custom headers that will be send with every request.
598
- This parameter can be used to send headers that are not documented in the
599
- openapi document or to provide an API-key.
600
-
601
- === cookies ===
602
- A dictionary or
603
- [https://docs.python.org/3/library/http.cookiejar.html#http.cookiejar.CookieJar | CookieJar object]
604
- to send with all requests.
605
-
606
- === proxies ===
607
- A dictionary of 'protocol': 'proxy url' to use for all requests.
608
- """
609
84
  self._source = source
610
85
  self._origin = origin
611
86
  self._base_path = base_path
612
87
  self.response_validation = response_validation
613
88
  self.disable_server_validation = disable_server_validation
614
89
  self._recursion_limit = recursion_limit
615
- self._recursion_default = recursion_default
90
+ self._recursion_default = deepcopy(recursion_default)
616
91
  self.session = Session()
617
- # only username and password, security_token or auth object should be provided
92
+ # Only username and password, security_token or auth object should be provided
618
93
  # if multiple are provided, username and password take precedence
619
94
  self.security_token = security_token
620
95
  self.auth = auth
621
96
  if username:
622
97
  self.auth = HTTPBasicAuth(username, password)
623
- # Robot Framework does not allow users to create tuples and requests
624
- # does not accept lists, so perform the conversion here
625
- if isinstance(cert, list):
626
- cert = tuple(cert)
98
+ # Requests only allows a string or a tuple[str, str], so ensure cert is a tuple
99
+ # if the passed argument is not a string.
100
+ if not isinstance(cert, str):
101
+ cert = (cert[0], cert[1])
627
102
  self.cert = cert
628
103
  self.verify = verify_tls
629
104
  self.extra_headers = extra_headers
@@ -633,23 +108,27 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
633
108
  if mappings_path and str(mappings_path) != ".":
634
109
  mappings_path = Path(mappings_path)
635
110
  if not mappings_path.is_file():
636
- logger.warning(
637
- f"mappings_path '{mappings_path}' is not a Python module."
638
- )
639
- # intermediate variable to ensure path.append is possible so we'll never
640
- # path.pop a location that we didn't append
111
+ logger.warn(f"mappings_path '{mappings_path}' is not a Python module.")
112
+ # Intermediate variable to ensure path.append is possible so we'll never
113
+ # path.pop a location that we didn't append.
641
114
  mappings_folder = str(mappings_path.parent)
642
115
  sys.path.append(mappings_folder)
643
116
  mappings_module_name = mappings_path.stem
644
117
  self.get_dto_class = get_dto_class(
645
118
  mappings_module_name=mappings_module_name
646
119
  )
120
+ self.get_path_dto_class = get_path_dto_class(
121
+ mappings_module_name=mappings_module_name
122
+ )
647
123
  self.get_id_property_name = get_id_property_name(
648
124
  mappings_module_name=mappings_module_name
649
125
  )
650
126
  sys.path.pop()
651
127
  else:
652
128
  self.get_dto_class = get_dto_class(mappings_module_name="no mapping")
129
+ self.get_path_dto_class = get_path_dto_class(
130
+ mappings_module_name="no mapping"
131
+ )
653
132
  self.get_id_property_name = get_id_property_name(
654
133
  mappings_module_name="no mapping"
655
134
  )
@@ -660,10 +139,7 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
660
139
  DEFAULT_ID_PROPERTY_NAME.id_property_name = default_id_property_name
661
140
  self._server_validation_warning_logged = False
662
141
 
663
- @property
664
- def origin(self) -> str:
665
- return self._origin
666
-
142
+ # region: library configuration keywords
667
143
  @keyword
668
144
  def set_origin(self, origin: str) -> None:
669
145
  """
@@ -710,7 +186,7 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
710
186
  self.auth = auth
711
187
 
712
188
  @keyword
713
- def set_extra_headers(self, extra_headers: Dict[str, str]) -> None:
189
+ def set_extra_headers(self, extra_headers: dict[str, str]) -> None:
714
190
  """
715
191
  Set the `extra_headers` used in requests after the library is imported.
716
192
 
@@ -719,1022 +195,221 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
719
195
  """
720
196
  self.extra_headers = extra_headers
721
197
 
722
- @property
723
- def base_url(self) -> str:
724
- return f"{self.origin}{self._base_path}"
725
-
726
- @cached_property
727
- def validation_spec(self) -> Spec:
728
- _, validation_spec, _ = self._load_specs_and_validator()
729
- return validation_spec
730
-
731
- @property
732
- def openapi_spec(self) -> Dict[str, Any]:
733
- """Return a deepcopy of the parsed openapi document."""
734
- # protect the parsed openapi spec from being mutated by reference
735
- return deepcopy(self._openapi_spec)
736
-
737
- @cached_property
738
- def _openapi_spec(self) -> Dict[str, Any]:
739
- parser, _, _ = self._load_specs_and_validator()
740
- return parser.specification
741
-
742
- @cached_property
743
- def response_validator(
198
+ # endregion
199
+ # region: data generation keywords
200
+ @keyword
201
+ def get_request_values(
744
202
  self,
745
- ) -> Callable[[RequestsOpenAPIRequest, RequestsOpenAPIResponse], None]:
746
- _, _, response_validator = self._load_specs_and_validator()
747
- return response_validator
203
+ path: str,
204
+ method: str,
205
+ overrides: Mapping[str, JSON] = default_json_mapping,
206
+ ) -> RequestValues:
207
+ """Return an object with all (valid) request values needed to make a request."""
208
+ json_data: dict[str, JSON] = {}
748
209
 
749
- def _get_json_types_from_spec(self, spec: Dict[str, Any]) -> Set[str]:
750
- json_types: Set[str] = set(self._get_json_types(spec))
751
- return {json_type for json_type in json_types if json_type is not None}
210
+ url: str = run_keyword("get_valid_url", path)
211
+ request_data: RequestData = run_keyword("get_request_data", path, method)
212
+ params = request_data.params
213
+ headers = request_data.headers
214
+ if request_data.has_body:
215
+ json_data = request_data.dto.as_dict()
752
216
 
753
- def _get_json_types(self, item: Any) -> Generator[str, None, None]:
754
- if isinstance(item, dict):
755
- content_dict = item.get("content")
756
- if content_dict is None:
757
- for value in item.values():
758
- yield from self._get_json_types(value)
217
+ request_values = RequestValues(
218
+ url=url,
219
+ method=method,
220
+ params=params,
221
+ headers=headers,
222
+ json_data=json_data,
223
+ )
759
224
 
225
+ for name, value in overrides.items():
226
+ if name.startswith(("body_", "header_", "query_")):
227
+ location, _, name_ = name.partition("_")
228
+ oas_name = get_oas_name_from_safe_name(name_)
229
+ if location == "body":
230
+ request_values.override_body_value(name=oas_name, value=value)
231
+ if location == "header":
232
+ request_values.override_header_value(name=oas_name, value=value)
233
+ if location == "query":
234
+ request_values.override_param_value(name=oas_name, value=str(value))
760
235
  else:
761
- for content_type in content_dict:
762
- if "json" in content_type:
763
- content_type_without_charset, _, _ = content_type.partition(";")
764
- yield content_type_without_charset
765
-
766
- if isinstance(item, list):
767
- for list_item in item:
768
- yield from self._get_json_types(list_item)
769
-
770
- def _load_specs_and_validator(
771
- self,
772
- ) -> Tuple[
773
- ResolvingParser,
774
- Spec,
775
- Callable[[RequestsOpenAPIRequest, RequestsOpenAPIResponse], None],
776
- ]:
777
- try:
236
+ oas_name = get_oas_name_from_safe_name(name)
237
+ request_values.override_request_value(name=oas_name, value=value)
778
238
 
779
- def recursion_limit_handler(
780
- limit: int, refstring: str, recursions: Any
781
- ) -> Any:
782
- return self._recursion_default
783
-
784
- # Since parsing of the OAS and creating the Spec can take a long time,
785
- # they are cached. This is done by storing them in an imported module that
786
- # will have a global scope due to how the Python import system works. This
787
- # ensures that in a Suite of Suites where multiple Suites use the same
788
- # `source`, that OAS is only parsed / loaded once.
789
- parser, validation_spec, response_validator = PARSER_CACHE.get(
790
- self._source, (None, None, None)
791
- )
239
+ return request_values
792
240
 
793
- if parser is None:
794
- parser = ResolvingParser(
795
- self._source,
796
- backend="openapi-spec-validator",
797
- recursion_limit=self._recursion_limit,
798
- recursion_limit_handler=recursion_limit_handler,
799
- )
800
-
801
- if parser.specification is None: # pragma: no cover
802
- BuiltIn().fatal_error(
803
- "Source was loaded, but no specification was present after parsing."
804
- )
805
-
806
- validation_spec = Spec.from_dict(parser.specification)
807
-
808
- json_types_from_spec: Set[str] = self._get_json_types_from_spec(
809
- parser.specification
810
- )
811
- extra_deserializers = {
812
- json_type: _json.loads for json_type in json_types_from_spec
813
- }
814
- config = Config(extra_media_type_deserializers=extra_deserializers)
815
- openapi = OpenAPI(spec=validation_spec, config=config)
816
- response_validator = openapi.validate_response
817
-
818
- PARSER_CACHE[self._source] = (
819
- parser,
820
- validation_spec,
821
- response_validator,
822
- )
241
+ @keyword
242
+ def get_request_data(self, path: str, method: str) -> RequestData:
243
+ """Return an object with valid request data for body, headers and query params."""
244
+ return _data_generation.get_request_data(
245
+ path=path,
246
+ method=method,
247
+ get_dto_class=self.get_dto_class,
248
+ get_id_property_name=self.get_id_property_name,
249
+ openapi_spec=self.openapi_spec,
250
+ )
823
251
 
824
- return parser, validation_spec, response_validator
252
+ @keyword
253
+ def get_invalid_body_data(
254
+ self,
255
+ url: str,
256
+ method: str,
257
+ status_code: int,
258
+ request_data: RequestData,
259
+ ) -> dict[str, JSON]:
260
+ """
261
+ Return `json_data` based on the `dto` on the `request_data` that will cause
262
+ the provided `status_code` for the `method` operation on the `url`.
825
263
 
826
- except ResolutionError as exception:
827
- BuiltIn().fatal_error(
828
- f"ResolutionError while trying to load openapi spec: {exception}"
829
- )
830
- except ValidationError as exception:
831
- BuiltIn().fatal_error(
832
- f"ValidationError while trying to load openapi spec: {exception}"
833
- )
264
+ > Note: applicable UniquePropertyValueConstraint and IdReference Relations are
265
+ considered before changes to `json_data` are made.
266
+ """
267
+ return _data_invalidation.get_invalid_body_data(
268
+ url=url,
269
+ method=method,
270
+ status_code=status_code,
271
+ request_data=request_data,
272
+ invalid_property_default_response=self.invalid_property_default_response,
273
+ )
834
274
 
835
- def validate_response_vs_spec(
836
- self, request: RequestsOpenAPIRequest, response: RequestsOpenAPIResponse
837
- ) -> None:
275
+ @keyword
276
+ def get_invalidated_parameters(
277
+ self,
278
+ status_code: int,
279
+ request_data: RequestData,
280
+ ) -> tuple[dict[str, JSON], dict[str, JSON]]:
838
281
  """
839
- Validate the reponse for a given request against the OpenAPI Spec that is
840
- loaded during library initialization.
282
+ Returns a version of `params, headers` as present on `request_data` that has
283
+ been modified to cause the provided `status_code`.
841
284
  """
842
- self.response_validator(request=request, response=response)
285
+ return _data_invalidation.get_invalidated_parameters(
286
+ status_code=status_code,
287
+ request_data=request_data,
288
+ invalid_property_default_response=self.invalid_property_default_response,
289
+ )
843
290
 
844
- def read_paths(self) -> Dict[str, Any]:
845
- return self.openapi_spec["paths"]
291
+ @keyword
292
+ def get_json_data_with_conflict(
293
+ self, url: str, method: str, dto: Dto, conflict_status_code: int
294
+ ) -> dict[str, JSON]:
295
+ """
296
+ Return `json_data` based on the `UniquePropertyValueConstraint` that must be
297
+ returned by the `get_relations` implementation on the `dto` for the given
298
+ `conflict_status_code`.
299
+ """
300
+ return _data_invalidation.get_json_data_with_conflict(
301
+ url=url,
302
+ base_url=self.base_url,
303
+ method=method,
304
+ dto=dto,
305
+ conflict_status_code=conflict_status_code,
306
+ )
846
307
 
308
+ # endregion
309
+ # region: path-related keywords
847
310
  @keyword
848
- def get_valid_url(self, endpoint: str, method: str) -> str:
311
+ def get_valid_url(self, path: str) -> str:
849
312
  """
850
- This keyword returns a valid url for the given `endpoint` and `method`.
313
+ This keyword returns a valid url for the given `path`.
851
314
 
852
- If the `endpoint` contains path parameters the Get Valid Id For Endpoint
315
+ If the `path` contains path parameters the Get Valid Id For Path
853
316
  keyword will be executed to retrieve valid ids for the path parameters.
854
317
 
855
318
  > Note: if valid ids cannot be retrieved within the scope of the API, the
856
319
  `PathPropertiesConstraint` Relation can be used. More information can be found
857
- [https://marketsquare.github.io/robotframework-openapi-libcore/advanced_use.html | here].
320
+ [https://marketsquare.github.io/robotframework-openapitools/advanced_use.html | here].
858
321
  """
859
- method = method.lower()
860
- try:
861
- # endpoint can be partially resolved or provided by a PathPropertiesConstraint
862
- parametrized_endpoint = self.get_parametrized_endpoint(endpoint=endpoint)
863
- _ = self.openapi_spec["paths"][parametrized_endpoint]
864
- except KeyError:
865
- raise ValueError(
866
- f"{endpoint} not found in paths section of the OpenAPI document."
867
- ) from None
868
- dto_class = self.get_dto_class(endpoint=endpoint, method=method)
869
- relations = dto_class.get_relations()
870
- paths = [p.path for p in relations if isinstance(p, PathPropertiesConstraint)]
871
- if paths:
872
- url = f"{self.base_url}{choice(paths)}"
873
- return url
874
- endpoint_parts = list(endpoint.split("/"))
875
- for index, part in enumerate(endpoint_parts):
876
- if part.startswith("{") and part.endswith("}"):
877
- type_endpoint_parts = endpoint_parts[slice(index)]
878
- type_endpoint = "/".join(type_endpoint_parts)
879
- existing_id: Union[str, int, float] = run_keyword(
880
- "get_valid_id_for_endpoint", type_endpoint, method
881
- )
882
- endpoint_parts[index] = str(existing_id)
883
- resolved_endpoint = "/".join(endpoint_parts)
884
- url = f"{self.base_url}{resolved_endpoint}"
885
- return url
322
+ return _path_functions.get_valid_url(
323
+ path=path,
324
+ base_url=self.base_url,
325
+ get_path_dto_class=self.get_path_dto_class,
326
+ openapi_spec=self.openapi_spec,
327
+ )
886
328
 
887
329
  @keyword
888
- def get_valid_id_for_endpoint(
889
- self, endpoint: str, method: str
890
- ) -> Union[str, int, float]:
330
+ def get_valid_id_for_path(self, path: str) -> str | int | float:
891
331
  """
892
- Support keyword that returns the `id` for an existing resource at `endpoint`.
332
+ Support keyword that returns the `id` for an existing resource at `path`.
893
333
 
894
334
  To prevent resource conflicts with other test cases, a new resource is created
895
- (POST) if possible.
335
+ (by a POST operation) if possible.
896
336
  """
897
-
898
- def dummy_transformer(
899
- valid_id: Union[str, int, float]
900
- ) -> Union[str, int, float]:
901
- return valid_id
902
-
903
- method = method.lower()
904
- url: str = run_keyword("get_valid_url", endpoint, method)
905
- # Try to create a new resource to prevent conflicts caused by
906
- # operations performed on the same resource by other test cases
907
- request_data = self.get_request_data(endpoint=endpoint, method="post")
908
-
909
- response: Response = run_keyword(
910
- "authorized_request",
911
- url,
912
- "post",
913
- request_data.get_required_params(),
914
- request_data.get_required_headers(),
915
- request_data.get_required_properties_dict(),
337
+ return _path_functions.get_valid_id_for_path(
338
+ path=path, get_id_property_name=self.get_id_property_name
916
339
  )
917
340
 
918
- # determine the id property name for this path and whether or not a transformer is used
919
- mapping = self.get_id_property_name(endpoint=endpoint)
920
- if isinstance(mapping, str):
921
- id_property = mapping
922
- # set the transformer to a dummy callable that returns the original value so
923
- # the transformer can be applied on any returned id
924
- id_transformer = dummy_transformer
925
- else:
926
- id_property, id_transformer = mapping
927
-
928
- if not response.ok:
929
- # If a new resource cannot be created using POST, try to retrieve a
930
- # valid id using a GET request.
931
- try:
932
- valid_id = choice(run_keyword("get_ids_from_url", url))
933
- return id_transformer(valid_id)
934
- except Exception as exception:
935
- raise AssertionError(
936
- f"Failed to get a valid id using GET on {url}"
937
- ) from exception
938
-
939
- response_data = response.json()
940
- if prepared_body := response.request.body:
941
- if isinstance(prepared_body, bytes):
942
- send_json = _json.loads(prepared_body.decode("UTF-8"))
943
- else:
944
- send_json = _json.loads(prepared_body)
945
- else:
946
- send_json = None
947
-
948
- # no support for retrieving an id from an array returned on a POST request
949
- if isinstance(response_data, list):
950
- raise NotImplementedError(
951
- f"Unexpected response body for POST request: expected an object but "
952
- f"received an array ({response_data})"
953
- )
954
-
955
- # POST on /resource_type/{id}/array_item/ will return the updated {id} resource
956
- # instead of a newly created resource. In this case, the send_json must be
957
- # in the array of the 'array_item' property on {id}
958
- send_path: str = response.request.path_url
959
- response_href: Optional[str] = response_data.get("href", None)
960
- if response_href and (send_path not in response_href) and send_json:
961
- try:
962
- property_to_check = send_path.replace(response_href, "")[1:]
963
- item_list: List[Dict[str, Any]] = response_data[property_to_check]
964
- # Use the (mandatory) id to get the POSTed resource from the list
965
- [valid_id] = [
966
- item[id_property]
967
- for item in item_list
968
- if item[id_property] == send_json[id_property]
969
- ]
970
- except Exception as exception:
971
- raise AssertionError(
972
- f"Failed to get a valid id from {response_href}"
973
- ) from exception
974
- else:
975
- try:
976
- valid_id = response_data[id_property]
977
- except KeyError:
978
- raise AssertionError(
979
- f"Failed to get a valid id from {response_data}"
980
- ) from None
981
- return id_transformer(valid_id)
982
-
983
341
  @keyword
984
- def get_ids_from_url(self, url: str) -> List[str]:
342
+ def get_parameterized_path_from_url(self, url: str) -> str:
985
343
  """
986
- Perform a GET request on the `url` and return the list of resource
987
- `ids` from the response.
344
+ Return the path as found in the `paths` section based on the given `url`.
988
345
  """
989
- endpoint = self.get_parameterized_endpoint_from_url(url)
990
- request_data = self.get_request_data(endpoint=endpoint, method="get")
991
- response = run_keyword(
992
- "authorized_request",
993
- url,
994
- "get",
995
- request_data.get_required_params(),
996
- request_data.get_required_headers(),
997
- )
998
- response.raise_for_status()
999
- response_data: Union[Dict[str, Any], List[Dict[str, Any]]] = response.json()
1000
-
1001
- # determine the property name to use
1002
- mapping = self.get_id_property_name(endpoint=endpoint)
1003
- if isinstance(mapping, str):
1004
- id_property = mapping
1005
- else:
1006
- id_property, _ = mapping
1007
-
1008
- if isinstance(response_data, list):
1009
- valid_ids: List[str] = [item[id_property] for item in response_data]
1010
- return valid_ids
1011
- # if the response is an object (dict), check if it's hal+json
1012
- if embedded := response_data.get("_embedded"):
1013
- # there should be 1 item in the dict that has a value that's a list
1014
- for value in embedded.values():
1015
- if isinstance(value, list):
1016
- valid_ids = [item[id_property] for item in value]
1017
- return valid_ids
1018
- if (valid_id := response_data.get(id_property)) is not None:
1019
- return [valid_id]
1020
- valid_ids = [item[id_property] for item in response_data["items"]]
1021
- return valid_ids
1022
-
1023
- @keyword
1024
- def get_request_data(self, endpoint: str, method: str) -> RequestData:
1025
- """Return an object with valid request data for body, headers and query params."""
1026
- method = method.lower()
1027
- dto_cls_name = self._get_dto_cls_name(endpoint=endpoint, method=method)
1028
- # The endpoint can contain already resolved Ids that have to be matched
1029
- # against the parametrized endpoints in the paths section.
1030
- spec_endpoint = self.get_parametrized_endpoint(endpoint)
1031
- dto_class = self.get_dto_class(endpoint=spec_endpoint, method=method)
1032
- try:
1033
- method_spec = self.openapi_spec["paths"][spec_endpoint][method]
1034
- except KeyError:
1035
- logger.info(
1036
- f"method '{method}' not supported on '{spec_endpoint}, using empty spec."
1037
- )
1038
- method_spec = {}
1039
-
1040
- parameters, params, headers = self.get_request_parameters(
1041
- dto_class=dto_class, method_spec=method_spec
1042
- )
1043
- if (body_spec := method_spec.get("requestBody", None)) is None:
1044
- if dto_class == DefaultDto:
1045
- dto_instance: Dto = DefaultDto()
1046
- else:
1047
- dto_class = make_dataclass(
1048
- cls_name=method_spec.get("operationId", dto_cls_name),
1049
- fields=[],
1050
- bases=(dto_class,),
1051
- )
1052
- dto_instance = dto_class()
1053
- return RequestData(
1054
- dto=dto_instance,
1055
- parameters=parameters,
1056
- params=params,
1057
- headers=headers,
1058
- has_body=False,
1059
- )
1060
- content_schema = resolve_schema(self.get_content_schema(body_spec))
1061
- headers.update({"content-type": self.get_content_type(body_spec)})
1062
- dto_data = self.get_json_data_for_dto_class(
1063
- schema=content_schema,
1064
- dto_class=dto_class,
1065
- operation_id=method_spec.get("operationId", ""),
1066
- )
1067
- if dto_data is None:
1068
- dto_instance = DefaultDto()
1069
- else:
1070
- fields = self.get_fields_from_dto_data(content_schema, dto_data)
1071
- dto_class = make_dataclass(
1072
- cls_name=method_spec.get("operationId", dto_cls_name),
1073
- fields=fields,
1074
- bases=(dto_class,),
1075
- )
1076
- dto_data = {get_safe_key(key): value for key, value in dto_data.items()}
1077
- dto_instance = dto_class(**dto_data)
1078
- return RequestData(
1079
- dto=dto_instance,
1080
- dto_schema=content_schema,
1081
- parameters=parameters,
1082
- params=params,
1083
- headers=headers,
1084
- )
1085
-
1086
- @staticmethod
1087
- def _get_dto_cls_name(endpoint: str, method: str) -> str:
1088
- method = method.capitalize()
1089
- path = endpoint.translate({ord(i): None for i in "{}"})
346
+ path = url.replace(self.base_url, "")
1090
347
  path_parts = path.split("/")
1091
- path_parts = [p.capitalize() for p in path_parts]
1092
- result = "".join([method, *path_parts])
1093
- return result
1094
-
1095
- @staticmethod
1096
- def get_fields_from_dto_data(
1097
- content_schema: Dict[str, Any], dto_data: Dict[str, Any]
1098
- ):
1099
- # FIXME: annotation is not Pyhon 3.8-compatible
1100
- # ) -> List[Union[str, Tuple[str, Type[Any]], Tuple[str, Type[Any], Field[Any]]]]:
1101
- """Get a dataclasses fields list based on the content_schema and dto_data."""
1102
- fields: List[
1103
- Union[str, Tuple[str, Type[Any]], Tuple[str, Type[Any], Field[Any]]]
1104
- ] = []
1105
- for key, value in dto_data.items():
1106
- required_properties = content_schema.get("required", [])
1107
- safe_key = get_safe_key(key)
1108
- metadata = {"original_property_name": key}
1109
- if key in required_properties:
1110
- # The fields list is used to create a dataclass, so non-default fields
1111
- # must go before fields with a default
1112
- fields.insert(0, (safe_key, type(value), field(metadata=metadata)))
1113
- else:
1114
- fields.append((safe_key, type(value), field(default=None, metadata=metadata))) # type: ignore[arg-type]
1115
- return fields
1116
-
1117
- def get_request_parameters(
1118
- self, dto_class: Union[Dto, Type[Dto]], method_spec: Dict[str, Any]
1119
- ) -> Tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, str]]:
1120
- """Get the methods parameter spec and params and headers with valid data."""
1121
- parameters = method_spec.get("parameters", [])
1122
- parameter_relations = dto_class.get_parameter_relations()
1123
- query_params = [p for p in parameters if p.get("in") == "query"]
1124
- header_params = [p for p in parameters if p.get("in") == "header"]
1125
- params = self.get_parameter_data(query_params, parameter_relations)
1126
- headers = self.get_parameter_data(header_params, parameter_relations)
1127
- return parameters, params, headers
1128
-
1129
- @classmethod
1130
- def get_content_schema(cls, body_spec: Dict[str, Any]) -> Dict[str, Any]:
1131
- """Get the content schema from the requestBody spec."""
1132
- content_type = cls.get_content_type(body_spec)
1133
- content_schema = body_spec["content"][content_type]["schema"]
1134
- return resolve_schema(content_schema)
1135
-
1136
- @staticmethod
1137
- def get_content_type(body_spec: Dict[str, Any]) -> str:
1138
- """Get and validate the first supported content type from the requested body spec
1139
-
1140
- Should be application/json like content type,
1141
- e.g "application/json;charset=utf-8" or "application/merge-patch+json"
1142
- """
1143
- content_types: List[str] = body_spec["content"].keys()
1144
- json_regex = r"application/([a-z\-]+\+)?json(;\s?charset=(.+))?"
1145
- for content_type in content_types:
1146
- if re.search(json_regex, content_type):
1147
- return content_type
1148
-
1149
- # At present no supported for other types.
1150
- raise NotImplementedError(
1151
- f"Only content types like 'application/json' are supported. "
1152
- f"Content types definded in the spec are '{content_types}'."
348
+ # first part will be '' since a path starts with /
349
+ path_parts.pop(0)
350
+ parameterized_path = _path_functions.get_parametrized_path(
351
+ path=path, openapi_spec=self.openapi_spec
1153
352
  )
1154
-
1155
- def get_parametrized_endpoint(self, endpoint: str) -> str:
1156
- """
1157
- Get the parametrized endpoint as found in the `paths` section of the openapi
1158
- document from a (partially) resolved endpoint.
1159
- """
1160
-
1161
- def match_parts(parts: List[str], spec_parts: List[str]) -> bool:
1162
- for part, spec_part in zip_longest(parts, spec_parts, fillvalue="Filler"):
1163
- if part == "Filler" or spec_part == "Filler":
1164
- return False
1165
- if part != spec_part and not spec_part.startswith("{"):
1166
- return False
1167
- return True
1168
-
1169
- endpoint_parts = endpoint.split("/")
1170
- # if the last part is empty, the path has a trailing `/` that
1171
- # should be ignored during matching
1172
- if endpoint_parts[-1] == "":
1173
- _ = endpoint_parts.pop(-1)
1174
-
1175
- spec_endpoints: List[str] = {**self.openapi_spec}["paths"].keys()
1176
-
1177
- candidates: List[str] = []
1178
-
1179
- for spec_endpoint in spec_endpoints:
1180
- spec_endpoint_parts = spec_endpoint.split("/")
1181
- # ignore trailing `/` the same way as for endpoint_parts
1182
- if spec_endpoint_parts[-1] == "":
1183
- _ = spec_endpoint_parts.pop(-1)
1184
- if match_parts(endpoint_parts, spec_endpoint_parts):
1185
- candidates.append(spec_endpoint)
1186
-
1187
- if not candidates:
1188
- raise ValueError(
1189
- f"{endpoint} not found in paths section of the OpenAPI document."
1190
- )
1191
-
1192
- if len(candidates) == 1:
1193
- return candidates[0]
1194
- # Multiple matches can happen in APIs with overloaded endpoints, e.g.
1195
- # /users/me
1196
- # /users/${user_id}
1197
- # In this case, find the closest (or exact) match
1198
- exact_match = [c for c in candidates if c == endpoint]
1199
- if exact_match:
1200
- return exact_match[0]
1201
- # TODO: Implement a decision mechanism when real-world examples become available
1202
- # In the face of ambiguity, refuse the temptation to guess.
1203
- raise ValueError(f"{endpoint} matched to multiple paths: {candidates}")
1204
-
1205
- @staticmethod
1206
- def get_parameter_data(
1207
- parameters: List[Dict[str, Any]],
1208
- parameter_relations: List[Relation],
1209
- ) -> Dict[str, str]:
1210
- """Generate a valid list of key-value pairs for all parameters."""
1211
- result: Dict[str, str] = {}
1212
- value: Any = None
1213
- for parameter in parameters:
1214
- parameter_name = parameter["name"]
1215
- parameter_schema = resolve_schema(parameter["schema"])
1216
- relations = [
1217
- r for r in parameter_relations if r.property_name == parameter_name
1218
- ]
1219
- if constrained_values := [
1220
- r.values for r in relations if isinstance(r, PropertyValueConstraint)
1221
- ]:
1222
- value = choice(*constrained_values)
1223
- if value is IGNORE:
1224
- continue
1225
- result[parameter_name] = value
1226
- continue
1227
- value = value_utils.get_valid_value(parameter_schema)
1228
- result[parameter_name] = value
1229
- return result
353
+ return parameterized_path
1230
354
 
1231
355
  @keyword
1232
- def get_json_data_for_dto_class(
1233
- self,
1234
- schema: Dict[str, Any],
1235
- dto_class: Union[Dto, Type[Dto]],
1236
- operation_id: str = "",
1237
- ) -> Optional[Dict[str, Any]]:
356
+ def get_ids_from_url(self, url: str) -> list[str]:
1238
357
  """
1239
- Generate a valid (json-compatible) dict for all the `dto_class` properties.
358
+ Perform a GET request on the `url` and return the list of resource
359
+ `ids` from the response.
1240
360
  """
1241
-
1242
- def get_constrained_values(property_name: str) -> List[Any]:
1243
- relations = dto_class.get_relations()
1244
- values_list = [
1245
- c.values
1246
- for c in relations
1247
- if (
1248
- isinstance(c, PropertyValueConstraint)
1249
- and c.property_name == property_name
1250
- )
1251
- ]
1252
- # values should be empty or contain 1 list of allowed values
1253
- return values_list.pop() if values_list else []
1254
-
1255
- def get_dependent_id(
1256
- property_name: str, operation_id: str
1257
- ) -> Optional[Union[str, int, float]]:
1258
- relations = dto_class.get_relations()
1259
- # multiple get paths are possible based on the operation being performed
1260
- id_get_paths = [
1261
- (d.get_path, d.operation_id)
1262
- for d in relations
1263
- if (isinstance(d, IdDependency) and d.property_name == property_name)
1264
- ]
1265
- if not id_get_paths:
1266
- return None
1267
- if len(id_get_paths) == 1:
1268
- id_get_path, _ = id_get_paths.pop()
1269
- else:
1270
- try:
1271
- [id_get_path] = [
1272
- path
1273
- for path, operation in id_get_paths
1274
- if operation == operation_id
1275
- ]
1276
- # There could be multiple get_paths, but not one for the current operation
1277
- except ValueError:
1278
- return None
1279
- valid_id = self.get_valid_id_for_endpoint(
1280
- endpoint=id_get_path, method="get"
1281
- )
1282
- logger.debug(f"get_dependent_id for {id_get_path} returned {valid_id}")
1283
- return valid_id
1284
-
1285
- json_data: Dict[str, Any] = {}
1286
-
1287
- property_names = []
1288
- for property_name in schema.get("properties", []):
1289
- if constrained_values := get_constrained_values(property_name):
1290
- # do not add properties that are configured to be ignored
1291
- if IGNORE in constrained_values:
1292
- continue
1293
- property_names.append(property_name)
1294
-
1295
- max_properties = schema.get("maxProperties")
1296
- if max_properties and len(property_names) > max_properties:
1297
- required_properties = schema.get("required", [])
1298
- number_of_optional_properties = max_properties - len(required_properties)
1299
- optional_properties = [
1300
- name for name in property_names if name not in required_properties
1301
- ]
1302
- selected_optional_properties = sample(
1303
- optional_properties, number_of_optional_properties
1304
- )
1305
- property_names = required_properties + selected_optional_properties
1306
-
1307
- for property_name in property_names:
1308
- properties_schema = schema["properties"][property_name]
1309
-
1310
- property_type = properties_schema.get("type")
1311
- if property_type is None:
1312
- property_types = properties_schema.get("types")
1313
- if property_types is None:
1314
- if properties_schema.get("properties") is not None:
1315
- nested_data = self.get_json_data_for_dto_class(
1316
- schema=properties_schema,
1317
- dto_class=DefaultDto,
1318
- )
1319
- json_data[property_name] = nested_data
1320
- continue
1321
- selected_type_schema = choice(property_types)
1322
- property_type = selected_type_schema["type"]
1323
- if properties_schema.get("readOnly", False):
1324
- continue
1325
- if constrained_values := get_constrained_values(property_name):
1326
- json_data[property_name] = choice(constrained_values)
1327
- continue
1328
- if (
1329
- dependent_id := get_dependent_id(
1330
- property_name=property_name, operation_id=operation_id
1331
- )
1332
- ) is not None:
1333
- json_data[property_name] = dependent_id
1334
- continue
1335
- if property_type == "object":
1336
- object_data = self.get_json_data_for_dto_class(
1337
- schema=properties_schema,
1338
- dto_class=DefaultDto,
1339
- operation_id="",
1340
- )
1341
- json_data[property_name] = object_data
1342
- continue
1343
- if property_type == "array":
1344
- array_data = self.get_json_data_for_dto_class(
1345
- schema=properties_schema["items"],
1346
- dto_class=DefaultDto,
1347
- operation_id=operation_id,
1348
- )
1349
- json_data[property_name] = [array_data]
1350
- continue
1351
- json_data[property_name] = value_utils.get_valid_value(properties_schema)
1352
-
1353
- return json_data
361
+ return _path_functions.get_ids_from_url(
362
+ url=url, get_id_property_name=self.get_id_property_name
363
+ )
1354
364
 
1355
365
  @keyword
1356
366
  def get_invalidated_url(
1357
367
  self,
1358
368
  valid_url: str,
1359
369
  path: str = "",
1360
- method: str = "",
1361
370
  expected_status_code: int = 404,
1362
- ) -> Optional[str]:
371
+ ) -> str:
1363
372
  """
1364
373
  Return an url with all the path parameters in the `valid_url` replaced by a
1365
- random UUID if no PathPropertiesConstraint is mapped for the `path`, `method`
1366
- and `expected_status_code`.
374
+ random UUID if no PathPropertiesConstraint is mapped for the `"get"` operation
375
+ on the mapped `path` and `expected_status_code`.
1367
376
  If a PathPropertiesConstraint is mapped, the `invalid_value` is returned.
1368
377
 
1369
- Raises ValueError if the valid_url cannot be invalidated.
1370
- """
1371
- dto_class = self.get_dto_class(endpoint=path, method=method)
1372
- relations = dto_class.get_relations()
1373
- paths = [
1374
- p.invalid_value
1375
- for p in relations
1376
- if isinstance(p, PathPropertiesConstraint)
1377
- and p.invalid_value_error_code == expected_status_code
1378
- ]
1379
- if paths:
1380
- url = f"{self.base_url}{choice(paths)}"
1381
- return url
1382
- parameterized_endpoint = self.get_parameterized_endpoint_from_url(valid_url)
1383
- parameterized_url = self.base_url + parameterized_endpoint
1384
- valid_url_parts = list(reversed(valid_url.split("/")))
1385
- parameterized_parts = reversed(parameterized_url.split("/"))
1386
- for index, (parameterized_part, _) in enumerate(
1387
- zip(parameterized_parts, valid_url_parts)
1388
- ):
1389
- if parameterized_part.startswith("{") and parameterized_part.endswith("}"):
1390
- valid_url_parts[index] = uuid4().hex
1391
- valid_url_parts.reverse()
1392
- invalid_url = "/".join(valid_url_parts)
1393
- return invalid_url
1394
- raise ValueError(f"{parameterized_endpoint} could not be invalidated.")
1395
-
1396
- @keyword
1397
- def get_parameterized_endpoint_from_url(self, url: str) -> str:
1398
- """
1399
- Return the endpoint as found in the `paths` section based on the given `url`.
1400
- """
1401
- endpoint = url.replace(self.base_url, "")
1402
- endpoint_parts = endpoint.split("/")
1403
- # first part will be '' since an endpoint starts with /
1404
- endpoint_parts.pop(0)
1405
- parameterized_endpoint = self.get_parametrized_endpoint(endpoint=endpoint)
1406
- return parameterized_endpoint
1407
-
1408
- @keyword
1409
- def get_invalid_json_data(
1410
- self,
1411
- url: str,
1412
- method: str,
1413
- status_code: int,
1414
- request_data: RequestData,
1415
- ) -> Dict[str, Any]:
1416
- """
1417
- Return `json_data` based on the `dto` on the `request_data` that will cause
1418
- the provided `status_code` for the `method` operation on the `url`.
1419
-
1420
- > Note: applicable UniquePropertyValueConstraint and IdReference Relations are
1421
- considered before changes to `json_data` are made.
1422
- """
1423
- method = method.lower()
1424
- data_relations = request_data.dto.get_relations_for_error_code(status_code)
1425
- data_relations = [
1426
- r for r in data_relations if not isinstance(r, PathPropertiesConstraint)
1427
- ]
1428
- if not data_relations:
1429
- if not request_data.dto_schema:
1430
- raise ValueError(
1431
- "Failed to invalidate: no data_relations and empty schema."
1432
- )
1433
- json_data = request_data.dto.get_invalidated_data(
1434
- schema=request_data.dto_schema,
1435
- status_code=status_code,
1436
- invalid_property_default_code=self.invalid_property_default_response,
1437
- )
1438
- return json_data
1439
- resource_relation = choice(data_relations)
1440
- if isinstance(resource_relation, UniquePropertyValueConstraint):
1441
- json_data = run_keyword(
1442
- "get_json_data_with_conflict",
1443
- url,
1444
- method,
1445
- request_data.dto,
1446
- status_code,
1447
- )
1448
- elif isinstance(resource_relation, IdReference):
1449
- run_keyword("ensure_in_use", url, resource_relation)
1450
- json_data = request_data.dto.as_dict()
1451
- else:
1452
- json_data = request_data.dto.get_invalidated_data(
1453
- schema=request_data.dto_schema,
1454
- status_code=status_code,
1455
- invalid_property_default_code=self.invalid_property_default_response,
1456
- )
1457
- return json_data
1458
-
1459
- @keyword
1460
- def get_invalidated_parameters(
1461
- self,
1462
- status_code: int,
1463
- request_data: RequestData,
1464
- ) -> Tuple[Dict[str, Any], Dict[str, str]]:
378
+ Raises: ValueError if the valid_url cannot be invalidated.
1465
379
  """
1466
- Returns a version of `params, headers` as present on `request_data` that has
1467
- been modified to cause the provided `status_code`.
1468
- """
1469
- if not request_data.parameters:
1470
- raise ValueError("No params or headers to invalidate.")
1471
-
1472
- # ensure the status_code can be triggered
1473
- relations = request_data.dto.get_parameter_relations_for_error_code(status_code)
1474
- relations_for_status_code = [
1475
- r
1476
- for r in relations
1477
- if isinstance(r, PropertyValueConstraint)
1478
- and (
1479
- r.error_code == status_code or r.invalid_value_error_code == status_code
1480
- )
1481
- ]
1482
- parameters_to_ignore = {
1483
- r.property_name
1484
- for r in relations_for_status_code
1485
- if r.invalid_value_error_code == status_code and r.invalid_value == IGNORE
1486
- }
1487
- relation_property_names = {r.property_name for r in relations_for_status_code}
1488
- if not relation_property_names:
1489
- if status_code != self.invalid_property_default_response:
1490
- raise ValueError(
1491
- f"No relations to cause status_code {status_code} found."
1492
- )
1493
-
1494
- # ensure we're not modifying mutable properties
1495
- params = deepcopy(request_data.params)
1496
- headers = deepcopy(request_data.headers)
1497
-
1498
- if status_code == self.invalid_property_default_response:
1499
- # take the params and headers that can be invalidated based on data type
1500
- # and expand the set with properties that can be invalided by relations
1501
- parameter_names = set(request_data.params_that_can_be_invalidated).union(
1502
- request_data.headers_that_can_be_invalidated
1503
- )
1504
- parameter_names.update(relation_property_names)
1505
- if not parameter_names:
1506
- raise ValueError(
1507
- "None of the query parameters and headers can be invalidated."
1508
- )
1509
- else:
1510
- # non-default status_codes can only be the result of a Relation
1511
- parameter_names = relation_property_names
1512
-
1513
- # Dto mappings may contain generic mappings for properties that are not present
1514
- # in this specific schema
1515
- request_data_parameter_names = [p.get("name") for p in request_data.parameters]
1516
- additional_relation_property_names = {
1517
- n for n in relation_property_names if n not in request_data_parameter_names
1518
- }
1519
- if additional_relation_property_names:
1520
- logger.warning(
1521
- f"get_parameter_relations_for_error_code yielded properties that are "
1522
- f"not defined in the schema: {additional_relation_property_names}\n"
1523
- f"These properties will be ignored for parameter invalidation."
1524
- )
1525
- parameter_names = parameter_names - additional_relation_property_names
1526
-
1527
- if not parameter_names:
1528
- raise ValueError(
1529
- f"No parameter can be changed to cause status_code {status_code}."
1530
- )
1531
-
1532
- parameter_names = parameter_names - parameters_to_ignore
1533
- parameter_to_invalidate = choice(tuple(parameter_names))
1534
-
1535
- # check for invalid parameters in the provided request_data
1536
- try:
1537
- [parameter_data] = [
1538
- data
1539
- for data in request_data.parameters
1540
- if data["name"] == parameter_to_invalidate
1541
- ]
1542
- except Exception:
1543
- raise ValueError(
1544
- f"{parameter_to_invalidate} not found in provided parameters."
1545
- ) from None
1546
-
1547
- # get the invalid_value for the chosen parameter
1548
- try:
1549
- [invalid_value_for_error_code] = [
1550
- r.invalid_value
1551
- for r in relations_for_status_code
1552
- if r.property_name == parameter_to_invalidate
1553
- and r.invalid_value_error_code == status_code
1554
- ]
1555
- except ValueError:
1556
- invalid_value_for_error_code = NOT_SET
1557
-
1558
- # get the constraint values if available for the chosen parameter
1559
- try:
1560
- [values_from_constraint] = [
1561
- r.values
1562
- for r in relations_for_status_code
1563
- if r.property_name == parameter_to_invalidate
1564
- ]
1565
- except ValueError:
1566
- values_from_constraint = []
1567
-
1568
- # if the parameter was not provided, add it to params / headers
1569
- params, headers = self.ensure_parameter_in_parameters(
1570
- parameter_to_invalidate=parameter_to_invalidate,
1571
- params=params,
1572
- headers=headers,
1573
- parameter_data=parameter_data,
1574
- values_from_constraint=values_from_constraint,
380
+ return _path_invalidation.get_invalidated_url(
381
+ valid_url=valid_url,
382
+ path=path,
383
+ base_url=self.base_url,
384
+ get_path_dto_class=self.get_path_dto_class,
385
+ expected_status_code=expected_status_code,
1575
386
  )
1576
387
 
1577
- # determine the invalid_value
1578
- if invalid_value_for_error_code != NOT_SET:
1579
- invalid_value = invalid_value_for_error_code
1580
- else:
1581
- if parameter_to_invalidate in params.keys():
1582
- valid_value = params[parameter_to_invalidate]
1583
- else:
1584
- valid_value = headers[parameter_to_invalidate]
1585
-
1586
- value_schema = resolve_schema(parameter_data["schema"])
1587
- invalid_value = value_utils.get_invalid_value(
1588
- value_schema=value_schema,
1589
- current_value=valid_value,
1590
- values_from_constraint=values_from_constraint,
1591
- )
1592
- logger.debug(f"{parameter_to_invalidate} changed to {invalid_value}")
1593
-
1594
- # update the params / headers and return
1595
- if parameter_to_invalidate in params.keys():
1596
- params[parameter_to_invalidate] = invalid_value
1597
- else:
1598
- headers[parameter_to_invalidate] = invalid_value
1599
- return params, headers
1600
-
1601
- @staticmethod
1602
- def ensure_parameter_in_parameters(
1603
- parameter_to_invalidate: str,
1604
- params: Dict[str, Any],
1605
- headers: Dict[str, str],
1606
- parameter_data: Dict[str, Any],
1607
- values_from_constraint: List[Any],
1608
- ) -> Tuple[Dict[str, Any], Dict[str, str]]:
1609
- """
1610
- Returns the params, headers tuple with parameter_to_invalidate with a valid
1611
- value to params or headers if not originally present.
1612
- """
1613
- if (
1614
- parameter_to_invalidate not in params.keys()
1615
- and parameter_to_invalidate not in headers.keys()
1616
- ):
1617
- if values_from_constraint:
1618
- valid_value = choice(values_from_constraint)
1619
- else:
1620
- parameter_schema = resolve_schema(parameter_data["schema"])
1621
- valid_value = value_utils.get_valid_value(parameter_schema)
1622
- if (
1623
- parameter_data["in"] == "query"
1624
- and parameter_to_invalidate not in params.keys()
1625
- ):
1626
- params[parameter_to_invalidate] = valid_value
1627
- if (
1628
- parameter_data["in"] == "header"
1629
- and parameter_to_invalidate not in headers.keys()
1630
- ):
1631
- headers[parameter_to_invalidate] = valid_value
1632
- return params, headers
1633
-
388
+ # endregion
389
+ # region: resource relations keywords
1634
390
  @keyword
1635
391
  def ensure_in_use(self, url: str, resource_relation: IdReference) -> None:
1636
392
  """
1637
393
  Ensure that the (right-most) `id` of the resource referenced by the `url`
1638
394
  is used by the resource defined by the `resource_relation`.
1639
395
  """
1640
- resource_id = ""
1641
-
1642
- endpoint = url.replace(self.base_url, "")
1643
- endpoint_parts = endpoint.split("/")
1644
- parameterized_endpoint = self.get_parametrized_endpoint(endpoint=endpoint)
1645
- parameterized_endpoint_parts = parameterized_endpoint.split("/")
1646
- for part, param_part in zip(
1647
- reversed(endpoint_parts), reversed(parameterized_endpoint_parts)
1648
- ):
1649
- if param_part.endswith("}"):
1650
- resource_id = part
1651
- break
1652
- if not resource_id:
1653
- raise ValueError(f"The provided url ({url}) does not contain an id.")
1654
- request_data = self.get_request_data(
1655
- method="post", endpoint=resource_relation.post_path
1656
- )
1657
- json_data = request_data.dto.as_dict()
1658
- json_data[resource_relation.property_name] = resource_id
1659
- post_url: str = run_keyword(
1660
- "get_valid_url",
1661
- resource_relation.post_path,
1662
- "post",
1663
- )
1664
- response: Response = run_keyword(
1665
- "authorized_request",
1666
- post_url,
1667
- "post",
1668
- request_data.params,
1669
- request_data.headers,
1670
- json_data,
1671
- )
1672
- if not response.ok:
1673
- logger.debug(
1674
- f"POST on {post_url} with json {json_data} failed: {response.json()}"
1675
- )
1676
- response.raise_for_status()
1677
-
1678
- @keyword
1679
- def get_json_data_with_conflict(
1680
- self, url: str, method: str, dto: Dto, conflict_status_code: int
1681
- ) -> Dict[str, Any]:
1682
- """
1683
- Return `json_data` based on the `UniquePropertyValueConstraint` that must be
1684
- returned by the `get_relations` implementation on the `dto` for the given
1685
- `conflict_status_code`.
1686
- """
1687
- method = method.lower()
1688
- json_data = dto.as_dict()
1689
- unique_property_value_constraints = [
1690
- r
1691
- for r in dto.get_relations()
1692
- if isinstance(r, UniquePropertyValueConstraint)
1693
- ]
1694
- for relation in unique_property_value_constraints:
1695
- json_data[relation.property_name] = relation.value
1696
- # create a new resource that the original request will conflict with
1697
- if method in ["patch", "put"]:
1698
- post_url_parts = url.split("/")[:-1]
1699
- post_url = "/".join(post_url_parts)
1700
- # the PATCH or PUT may use a different dto than required for POST
1701
- # so a valid POST dto must be constructed
1702
- endpoint = post_url.replace(self.base_url, "")
1703
- request_data = self.get_request_data(endpoint=endpoint, method="post")
1704
- post_json = request_data.dto.as_dict()
1705
- for key in post_json.keys():
1706
- if key in json_data:
1707
- post_json[key] = json_data.get(key)
1708
- else:
1709
- post_url = url
1710
- post_json = json_data
1711
- endpoint = post_url.replace(self.base_url, "")
1712
- request_data = self.get_request_data(endpoint=endpoint, method="post")
1713
- response: Response = run_keyword(
1714
- "authorized_request",
1715
- post_url,
1716
- "post",
1717
- request_data.params,
1718
- request_data.headers,
1719
- post_json,
1720
- )
1721
- # conflicting resource may already exist
1722
- assert (
1723
- response.ok or response.status_code == conflict_status_code
1724
- ), f"get_json_data_with_conflict received {response.status_code}: {response.json()}"
1725
- return json_data
1726
- raise ValueError(
1727
- f"No UniquePropertyValueConstraint in the get_relations list on dto {dto}."
396
+ _resource_relations.ensure_in_use(
397
+ url=url,
398
+ base_url=self.base_url,
399
+ openapi_spec=self.openapi_spec,
400
+ resource_relation=resource_relation,
1728
401
  )
1729
402
 
403
+ # endregion
404
+ # region: request keywords
1730
405
  @keyword
1731
406
  def authorized_request( # pylint: disable=too-many-arguments
1732
407
  self,
1733
408
  url: str,
1734
409
  method: str,
1735
- params: Optional[Dict[str, Any]] = None,
1736
- headers: Optional[Dict[str, str]] = None,
1737
- json_data: Optional[JSON] = None,
410
+ params: dict[str, Any] | None = None,
411
+ headers: dict[str, str] | None = None,
412
+ json_data: JSON = None,
1738
413
  data: Any = None,
1739
414
  files: Any = None,
1740
415
  ) -> Response:
@@ -1749,7 +424,7 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
1749
424
  > Note: provided username / password or auth objects take precedence over token
1750
425
  based security
1751
426
  """
1752
- headers = headers if headers else {}
427
+ headers = deepcopy(headers) if headers else {}
1753
428
  if self.extra_headers:
1754
429
  headers.update(self.extra_headers)
1755
430
  # if both an auth object and a token are available, auth takes precedence
@@ -1774,90 +449,64 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
1774
449
  logger.debug(f"Response text: {response.text}")
1775
450
  return response
1776
451
 
452
+ # endregion
453
+ # region: validation keywords
1777
454
  @keyword
1778
455
  def perform_validated_request(
1779
456
  self,
1780
457
  path: str,
1781
458
  status_code: int,
1782
459
  request_values: RequestValues,
1783
- original_data: Optional[Dict[str, Any]] = None,
460
+ original_data: Mapping[str, JSON] = default_json_mapping,
1784
461
  ) -> None:
1785
462
  """
1786
463
  This keyword first calls the Authorized Request keyword, then the Validate
1787
464
  Response keyword and finally validates, for `DELETE` operations, whether
1788
465
  the target resource was indeed deleted (OK response) or not (error responses).
1789
466
  """
1790
- response = run_keyword(
1791
- "authorized_request",
1792
- request_values.url,
1793
- request_values.method,
1794
- request_values.params,
1795
- request_values.headers,
1796
- request_values.json_data,
467
+ _validation.perform_validated_request(
468
+ path=path,
469
+ status_code=status_code,
470
+ request_values=request_values,
471
+ original_data=original_data,
1797
472
  )
1798
- if response.status_code != status_code:
1799
- try:
1800
- response_json = response.json()
1801
- except Exception as _: # pylint: disable=broad-except
1802
- logger.info(
1803
- f"Failed to get json content from response. "
1804
- f"Response text was: {response.text}"
1805
- )
1806
- response_json = {}
1807
- if not response.ok:
1808
- if description := response_json.get("detail"):
1809
- pass
1810
- else:
1811
- description = response_json.get(
1812
- "message", "response contains no message or detail."
1813
- )
1814
- logger.error(f"{response.reason}: {description}")
1815
-
1816
- logger.debug(
1817
- f"\nSend: {_json.dumps(request_values.json_data, indent=4, sort_keys=True)}"
1818
- f"\nGot: {_json.dumps(response_json, indent=4, sort_keys=True)}"
1819
- )
1820
- raise AssertionError(
1821
- f"Response status_code {response.status_code} was not {status_code}"
1822
- )
1823
473
 
1824
- run_keyword("validate_response", path, response, original_data)
474
+ @keyword
475
+ def validate_response_using_validator(self, response: Response) -> None:
476
+ """
477
+ Validate the `response` against the OpenAPI Spec that is
478
+ loaded during library initialization.
479
+ """
480
+ _validation.validate_response_using_validator(
481
+ response=response,
482
+ response_validator=self.response_validator,
483
+ )
1825
484
 
1826
- if request_values.method == "DELETE":
1827
- get_request_data = self.get_request_data(endpoint=path, method="GET")
1828
- get_params = get_request_data.params
1829
- get_headers = get_request_data.headers
1830
- get_response = run_keyword(
1831
- "authorized_request", request_values.url, "GET", get_params, get_headers
1832
- )
1833
- if response.ok:
1834
- if get_response.ok:
1835
- raise AssertionError(
1836
- f"Resource still exists after deletion. Url was {request_values.url}"
1837
- )
1838
- # if the path supports GET, 404 is expected, if not 405 is expected
1839
- if get_response.status_code not in [404, 405]:
1840
- logger.warning(
1841
- f"Unexpected response after deleting resource: Status_code "
1842
- f"{get_response.status_code} was received after trying to get {request_values.url} "
1843
- f"after sucessfully deleting it."
1844
- )
1845
- elif not get_response.ok:
1846
- raise AssertionError(
1847
- f"Resource could not be retrieved after failed deletion. "
1848
- f"Url was {request_values.url}, status_code was {get_response.status_code}"
1849
- )
485
+ @keyword
486
+ def assert_href_to_resource_is_valid(
487
+ self, href: str, referenced_resource: dict[str, JSON]
488
+ ) -> None:
489
+ """
490
+ Attempt to GET the resource referenced by the `href` and validate it's equal
491
+ to the provided `referenced_resource` object / dictionary.
492
+ """
493
+ _validation.assert_href_to_resource_is_valid(
494
+ href=href,
495
+ origin=self.origin,
496
+ base_url=self.base_url,
497
+ referenced_resource=referenced_resource,
498
+ )
1850
499
 
1851
500
  @keyword
1852
501
  def validate_response(
1853
502
  self,
1854
503
  path: str,
1855
504
  response: Response,
1856
- original_data: Optional[Dict[str, Any]] = None,
505
+ original_data: Mapping[str, JSON] = default_json_mapping,
1857
506
  ) -> None:
1858
507
  """
1859
508
  Validate the `response` by performing the following validations:
1860
- - validate the `response` against the openapi schema for the `endpoint`
509
+ - validate the `response` against the openapi schema for the `path`
1861
510
  - validate that the response does not contain extra properties
1862
511
  - validate that a href, if present, refers to the correct resource
1863
512
  - validate that the value for a property that is in the response is equal to
@@ -1865,380 +514,163 @@ class OpenApiLibCore: # pylint: disable=too-many-instance-attributes
1865
514
  - validate that no `original_data` is preserved when performing a PUT operation
1866
515
  - validate that a PATCH operation only updates the provided properties
1867
516
  """
1868
- if response.status_code == 204:
1869
- assert not response.content
1870
- return None
1871
-
1872
- try:
1873
- self._validate_response_against_spec(response)
1874
- except OpenAPIError as exception:
1875
- raise Failure(f"Response did not pass schema validation: {exception}")
1876
-
1877
- request_method = response.request.method
1878
- if request_method is None:
1879
- logger.warning(
1880
- f"Could not validate response for path {path}; no method found "
1881
- f"on the request property of the provided response."
1882
- )
1883
- return None
1884
-
1885
- response_spec = self._get_response_spec(
517
+ _validation.validate_response(
1886
518
  path=path,
1887
- method=request_method,
1888
- status_code=response.status_code,
519
+ response=response,
520
+ response_validator=self.response_validator,
521
+ server_validation_warning_logged=self._server_validation_warning_logged,
522
+ disable_server_validation=self.disable_server_validation,
523
+ invalid_property_default_response=self.invalid_property_default_response,
524
+ response_validation=self.response_validation,
525
+ openapi_spec=self.openapi_spec,
526
+ original_data=original_data,
1889
527
  )
1890
528
 
1891
- content_type_from_response = response.headers.get("Content-Type", "unknown")
1892
- mime_type_from_response, _, _ = content_type_from_response.partition(";")
529
+ @staticmethod
530
+ @keyword
531
+ def validate_send_response(
532
+ response: Response,
533
+ original_data: Mapping[str, JSON] = default_json_mapping,
534
+ ) -> None:
535
+ """
536
+ Validate that each property that was send that is in the response has the value
537
+ that was send.
538
+ In case a PATCH request, validate that only the properties that were patched
539
+ have changed and that other properties are still at their pre-patch values.
540
+ """
541
+ _validation.validate_send_response(
542
+ response=response, original_data=original_data
543
+ )
1893
544
 
1894
- if not response_spec.get("content"):
1895
- logger.warning(
1896
- "The response cannot be validated: 'content' not specified in the OAS."
1897
- )
1898
- return None
1899
-
1900
- # multiple content types can be specified in the OAS
1901
- content_types = list(response_spec["content"].keys())
1902
- supported_types = [
1903
- ct for ct in content_types if ct.partition(";")[0].endswith("json")
1904
- ]
1905
- if not supported_types:
1906
- raise NotImplementedError(
1907
- f"The content_types '{content_types}' are not supported. "
1908
- f"Only json types are currently supported."
1909
- )
1910
- content_type = supported_types[0]
1911
- mime_type = content_type.partition(";")[0]
545
+ # endregion
1912
546
 
1913
- if mime_type != mime_type_from_response:
1914
- raise ValueError(
1915
- f"Content-Type '{content_type_from_response}' of the response "
1916
- f"does not match '{mime_type}' as specified in the OpenAPI document."
1917
- )
547
+ @property
548
+ def origin(self) -> str:
549
+ return self._origin
1918
550
 
1919
- json_response = response.json()
1920
- response_schema = resolve_schema(
1921
- response_spec["content"][content_type]["schema"]
1922
- )
551
+ @property
552
+ def base_url(self) -> str:
553
+ return f"{self.origin}{self._base_path}"
554
+
555
+ @cached_property
556
+ def validation_spec(self) -> Spec:
557
+ _, validation_spec, _ = self._load_specs_and_validator()
558
+ return validation_spec
559
+
560
+ @property
561
+ def openapi_spec(self) -> OpenApiObject:
562
+ """Return a deepcopy of the parsed openapi document."""
563
+ # protect the parsed openapi spec from being mutated by reference
564
+ return deepcopy(self._openapi_spec)
565
+
566
+ @cached_property
567
+ def _openapi_spec(self) -> OpenApiObject:
568
+ parser, _, _ = self._load_specs_and_validator()
569
+ spec_model = OpenApiObject.model_validate(parser.specification)
570
+ register_path_parameters(spec_model.paths)
571
+ return spec_model
572
+
573
+ @cached_property
574
+ def response_validator(
575
+ self,
576
+ ) -> ResponseValidatorType:
577
+ _, _, response_validator = self._load_specs_and_validator()
578
+ return response_validator
579
+
580
+ def _get_json_types_from_spec(self, spec: dict[str, JSON]) -> set[str]:
581
+ json_types: set[str] = set(self._get_json_types(spec))
582
+ return {json_type for json_type in json_types if json_type is not None}
583
+
584
+ def _get_json_types(self, item: object) -> Generator[str, None, None]:
585
+ if isinstance(item, dict):
586
+ content_dict = item.get("content")
587
+ if content_dict is None:
588
+ for value in item.values():
589
+ yield from self._get_json_types(value)
1923
590
 
1924
- response_types = response_schema.get("types")
1925
- if response_types:
1926
- # In case of oneOf / anyOf there can be multiple possible response types
1927
- # which makes generic validation too complex
1928
- return None
1929
- response_type = response_schema.get("type", "undefined")
1930
- if response_type not in ["object", "array"]:
1931
- self._validate_value_type(value=json_response, expected_type=response_type)
1932
- return None
1933
-
1934
- if list_item_schema := response_schema.get("items"):
1935
- if not isinstance(json_response, list):
1936
- raise AssertionError(
1937
- f"Response schema violation: the schema specifies an array as "
1938
- f"response type but the response was of type {type(json_response)}."
1939
- )
1940
- type_of_list_items = list_item_schema.get("type")
1941
- if type_of_list_items == "object":
1942
- for resource in json_response:
1943
- run_keyword(
1944
- "validate_resource_properties", resource, list_item_schema
1945
- )
1946
591
  else:
1947
- for item in json_response:
1948
- self._validate_value_type(
1949
- value=item, expected_type=type_of_list_items
1950
- )
1951
- # no further validation; value validation of individual resources should
1952
- # be performed on the endpoints for the specific resource
1953
- return None
1954
-
1955
- run_keyword("validate_resource_properties", json_response, response_schema)
1956
- # ensure the href is valid if present in the response
1957
- if href := json_response.get("href"):
1958
- self._assert_href_is_valid(href, json_response)
1959
- # every property that was sucessfully send and that is in the response
1960
- # schema must have the value that was send
1961
- if response.ok and response.request.method in ["POST", "PUT", "PATCH"]:
1962
- run_keyword("validate_send_response", response, original_data)
1963
- return None
1964
-
1965
- def _assert_href_is_valid(self, href: str, json_response: Dict[str, Any]) -> None:
1966
- url = f"{self.origin}{href}"
1967
- path = url.replace(self.base_url, "")
1968
- request_data = self.get_request_data(endpoint=path, method="GET")
1969
- params = request_data.params
1970
- headers = request_data.headers
1971
- get_response = run_keyword("authorized_request", url, "GET", params, headers)
1972
- assert (
1973
- get_response.json() == json_response
1974
- ), f"{get_response.json()} not equal to original {json_response}"
592
+ for content_type in content_dict:
593
+ if "json" in content_type:
594
+ content_type_without_charset, _, _ = content_type.partition(";")
595
+ yield content_type_without_charset
596
+
597
+ if isinstance(item, list):
598
+ for list_item in item:
599
+ yield from self._get_json_types(list_item)
600
+
601
+ def _load_specs_and_validator(
602
+ self,
603
+ ) -> tuple[
604
+ ResolvingParser,
605
+ Spec,
606
+ ResponseValidatorType,
607
+ ]:
608
+ def recursion_limit_handler(
609
+ limit: int, # pylint: disable=unused-argument
610
+ refstring: str, # pylint: disable=unused-argument
611
+ recursions: JSON, # pylint: disable=unused-argument
612
+ ) -> JSON:
613
+ return self._recursion_default # pragma: no cover
1975
614
 
1976
- def _validate_response_against_spec(self, response: Response) -> None:
1977
615
  try:
1978
- self.validate_response_vs_spec(
1979
- request=RequestsOpenAPIRequest(response.request),
1980
- response=RequestsOpenAPIResponse(response),
1981
- )
1982
- except (ResponseValidationError, ServerNotFound) as exception:
1983
- errors: List[InvalidSchemaValue] = exception.__cause__
1984
- validation_errors: Optional[List[ValidationError]] = getattr(
1985
- errors, "schema_errors", None
1986
- )
1987
- if validation_errors:
1988
- error_message = "\n".join(
1989
- [
1990
- f"{list(error.schema_path)}: {error.message}"
1991
- for error in validation_errors
1992
- ]
616
+ # Since parsing of the OAS and creating the Spec can take a long time,
617
+ # they are cached. This is done by storing them in an imported module that
618
+ # will have a global scope due to how the Python import system works. This
619
+ # ensures that in a Suite of Suites where multiple Suites use the same
620
+ # `source`, that OAS is only parsed / loaded once.
621
+ cached_parser = PARSER_CACHE.get(self._source, None)
622
+ if cached_parser:
623
+ return (
624
+ cached_parser.parser,
625
+ cached_parser.validation_spec,
626
+ cached_parser.response_validator,
1993
627
  )
1994
- else:
1995
- error_message = str(exception)
1996
-
1997
- if isinstance(exception, ServerNotFound):
1998
- if not self._server_validation_warning_logged:
1999
- logger.warning(
2000
- f"ServerNotFound was raised during response validation. "
2001
- f"Due to this, no full response validation will be performed."
2002
- f"\nThe original error was: {error_message}"
2003
- )
2004
- self._server_validation_warning_logged = True
2005
- if self.disable_server_validation:
2006
- return
2007
- if response.status_code == self.invalid_property_default_response:
2008
- logger.debug(error_message)
2009
- return
2010
- if self.response_validation == ValidationLevel.STRICT:
2011
- logger.error(error_message)
2012
- raise exception
2013
- if self.response_validation == ValidationLevel.WARN:
2014
- logger.warning(error_message)
2015
- elif self.response_validation == ValidationLevel.INFO:
2016
- logger.info(error_message)
2017
628
 
2018
- @keyword
2019
- def validate_resource_properties(
2020
- self, resource: Dict[str, Any], schema: Dict[str, Any]
2021
- ) -> None:
2022
- """
2023
- Validate that the `resource` does not contain any properties that are not
2024
- defined in the `schema_properties`.
2025
- """
2026
- schema_properties = schema.get("properties", {})
2027
- property_names_from_schema = set(schema_properties.keys())
2028
- property_names_in_resource = set(resource.keys())
2029
-
2030
- if property_names_from_schema != property_names_in_resource:
2031
- # The additionalProperties property determines whether properties with
2032
- # unspecified names are allowed. This property can be boolean or an object
2033
- # (dict) that specifies the type of any additional properties.
2034
- additional_properties = schema.get("additionalProperties", True)
2035
- if isinstance(additional_properties, bool):
2036
- allow_additional_properties = additional_properties
2037
- allowed_additional_properties_type = None
2038
- else:
2039
- allow_additional_properties = True
2040
- allowed_additional_properties_type = additional_properties["type"]
2041
-
2042
- extra_property_names = property_names_in_resource.difference(
2043
- property_names_from_schema
2044
- )
2045
- if allow_additional_properties:
2046
- # If a type is defined for extra properties, validate them
2047
- if allowed_additional_properties_type:
2048
- extra_properties = {
2049
- key: value
2050
- for key, value in resource.items()
2051
- if key in extra_property_names
2052
- }
2053
- self._validate_type_of_extra_properties(
2054
- extra_properties=extra_properties,
2055
- expected_type=allowed_additional_properties_type,
2056
- )
2057
- # If allowed, validation should not fail on extra properties
2058
- extra_property_names = set()
2059
-
2060
- required_properties = set(schema.get("required", []))
2061
- missing_properties = required_properties.difference(
2062
- property_names_in_resource
629
+ parser = ResolvingParser(
630
+ self._source,
631
+ backend="openapi-spec-validator",
632
+ recursion_limit=self._recursion_limit,
633
+ recursion_limit_handler=recursion_limit_handler,
2063
634
  )
2064
635
 
2065
- if extra_property_names or missing_properties:
2066
- extra = (
2067
- f"\n\tExtra properties in response: {extra_property_names}"
2068
- if extra_property_names
2069
- else ""
2070
- )
2071
- missing = (
2072
- f"\n\tRequired properties missing in response: {missing_properties}"
2073
- if missing_properties
2074
- else ""
2075
- )
2076
- raise AssertionError(
2077
- f"Response schema violation: the response contains properties that are "
2078
- f"not specified in the schema or does not contain properties that are "
2079
- f"required according to the schema."
2080
- f"\n\tReceived in the response: {property_names_in_resource}"
2081
- f"\n\tDefined in the schema: {property_names_from_schema}"
2082
- f"{extra}{missing}"
636
+ if parser.specification is None: # pragma: no cover
637
+ raise FatalError(
638
+ "Source was loaded, but no specification was present after parsing."
2083
639
  )
2084
640
 
2085
- @staticmethod
2086
- def _validate_value_type(value: Any, expected_type: str) -> None:
2087
- type_mapping = {
2088
- "string": str,
2089
- "number": float,
2090
- "integer": int,
2091
- "boolean": bool,
2092
- "array": list,
2093
- "object": dict,
2094
- }
2095
- python_type = type_mapping.get(expected_type, None)
2096
- if python_type is None:
2097
- raise AssertionError(
2098
- f"Validation of type '{expected_type}' is not supported."
2099
- )
2100
- if not isinstance(value, python_type):
2101
- raise AssertionError(f"{value} is not of type {expected_type}")
641
+ validation_spec = Spec.from_dict(parser.specification) # pyright: ignore[reportArgumentType]
2102
642
 
2103
- @staticmethod
2104
- def _validate_type_of_extra_properties(
2105
- extra_properties: Dict[str, Any], expected_type: str
2106
- ) -> None:
2107
- type_mapping = {
2108
- "string": str,
2109
- "number": float,
2110
- "integer": int,
2111
- "boolean": bool,
2112
- "array": list,
2113
- "object": dict,
2114
- }
2115
-
2116
- python_type = type_mapping.get(expected_type, None)
2117
- if python_type is None:
2118
- logger.warning(
2119
- f"Additonal properties were not validated: "
2120
- f"type '{expected_type}' is not supported."
643
+ json_types_from_spec: set[str] = self._get_json_types_from_spec(
644
+ parser.specification
2121
645
  )
2122
- return
2123
-
2124
- invalid_extra_properties = {
2125
- key: value
2126
- for key, value in extra_properties.items()
2127
- if not isinstance(value, python_type)
2128
- }
2129
- if invalid_extra_properties:
2130
- raise AssertionError(
2131
- f"Response contains invalid additionalProperties: "
2132
- f"{invalid_extra_properties} are not of type {expected_type}."
646
+ extra_deserializers = {
647
+ json_type: _json.loads for json_type in json_types_from_spec
648
+ }
649
+ config = Config(extra_media_type_deserializers=extra_deserializers) # type: ignore[arg-type]
650
+ openapi = OpenAPI(spec=validation_spec, config=config)
651
+ response_validator: ResponseValidatorType = openapi.validate_response # type: ignore[assignment]
652
+
653
+ PARSER_CACHE[self._source] = CachedParser(
654
+ parser=parser,
655
+ validation_spec=validation_spec,
656
+ response_validator=response_validator,
2133
657
  )
2134
658
 
2135
- @staticmethod
2136
- @keyword
2137
- def validate_send_response(
2138
- response: Response, original_data: Optional[Dict[str, Any]] = None
2139
- ) -> None:
2140
- """
2141
- Validate that each property that was send that is in the response has the value
2142
- that was send.
2143
- In case a PATCH request, validate that only the properties that were patched
2144
- have changed and that other properties are still at their pre-patch values.
2145
- """
659
+ return parser, validation_spec, response_validator
2146
660
 
2147
- def validate_list_response(
2148
- send_list: List[Any], received_list: List[Any]
2149
- ) -> None:
2150
- for item in send_list:
2151
- if item not in received_list:
2152
- raise AssertionError(
2153
- f"Received value '{received_list}' does "
2154
- f"not contain '{item}' in the {response.request.method} request."
2155
- f"\nSend: {_json.dumps(send_json, indent=4, sort_keys=True)}"
2156
- f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
2157
- )
2158
-
2159
- def validate_dict_response(
2160
- send_dict: Dict[str, Any], received_dict: Dict[str, Any]
2161
- ) -> None:
2162
- for send_property_name, send_property_value in send_dict.items():
2163
- # sometimes, a property in the request is not in the response, e.g. a password
2164
- if send_property_name not in received_dict.keys():
2165
- continue
2166
- if send_property_value is not None:
2167
- # if a None value is send, the target property should be cleared or
2168
- # reverted to the default value (which cannot be specified in the
2169
- # openapi document)
2170
- received_value = received_dict[send_property_name]
2171
- # In case of lists / arrays, the send values are often appended to
2172
- # existing data
2173
- if isinstance(received_value, list):
2174
- validate_list_response(
2175
- send_list=send_property_value, received_list=received_value
2176
- )
2177
- continue
2178
-
2179
- # when dealing with objects, we'll need to iterate the properties
2180
- if isinstance(received_value, dict):
2181
- validate_dict_response(
2182
- send_dict=send_property_value, received_dict=received_value
2183
- )
2184
- continue
2185
-
2186
- assert received_value == send_property_value, (
2187
- f"Received value for {send_property_name} '{received_value}' does not "
2188
- f"match '{send_property_value}' in the {response.request.method} request."
2189
- f"\nSend: {_json.dumps(send_json, indent=4, sort_keys=True)}"
2190
- f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
2191
- )
2192
-
2193
- if response.request.body is None:
2194
- logger.warning(
2195
- "Could not validate send response; the body of the request property "
2196
- "on the provided response was None."
2197
- )
2198
- return None
2199
- if isinstance(response.request.body, bytes):
2200
- send_json = _json.loads(response.request.body.decode("UTF-8"))
2201
- else:
2202
- send_json = _json.loads(response.request.body)
2203
-
2204
- response_data = response.json()
2205
- # POST on /resource_type/{id}/array_item/ will return the updated {id} resource
2206
- # instead of a newly created resource. In this case, the send_json must be
2207
- # in the array of the 'array_item' property on {id}
2208
- send_path: str = response.request.path_url
2209
- response_path = response_data.get("href", None)
2210
- if response_path and send_path not in response_path:
2211
- property_to_check = send_path.replace(response_path, "")[1:]
2212
- if response_data.get(property_to_check) and isinstance(
2213
- response_data[property_to_check], list
2214
- ):
2215
- item_list: List[Dict[str, Any]] = response_data[property_to_check]
2216
- # Use the (mandatory) id to get the POSTed resource from the list
2217
- [response_data] = [
2218
- item for item in item_list if item["id"] == send_json["id"]
2219
- ]
2220
-
2221
- # incoming arguments are dictionaries, so they can be validated as such
2222
- validate_dict_response(send_dict=send_json, received_dict=response_data)
2223
-
2224
- # In case of PATCH requests, ensure that only send properties have changed
2225
- if original_data:
2226
- for send_property_name, send_value in original_data.items():
2227
- if send_property_name not in send_json.keys():
2228
- assert send_value == response_data[send_property_name], (
2229
- f"Received value for {send_property_name} '{response_data[send_property_name]}' does not "
2230
- f"match '{send_value}' in the pre-patch data"
2231
- f"\nPre-patch: {_json.dumps(original_data, indent=4, sort_keys=True)}"
2232
- f"\nGot: {_json.dumps(response_data, indent=4, sort_keys=True)}"
2233
- )
2234
- return None
2235
-
2236
- def _get_response_spec(
2237
- self, path: str, method: str, status_code: int
2238
- ) -> Dict[str, Any]:
2239
- method = method.lower()
2240
- status = str(status_code)
2241
- spec: Dict[str, Any] = {**self.openapi_spec}["paths"][path][method][
2242
- "responses"
2243
- ][status]
2244
- return spec
661
+ except ResolutionError as exception: # pragma: no cover
662
+ raise FatalError(
663
+ f"ResolutionError while trying to load openapi spec: {exception}"
664
+ ) from exception
665
+ except ValidationError as exception: # pragma: no cover
666
+ raise FatalError(
667
+ f"ValidationError while trying to load openapi spec: {exception}"
668
+ ) from exception
669
+
670
+ def read_paths(self) -> dict[str, PathItemObject]:
671
+ return self.openapi_spec.paths
672
+
673
+ __init__.__doc__ = OPENAPILIBCORE_INIT_DOCSTRING
674
+
675
+
676
+ OpenApiLibCore.__doc__ = OPENAPILIBCORE_LIBRARY_DOCSTRING