robotframework-openapitools 0.4.0__py3-none-any.whl → 1.0.0b2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. OpenApiDriver/__init__.py +44 -41
  2. OpenApiDriver/openapi_executors.py +40 -39
  3. OpenApiDriver/openapi_reader.py +115 -116
  4. OpenApiDriver/openapidriver.libspec +71 -61
  5. OpenApiDriver/openapidriver.py +25 -19
  6. OpenApiLibCore/__init__.py +13 -11
  7. OpenApiLibCore/annotations.py +3 -0
  8. OpenApiLibCore/data_generation/__init__.py +12 -0
  9. OpenApiLibCore/data_generation/body_data_generation.py +269 -0
  10. OpenApiLibCore/data_generation/data_generation_core.py +240 -0
  11. OpenApiLibCore/data_invalidation.py +281 -0
  12. OpenApiLibCore/dto_base.py +29 -35
  13. OpenApiLibCore/dto_utils.py +97 -85
  14. OpenApiLibCore/oas_cache.py +14 -13
  15. OpenApiLibCore/openapi_libcore.libspec +346 -193
  16. OpenApiLibCore/openapi_libcore.py +389 -1702
  17. OpenApiLibCore/parameter_utils.py +91 -0
  18. OpenApiLibCore/path_functions.py +215 -0
  19. OpenApiLibCore/path_invalidation.py +44 -0
  20. OpenApiLibCore/protocols.py +30 -0
  21. OpenApiLibCore/request_data.py +281 -0
  22. OpenApiLibCore/resource_relations.py +54 -0
  23. OpenApiLibCore/validation.py +497 -0
  24. OpenApiLibCore/value_utils.py +528 -481
  25. openapi_libgen/__init__.py +46 -0
  26. openapi_libgen/command_line.py +87 -0
  27. openapi_libgen/parsing_utils.py +26 -0
  28. openapi_libgen/spec_parser.py +221 -0
  29. openapi_libgen/templates/__init__.jinja +3 -0
  30. openapi_libgen/templates/library.jinja +30 -0
  31. robotframework_openapitools-1.0.0b2.dist-info/METADATA +237 -0
  32. robotframework_openapitools-1.0.0b2.dist-info/RECORD +37 -0
  33. {robotframework_openapitools-0.4.0.dist-info → robotframework_openapitools-1.0.0b2.dist-info}/WHEEL +1 -1
  34. robotframework_openapitools-1.0.0b2.dist-info/entry_points.txt +3 -0
  35. roboswag/__init__.py +0 -9
  36. roboswag/__main__.py +0 -3
  37. roboswag/auth.py +0 -44
  38. roboswag/cli.py +0 -80
  39. roboswag/core.py +0 -85
  40. roboswag/generate/__init__.py +0 -1
  41. roboswag/generate/generate.py +0 -121
  42. roboswag/generate/models/__init__.py +0 -0
  43. roboswag/generate/models/api.py +0 -219
  44. roboswag/generate/models/definition.py +0 -28
  45. roboswag/generate/models/endpoint.py +0 -68
  46. roboswag/generate/models/parameter.py +0 -25
  47. roboswag/generate/models/response.py +0 -8
  48. roboswag/generate/models/tag.py +0 -16
  49. roboswag/generate/models/utils.py +0 -60
  50. roboswag/generate/templates/api_init.jinja +0 -15
  51. roboswag/generate/templates/models.jinja +0 -7
  52. roboswag/generate/templates/paths.jinja +0 -68
  53. roboswag/logger.py +0 -33
  54. roboswag/validate/__init__.py +0 -6
  55. roboswag/validate/core.py +0 -3
  56. roboswag/validate/schema.py +0 -21
  57. roboswag/validate/text_response.py +0 -14
  58. robotframework_openapitools-0.4.0.dist-info/METADATA +0 -42
  59. robotframework_openapitools-0.4.0.dist-info/RECORD +0 -41
  60. {robotframework_openapitools-0.4.0.dist-info → robotframework_openapitools-1.0.0b2.dist-info}/LICENSE +0 -0
@@ -1,481 +1,528 @@
1
- # mypy: disable-error-code=no-any-return
2
- """Utility module with functions to handle OpenAPI value types and restrictions."""
3
- import base64
4
- import datetime
5
- from copy import deepcopy
6
- from logging import getLogger
7
- from random import choice, randint, uniform
8
- from typing import Any, Callable, Dict, List, Optional, Union
9
-
10
- import faker
11
- import rstr
12
-
13
- JSON = Union[Dict[str, "JSON"], List["JSON"], str, int, float, bool, None]
14
-
15
- logger = getLogger(__name__)
16
-
17
- IGNORE = object()
18
-
19
-
20
- class LocalizedFaker:
21
- """Class to support setting a locale post-init."""
22
-
23
- # pylint: disable=missing-function-docstring
24
- def __init__(self) -> None:
25
- self.fake = faker.Faker()
26
-
27
- def set_locale(self, locale: Union[str, List[str]]) -> None:
28
- """Update the fake attribute with a Faker instance with the provided locale."""
29
- self.fake = faker.Faker(locale)
30
-
31
- @property
32
- def date(self) -> Callable[[], str]:
33
- return self.fake.date
34
-
35
- @property
36
- def date_time(self) -> Callable[[], datetime.datetime]:
37
- return self.fake.date_time
38
-
39
- @property
40
- def password(self) -> Callable[[], str]:
41
- return self.fake.password
42
-
43
- @property
44
- def binary(self) -> Callable[[], bytes]:
45
- return self.fake.binary
46
-
47
- @property
48
- def email(self) -> Callable[[], str]:
49
- return self.fake.safe_email
50
-
51
- @property
52
- def uuid(self) -> Callable[[], str]:
53
- return self.fake.uuid4
54
-
55
- @property
56
- def uri(self) -> Callable[[], str]:
57
- return self.fake.uri
58
-
59
- @property
60
- def url(self) -> Callable[[], str]:
61
- return self.fake.url
62
-
63
- @property
64
- def hostname(self) -> Callable[[], str]:
65
- return self.fake.hostname
66
-
67
- @property
68
- def ipv4(self) -> Callable[[], str]:
69
- return self.fake.ipv4
70
-
71
- @property
72
- def ipv6(self) -> Callable[[], str]:
73
- return self.fake.ipv6
74
-
75
- @property
76
- def name(self) -> Callable[[], str]:
77
- return self.fake.name
78
-
79
- @property
80
- def text(self) -> Callable[[], str]:
81
- return self.fake.text
82
-
83
- @property
84
- def description(self) -> Callable[[], str]:
85
- return self.fake.text
86
-
87
-
88
- FAKE = LocalizedFaker()
89
-
90
-
91
- def json_type_name_of_python_type(python_type: Any) -> str:
92
- """Return the JSON type name for supported Python types."""
93
- if python_type == str:
94
- return "string"
95
- if python_type == bool:
96
- return "boolean"
97
- if python_type == int:
98
- return "integer"
99
- if python_type == float:
100
- return "number"
101
- if python_type == list:
102
- return "array"
103
- if python_type == dict:
104
- return "object"
105
- if python_type == type(None):
106
- return "null"
107
- raise ValueError(f"No json type mapping for Python type {python_type} available.")
108
-
109
-
110
- def python_type_by_json_type_name(type_name: str) -> Any:
111
- """Return the Python type based on the JSON type name."""
112
- if type_name == "string":
113
- return str
114
- if type_name == "boolean":
115
- return bool
116
- if type_name == "integer":
117
- return int
118
- if type_name == "number":
119
- return float
120
- if type_name == "array":
121
- return list
122
- if type_name == "object":
123
- return dict
124
- if type_name == "null":
125
- return type(None)
126
- raise ValueError(f"No Python type mapping for JSON type '{type_name}' available.")
127
-
128
-
129
- def get_valid_value(value_schema: Dict[str, Any]) -> Any:
130
- """Return a random value that is valid under the provided value_schema."""
131
- value_schema = deepcopy(value_schema)
132
-
133
- if value_schema.get("types"):
134
- value_schema = choice(value_schema["types"])
135
-
136
- if (from_const := value_schema.get("const")) is not None:
137
- return from_const
138
- if from_enum := value_schema.get("enum"):
139
- return choice(from_enum)
140
-
141
- value_type = value_schema["type"]
142
-
143
- if value_type == "null":
144
- return None
145
- if value_type == "boolean":
146
- return FAKE.fake.boolean()
147
- if value_type == "integer":
148
- return get_random_int(value_schema=value_schema)
149
- if value_type == "number":
150
- return get_random_float(value_schema=value_schema)
151
- if value_type == "string":
152
- return get_random_string(value_schema=value_schema)
153
- if value_type == "array":
154
- return get_random_array(value_schema=value_schema)
155
- raise NotImplementedError(f"Type '{value_type}' is currently not supported")
156
-
157
-
158
- def get_invalid_value(
159
- value_schema: Dict[str, Any],
160
- current_value: Any,
161
- values_from_constraint: Optional[List[Any]] = None,
162
- ) -> Any:
163
- """Return a random value that violates the provided value_schema."""
164
- value_schema = deepcopy(value_schema)
165
-
166
- if value_schemas := value_schema.get("types"):
167
- if len(value_schemas) > 1:
168
- value_schemas = [
169
- schema for schema in value_schemas if schema["type"] != "null"
170
- ]
171
- value_schema = choice(value_schemas)
172
-
173
- invalid_value: Any = None
174
- value_type = value_schema["type"]
175
-
176
- if not isinstance(current_value, python_type_by_json_type_name(value_type)):
177
- current_value = get_valid_value(value_schema=value_schema)
178
-
179
- if (
180
- values_from_constraint
181
- and (
182
- invalid_value := get_invalid_value_from_constraint(
183
- values_from_constraint=values_from_constraint,
184
- value_type=value_type,
185
- )
186
- )
187
- is not None
188
- ):
189
- return invalid_value
190
- # If an enum is possible, combine the values from the enum to invalidate the value
191
- if enum_values := value_schema.get("enum"):
192
- if (
193
- invalid_value := get_invalid_value_from_enum(
194
- values=enum_values, value_type=value_type
195
- )
196
- ) is not None:
197
- return invalid_value
198
- # Violate min / max values or length if possible
199
- if (
200
- invalid_value := get_value_out_of_bounds(
201
- value_schema=value_schema, current_value=current_value
202
- )
203
- ) is not None:
204
- return invalid_value
205
- # No value constraints or min / max ranges to violate, so change the data type
206
- if value_type == "string":
207
- # Since int / float / bool can always be cast to sting, change
208
- # the string to a nested object.
209
- # An array gets exploded in query strings, "null" is then often invalid
210
- return [{"invalid": [None, False]}, "null", None, True]
211
- logger.debug(f"property type changed from {value_type} to random string")
212
- return FAKE.uuid()
213
-
214
-
215
- def get_random_int(value_schema: Dict[str, Any]) -> int:
216
- """Generate a random int within the min/max range of the schema, if specified."""
217
- # Use int32 integers if "format" does not specify int64
218
- property_format = value_schema.get("format", "int32")
219
- if property_format == "int64":
220
- min_int = -9223372036854775808
221
- max_int = 9223372036854775807
222
- else:
223
- min_int = -2147483648
224
- max_int = 2147483647
225
- # OAS 3.0: exclusiveMinimum/Maximum is a bool in combination with minimum/maximum
226
- # OAS 3.1: exclusiveMinimum/Maximum is an integer
227
- minimum = value_schema.get("minimum", min_int)
228
- maximum = value_schema.get("maximum", max_int)
229
- if (exclusive_minimum := value_schema.get("exclusiveMinimum")) is not None:
230
- if isinstance(exclusive_minimum, bool):
231
- if exclusive_minimum:
232
- minimum += 1
233
- else:
234
- minimum = exclusive_minimum + 1
235
- if (exclusive_maximum := value_schema.get("exclusiveMaximum")) is not None:
236
- if isinstance(exclusive_maximum, bool):
237
- if exclusive_maximum:
238
- maximum -= 1
239
- else:
240
- maximum = exclusive_maximum - 1
241
- return randint(minimum, maximum)
242
-
243
-
244
- def get_random_float(value_schema: Dict[str, Any]) -> float:
245
- """Generate a random float within the min/max range of the schema, if specified."""
246
- # Python floats are already double precision, so no check for "format"
247
- minimum = value_schema.get("minimum")
248
- maximum = value_schema.get("maximum")
249
- if minimum is None:
250
- if maximum is None:
251
- minimum = -1.0
252
- maximum = 1.0
253
- else:
254
- minimum = maximum - 1.0
255
- else:
256
- if maximum is None:
257
- maximum = minimum + 1.0
258
- if maximum < minimum:
259
- raise ValueError(f"maximum of {maximum} is less than minimum of {minimum}")
260
-
261
- # For simplicity's sake, exclude both boundaries if one boundary is exclusive
262
- exclude_boundaries = False
263
-
264
- exclusive_minimum = value_schema.get("exclusiveMinimum", False)
265
- exclusive_maximum = value_schema.get("exclusiveMaximum", False)
266
- # OAS 3.0: exclusiveMinimum/Maximum is a bool in combination with minimum/maximum
267
- # OAS 3.1: exclusiveMinimum/Maximum is an integer or number
268
- if not isinstance(exclusive_minimum, bool):
269
- exclude_boundaries = True
270
- minimum = exclusive_minimum
271
- else:
272
- exclude_boundaries = exclusive_minimum
273
- if not isinstance(exclusive_maximum, bool):
274
- exclude_boundaries = True
275
- maximum = exclusive_maximum
276
- else:
277
- exclude_boundaries = exclusive_minimum or exclusive_maximum
278
-
279
- if exclude_boundaries and minimum == maximum:
280
- raise ValueError(
281
- f"maximum of {maximum} is equal to minimum of {minimum} and "
282
- f"exclusiveMinimum or exclusiveMaximum is specified"
283
- )
284
-
285
- while exclude_boundaries:
286
- result = uniform(minimum, maximum)
287
- if minimum < result < maximum: # pragma: no cover
288
- return result
289
- return uniform(minimum, maximum)
290
-
291
-
292
- def get_random_string(value_schema: Dict[str, Any]) -> Union[bytes, str]:
293
- """Generate a random string within the min/max length in the schema, if specified."""
294
- # if a pattern is provided, format and min/max length can be ignored
295
- if pattern := value_schema.get("pattern"):
296
- return rstr.xeger(pattern)
297
- minimum = value_schema.get("minLength", 0)
298
- maximum = value_schema.get("maxLength", 36)
299
- if minimum > maximum:
300
- maximum = minimum
301
- format_ = value_schema.get("format", "uuid")
302
- # byte is a special case due to the required encoding
303
- if format_ == "byte":
304
- data = FAKE.uuid()
305
- return base64.b64encode(data.encode("utf-8"))
306
- value = fake_string(string_format=format_)
307
- while len(value) < minimum:
308
- # use fake.name() to ensure the returned string uses the provided locale
309
- value = value + FAKE.name()
310
- if len(value) > maximum:
311
- value = value[:maximum]
312
- return value
313
-
314
-
315
- def fake_string(string_format: str) -> str:
316
- """
317
- Generate a random string based on the provided format if the format is supported.
318
- """
319
- # format names may contain -, which is invalid in Python naming
320
- string_format = string_format.replace("-", "_")
321
- fake_generator = getattr(FAKE, string_format, FAKE.uuid)
322
- value: str = fake_generator()
323
- if isinstance(value, datetime.datetime):
324
- return value.strftime("%Y-%m-%dT%H:%M:%SZ")
325
- return value
326
-
327
-
328
- def get_random_array(value_schema: Dict[str, Any]) -> List[Any]:
329
- """Generate a list with random elements as specified by the schema."""
330
- minimum = value_schema.get("minItems", 0)
331
- maximum = value_schema.get("maxItems", 1)
332
- if minimum > maximum:
333
- maximum = minimum
334
- items_schema = value_schema["items"]
335
- value = []
336
- for _ in range(maximum):
337
- item_value = get_valid_value(items_schema)
338
- value.append(item_value)
339
- return value
340
-
341
-
342
- def get_invalid_value_from_constraint(
343
- values_from_constraint: List[Any], value_type: str
344
- ) -> Any:
345
- """
346
- Return a value of the same type as the values in the values_from_constraints that
347
- is not in the values_from_constraints, if possible. Otherwise returns None.
348
- """
349
- # if IGNORE is in the values_from_constraints, the parameter needs to be
350
- # ignored for an OK response so leaving the value at it's original value
351
- # should result in the specified error response
352
- if IGNORE in values_from_constraint:
353
- return IGNORE
354
- # if the value is forced True or False, return the opposite to invalidate
355
- if len(values_from_constraint) == 1 and value_type == "boolean":
356
- return not values_from_constraint[0]
357
- # for unsupported types or empty constraints lists return None
358
- if (
359
- value_type not in ["string", "integer", "number", "array", "object"]
360
- or not values_from_constraint
361
- ):
362
- return None
363
-
364
- values_from_constraint = deepcopy(values_from_constraint)
365
- # for objects, keep the keys intact but update the values
366
- if value_type == "object":
367
- valid_object = values_from_constraint.pop()
368
- invalid_object = {}
369
- for key, value in valid_object.items():
370
- python_type_of_value = type(value)
371
- json_type_of_value = json_type_name_of_python_type(python_type_of_value)
372
- invalid_object[key] = get_invalid_value_from_constraint(
373
- values_from_constraint=[value],
374
- value_type=json_type_of_value,
375
- )
376
- return invalid_object
377
-
378
- # for arrays, update each value in the array to a value of the same type
379
- if value_type == "array":
380
- valid_array = values_from_constraint.pop()
381
- invalid_array = []
382
- for value in valid_array:
383
- python_type_of_value = type(value)
384
- json_type_of_value = json_type_name_of_python_type(python_type_of_value)
385
- invalid_value = get_invalid_value_from_constraint(
386
- values_from_constraint=[value],
387
- value_type=json_type_of_value,
388
- )
389
- invalid_array.append(invalid_value)
390
- return invalid_array
391
-
392
- invalid_values = 2 * values_from_constraint
393
- invalid_value = invalid_values.pop()
394
- if value_type in ["integer", "number"]:
395
- for value in invalid_values:
396
- invalid_value = abs(invalid_value) + abs(value)
397
- if not invalid_value:
398
- invalid_value += 1
399
- return invalid_value
400
- for value in invalid_values:
401
- invalid_value = invalid_value + value
402
- # None for empty string
403
- return invalid_value if invalid_value else None
404
-
405
-
406
- def get_invalid_value_from_enum(values: List[Any], value_type: str) -> Any:
407
- """Return a value not in the enum by combining the enum values."""
408
- if value_type == "string":
409
- invalid_value: Any = ""
410
- elif value_type in ["integer", "number"]:
411
- invalid_value = 0
412
- elif value_type == "array":
413
- invalid_value = []
414
- elif value_type == "object":
415
- # force creation of a new object since we will be modifying it
416
- invalid_value = {**values[0]}
417
- else:
418
- logger.warning(f"Cannot invalidate enum value with type {value_type}")
419
- return None
420
- for value in values:
421
- # repeat each addition to ensure single-item enums are invalidated
422
- if value_type in ["integer", "number"]:
423
- invalid_value += abs(value) + abs(value)
424
- if value_type == "string":
425
- invalid_value += value + value
426
- if value_type == "array":
427
- invalid_value.extend(value)
428
- invalid_value.extend(value)
429
- # objects are a special case, since they must be of the same type / class
430
- # invalid_value.update(value) will end up with the last value in the list,
431
- # which is a valid value, so another approach is needed
432
- if value_type == "object":
433
- for key in invalid_value.keys():
434
- invalid_value[key] = value.get(key)
435
- if invalid_value not in values:
436
- return invalid_value
437
- return invalid_value
438
-
439
-
440
- def get_value_out_of_bounds(value_schema: Dict[str, Any], current_value: Any) -> Any:
441
- """
442
- Return a value just outside the value or length range if specified in the
443
- provided schema, otherwise None is returned.
444
- """
445
- value_type = value_schema["type"]
446
-
447
- if value_type in ["integer", "number"]:
448
- if (minimum := value_schema.get("minimum")) is not None:
449
- if value_schema.get("exclusiveMinimum") is True:
450
- return minimum
451
- return minimum - 1
452
- if (maximum := value_schema.get("maximum")) is not None:
453
- if value_schema.get("exclusiveMaximum") is True:
454
- return maximum
455
- return maximum + 1
456
- # In OAS 3.1 exclusveMinimum/Maximum are no longer boolean but instead integer
457
- # or number and minimum/maximum should not be used with exclusiveMinimum/Maximum
458
- if (exclusive_minimum := value_schema.get("exclusiveMinimum")) is not None:
459
- return exclusive_minimum
460
- if (exclusive_maximum := value_schema.get("exclusiveMaximum")) is not None:
461
- return exclusive_maximum
462
- if value_type == "array":
463
- if minimum := value_schema.get("minItems", 0) > 0:
464
- return current_value[0 : minimum - 1]
465
- if (maximum := value_schema.get("maxItems")) is not None:
466
- invalid_value = current_value if current_value else ["x"]
467
- while len(invalid_value) <= maximum:
468
- invalid_value.append(choice(invalid_value))
469
- return invalid_value
470
- if value_type == "string":
471
- # if there is a minimum length, send 1 character less
472
- if minimum := value_schema.get("minLength", 0):
473
- return current_value[0 : minimum - 1]
474
- # if there is a maximum length, send 1 character more
475
- if maximum := value_schema.get("maxLength"):
476
- invalid_value = current_value if current_value else "x"
477
- # add random characters from the current value to prevent adding new characters
478
- while len(invalid_value) <= maximum:
479
- invalid_value += choice(invalid_value)
480
- return invalid_value
481
- return None
1
+ # mypy: disable-error-code=no-any-return
2
+ """Utility module with functions to handle OpenAPI value types and restrictions."""
3
+
4
+ import base64
5
+ import datetime
6
+ from copy import deepcopy
7
+ from random import choice, randint, uniform
8
+ from typing import Any, Callable, Iterable, Mapping, cast, overload
9
+
10
+ import faker
11
+ import rstr
12
+ from robot.api import logger
13
+
14
+ from OpenApiLibCore.annotations import JSON
15
+
16
+
17
+ class Ignore:
18
+ """Helper class to flag properties to be ignored in data generation."""
19
+
20
+ def __str__(self) -> str:
21
+ return "IGNORE"
22
+
23
+
24
+ class UnSet:
25
+ """Helper class to flag arguments that have not been set in a keyword call."""
26
+
27
+ def __str__(self) -> str:
28
+ return "UNSET"
29
+
30
+
31
+ IGNORE = Ignore()
32
+
33
+ UNSET = UnSet()
34
+
35
+
36
+ class LocalizedFaker:
37
+ """Class to support setting a locale post-init."""
38
+
39
+ # pylint: disable=missing-function-docstring
40
+ def __init__(self) -> None:
41
+ self.fake = faker.Faker()
42
+
43
+ def set_locale(self, locale: str | list[str]) -> None:
44
+ """Update the fake attribute with a Faker instance with the provided locale."""
45
+ self.fake = faker.Faker(locale)
46
+
47
+ @property
48
+ def date(self) -> Callable[[], str]:
49
+ return self.fake.date
50
+
51
+ @property
52
+ def date_time(self) -> Callable[[], datetime.datetime]:
53
+ return self.fake.date_time
54
+
55
+ @property
56
+ def password(self) -> Callable[[], str]:
57
+ return self.fake.password
58
+
59
+ @property
60
+ def binary(self) -> Callable[[], bytes]:
61
+ return self.fake.binary
62
+
63
+ @property
64
+ def email(self) -> Callable[[], str]:
65
+ return self.fake.safe_email
66
+
67
+ @property
68
+ def uuid(self) -> Callable[[], str]:
69
+ return self.fake.uuid4
70
+
71
+ @property
72
+ def uri(self) -> Callable[[], str]:
73
+ return self.fake.uri
74
+
75
+ @property
76
+ def url(self) -> Callable[[], str]:
77
+ return self.fake.url
78
+
79
+ @property
80
+ def hostname(self) -> Callable[[], str]:
81
+ return self.fake.hostname
82
+
83
+ @property
84
+ def ipv4(self) -> Callable[[], str]:
85
+ return self.fake.ipv4
86
+
87
+ @property
88
+ def ipv6(self) -> Callable[[], str]:
89
+ return self.fake.ipv6
90
+
91
+ @property
92
+ def name(self) -> Callable[[], str]:
93
+ return self.fake.name
94
+
95
+ @property
96
+ def text(self) -> Callable[[], str]:
97
+ return self.fake.text
98
+
99
+ @property
100
+ def description(self) -> Callable[[], str]:
101
+ return self.fake.text
102
+
103
+
104
+ FAKE = LocalizedFaker()
105
+
106
+
107
+ def json_type_name_of_python_type(python_type: Any) -> str:
108
+ """Return the JSON type name for supported Python types."""
109
+ if python_type == str:
110
+ return "string"
111
+ if python_type == bool:
112
+ return "boolean"
113
+ if python_type == int:
114
+ return "integer"
115
+ if python_type == float:
116
+ return "number"
117
+ if python_type == list:
118
+ return "array"
119
+ if python_type == dict:
120
+ return "object"
121
+ if python_type == type(None):
122
+ return "null"
123
+ raise ValueError(f"No json type mapping for Python type {python_type} available.")
124
+
125
+
126
+ def python_type_by_json_type_name(type_name: str) -> type:
127
+ """Return the Python type based on the JSON type name."""
128
+ if type_name == "string":
129
+ return str
130
+ if type_name == "boolean":
131
+ return bool
132
+ if type_name == "integer":
133
+ return int
134
+ if type_name == "number":
135
+ return float
136
+ if type_name == "array":
137
+ return list
138
+ if type_name == "object":
139
+ return dict
140
+ if type_name == "null":
141
+ return type(None)
142
+ raise ValueError(f"No Python type mapping for JSON type '{type_name}' available.")
143
+
144
+
145
+ def get_valid_value(value_schema: Mapping[str, Any]) -> JSON:
146
+ """Return a random value that is valid under the provided value_schema."""
147
+ value_schema = deepcopy(value_schema)
148
+
149
+ if value_schema.get("types"):
150
+ value_schema = choice(value_schema["types"])
151
+
152
+ if (from_const := value_schema.get("const")) is not None:
153
+ return from_const
154
+ if from_enum := value_schema.get("enum"):
155
+ return choice(from_enum)
156
+
157
+ value_type = value_schema["type"]
158
+
159
+ if value_type == "null":
160
+ return None
161
+ if value_type == "boolean":
162
+ return FAKE.fake.boolean()
163
+ if value_type == "integer":
164
+ return get_random_int(value_schema=value_schema)
165
+ if value_type == "number":
166
+ return get_random_float(value_schema=value_schema)
167
+ if value_type == "string":
168
+ return get_random_string(value_schema=value_schema)
169
+ if value_type == "array":
170
+ return get_random_array(value_schema=value_schema)
171
+ raise NotImplementedError(f"Type '{value_type}' is currently not supported")
172
+
173
+
174
+ def get_invalid_value(
175
+ value_schema: Mapping[str, Any],
176
+ current_value: Any,
177
+ values_from_constraint: Iterable[Any] = tuple(),
178
+ ) -> JSON | Ignore:
179
+ """Return a random value that violates the provided value_schema."""
180
+ value_schema = deepcopy(value_schema)
181
+
182
+ if value_schemas := value_schema.get("types"):
183
+ if len(value_schemas) > 1:
184
+ value_schemas = [
185
+ schema for schema in value_schemas if schema["type"] != "null"
186
+ ]
187
+ value_schema = choice(value_schemas)
188
+
189
+ invalid_value: Any = None
190
+ value_type = value_schema["type"]
191
+
192
+ if not isinstance(current_value, python_type_by_json_type_name(value_type)):
193
+ current_value = get_valid_value(value_schema=value_schema)
194
+
195
+ if (
196
+ values_from_constraint
197
+ and (
198
+ invalid_value := get_invalid_value_from_constraint(
199
+ values_from_constraint=list(values_from_constraint),
200
+ value_type=value_type,
201
+ )
202
+ )
203
+ is not None
204
+ ):
205
+ return invalid_value
206
+ # If an enum is possible, combine the values from the enum to invalidate the value
207
+ if enum_values := value_schema.get("enum"):
208
+ if (
209
+ invalid_value := get_invalid_value_from_enum(
210
+ values=enum_values, value_type=value_type
211
+ )
212
+ ) is not None:
213
+ return invalid_value
214
+ # Violate min / max values or length if possible
215
+ if (
216
+ invalid_value := get_value_out_of_bounds(
217
+ value_schema=value_schema, current_value=current_value
218
+ )
219
+ ) is not None:
220
+ return invalid_value
221
+ # No value constraints or min / max ranges to violate, so change the data type
222
+ if value_type == "string":
223
+ # Since int / float / bool can always be cast to sting, change
224
+ # the string to a nested object.
225
+ # An array gets exploded in query strings, "null" is then often invalid
226
+ return [{"invalid": [None, False]}, "null", None, True]
227
+ logger.debug(f"property type changed from {value_type} to random string")
228
+ return FAKE.uuid()
229
+
230
+
231
+ def get_random_int(value_schema: Mapping[str, Any]) -> int:
232
+ """Generate a random int within the min/max range of the schema, if specified."""
233
+ # Use int32 integers if "format" does not specify int64
234
+ property_format = value_schema.get("format", "int32")
235
+ if property_format == "int64":
236
+ min_int = -9223372036854775808
237
+ max_int = 9223372036854775807
238
+ else:
239
+ min_int = -2147483648
240
+ max_int = 2147483647
241
+ # OAS 3.0: exclusiveMinimum/Maximum is a bool in combination with minimum/maximum
242
+ # OAS 3.1: exclusiveMinimum/Maximum is an integer
243
+ minimum = value_schema.get("minimum", min_int)
244
+ maximum = value_schema.get("maximum", max_int)
245
+ if (exclusive_minimum := value_schema.get("exclusiveMinimum")) is not None:
246
+ if isinstance(exclusive_minimum, bool):
247
+ if exclusive_minimum:
248
+ minimum += 1
249
+ else:
250
+ minimum = exclusive_minimum + 1
251
+ if (exclusive_maximum := value_schema.get("exclusiveMaximum")) is not None:
252
+ if isinstance(exclusive_maximum, bool):
253
+ if exclusive_maximum:
254
+ maximum -= 1
255
+ else:
256
+ maximum = exclusive_maximum - 1
257
+ return randint(minimum, maximum)
258
+
259
+
260
+ def get_random_float(value_schema: Mapping[str, Any]) -> float:
261
+ """Generate a random float within the min/max range of the schema, if specified."""
262
+ # Python floats are already double precision, so no check for "format"
263
+ minimum = value_schema.get("minimum")
264
+ maximum = value_schema.get("maximum")
265
+ if minimum is None:
266
+ if maximum is None:
267
+ minimum = -1.0
268
+ maximum = 1.0
269
+ else:
270
+ minimum = maximum - 1.0
271
+ else:
272
+ if maximum is None:
273
+ maximum = minimum + 1.0
274
+ if maximum < minimum:
275
+ raise ValueError(f"maximum of {maximum} is less than minimum of {minimum}")
276
+
277
+ # For simplicity's sake, exclude both boundaries if one boundary is exclusive
278
+ exclude_boundaries = False
279
+
280
+ exclusive_minimum = value_schema.get("exclusiveMinimum", False)
281
+ exclusive_maximum = value_schema.get("exclusiveMaximum", False)
282
+ # OAS 3.0: exclusiveMinimum/Maximum is a bool in combination with minimum/maximum
283
+ # OAS 3.1: exclusiveMinimum/Maximum is an integer or number
284
+ if not isinstance(exclusive_minimum, bool):
285
+ exclude_boundaries = True
286
+ minimum = exclusive_minimum
287
+ else:
288
+ exclude_boundaries = exclusive_minimum
289
+ if not isinstance(exclusive_maximum, bool):
290
+ exclude_boundaries = True
291
+ maximum = exclusive_maximum
292
+ else:
293
+ exclude_boundaries = exclusive_minimum or exclusive_maximum
294
+
295
+ if exclude_boundaries and minimum == maximum:
296
+ raise ValueError(
297
+ f"maximum of {maximum} is equal to minimum of {minimum} and "
298
+ f"exclusiveMinimum or exclusiveMaximum is specified"
299
+ )
300
+
301
+ while exclude_boundaries:
302
+ result = uniform(minimum, maximum)
303
+ if minimum < result < maximum: # pragma: no cover
304
+ return result
305
+ return uniform(minimum, maximum)
306
+
307
+
308
+ def get_random_string(value_schema: Mapping[str, Any]) -> bytes | str:
309
+ """Generate a random string within the min/max length in the schema, if specified."""
310
+ # if a pattern is provided, format and min/max length can be ignored
311
+ if pattern := value_schema.get("pattern"):
312
+ return rstr.xeger(pattern)
313
+ minimum = value_schema.get("minLength", 0)
314
+ maximum = value_schema.get("maxLength", 36)
315
+ maximum = max(minimum, maximum)
316
+ format_ = value_schema.get("format", "uuid")
317
+ # byte is a special case due to the required encoding
318
+ if format_ == "byte":
319
+ data = FAKE.uuid()
320
+ return base64.b64encode(data.encode("utf-8"))
321
+ value = fake_string(string_format=format_)
322
+ while len(value) < minimum:
323
+ # use fake.name() to ensure the returned string uses the provided locale
324
+ value = value + FAKE.name()
325
+ if len(value) > maximum:
326
+ value = value[:maximum]
327
+ return value
328
+
329
+
330
+ def fake_string(string_format: str) -> str:
331
+ """
332
+ Generate a random string based on the provided format if the format is supported.
333
+ """
334
+ # format names may contain -, which is invalid in Python naming
335
+ string_format = string_format.replace("-", "_")
336
+ fake_generator = getattr(FAKE, string_format, FAKE.uuid)
337
+ value: str = fake_generator()
338
+ if isinstance(value, datetime.datetime):
339
+ return value.strftime("%Y-%m-%dT%H:%M:%SZ")
340
+ return value
341
+
342
+
343
+ def get_random_array(value_schema: Mapping[str, Any]) -> list[JSON]:
344
+ """Generate a list with random elements as specified by the schema."""
345
+ minimum = value_schema.get("minItems", 0)
346
+ maximum = value_schema.get("maxItems", 1)
347
+ maximum = max(minimum, maximum)
348
+ items_schema = value_schema["items"]
349
+ value = []
350
+ for _ in range(maximum):
351
+ item_value = get_valid_value(items_schema)
352
+ value.append(item_value)
353
+ return value
354
+
355
+
356
+ def get_invalid_value_from_constraint(
357
+ values_from_constraint: list[JSON | Ignore], value_type: str
358
+ ) -> JSON | Ignore:
359
+ """
360
+ Return a value of the same type as the values in the values_from_constraints that
361
+ is not in the values_from_constraints, if possible. Otherwise returns None.
362
+ """
363
+ # if IGNORE is in the values_from_constraints, the parameter needs to be
364
+ # ignored for an OK response so leaving the value at it's original value
365
+ # should result in the specified error response
366
+ if any(map(lambda x: isinstance(x, Ignore), values_from_constraint)):
367
+ return IGNORE
368
+ # if the value is forced True or False, return the opposite to invalidate
369
+ if len(values_from_constraint) == 1 and value_type == "boolean":
370
+ return not values_from_constraint[0]
371
+ # for unsupported types or empty constraints lists return None
372
+ if (
373
+ value_type not in ["string", "integer", "number", "array", "object"]
374
+ or not values_from_constraint
375
+ ):
376
+ return None
377
+
378
+ values_from_constraint = deepcopy(values_from_constraint)
379
+ # for objects, keep the keys intact but update the values
380
+ if value_type == "object":
381
+ valid_object = cast(dict[str, JSON], values_from_constraint.pop())
382
+ invalid_object: dict[str, JSON] = {}
383
+ for key, value in valid_object.items():
384
+ python_type_of_value = type(value)
385
+ json_type_of_value = json_type_name_of_python_type(python_type_of_value)
386
+ invalid_value = cast(
387
+ JSON,
388
+ get_invalid_value_from_constraint(
389
+ values_from_constraint=[value],
390
+ value_type=json_type_of_value,
391
+ ),
392
+ )
393
+ invalid_object[key] = invalid_value
394
+ return invalid_object
395
+
396
+ # for arrays, update each value in the array to a value of the same type
397
+ if value_type == "array":
398
+ valid_array = cast(list[JSON], values_from_constraint.pop())
399
+ invalid_array: list[JSON] = []
400
+ for value in valid_array:
401
+ python_type_of_value = type(value)
402
+ json_type_of_value = json_type_name_of_python_type(python_type_of_value)
403
+ invalid_value = cast(
404
+ JSON,
405
+ get_invalid_value_from_constraint(
406
+ values_from_constraint=[value],
407
+ value_type=json_type_of_value,
408
+ ),
409
+ )
410
+ invalid_array.append(invalid_value)
411
+ return invalid_array
412
+
413
+ if value_type in ["integer", "number"]:
414
+ int_or_number_list = cast(list[int | float], values_from_constraint)
415
+ return get_invalid_int_or_number(values_from_constraint=int_or_number_list)
416
+
417
+ str_or_bytes_list = cast(list[str] | list[bytes], values_from_constraint)
418
+ invalid_value = get_invalid_str_or_bytes(values_from_constraint=str_or_bytes_list)
419
+ # None for empty string
420
+ return invalid_value if invalid_value else None
421
+
422
+
423
+ def get_invalid_int_or_number(values_from_constraint: list[int | float]) -> int | float:
424
+ invalid_values = 2 * values_from_constraint
425
+ invalid_value = invalid_values.pop()
426
+ for value in invalid_values:
427
+ invalid_value = abs(invalid_value) + abs(value)
428
+ if not invalid_value:
429
+ invalid_value += 1
430
+ return invalid_value
431
+
432
+
433
+ @overload
434
+ def get_invalid_str_or_bytes(values_from_constraint: list[str]) -> str: ...
435
+
436
+
437
+ @overload
438
+ def get_invalid_str_or_bytes(values_from_constraint: list[bytes]) -> bytes: ...
439
+
440
+
441
+ def get_invalid_str_or_bytes(values_from_constraint: list[Any]) -> Any:
442
+ invalid_values = 2 * values_from_constraint
443
+ invalid_value = invalid_values.pop()
444
+ for value in invalid_values:
445
+ invalid_value = invalid_value + value
446
+ return invalid_value
447
+
448
+
449
+ def get_invalid_value_from_enum(values: list[Any], value_type: str) -> JSON:
450
+ """Return a value not in the enum by combining the enum values."""
451
+ if value_type == "string":
452
+ invalid_value: Any = ""
453
+ elif value_type in ["integer", "number"]:
454
+ invalid_value = 0
455
+ elif value_type == "array":
456
+ invalid_value = []
457
+ elif value_type == "object":
458
+ # force creation of a new object since we will be modifying it
459
+ invalid_value = {**values[0]}
460
+ else:
461
+ logger.warn(f"Cannot invalidate enum value with type {value_type}")
462
+ return None
463
+ for value in values:
464
+ # repeat each addition to ensure single-item enums are invalidated
465
+ if value_type in ["integer", "number"]:
466
+ invalid_value += abs(value) + abs(value)
467
+ if value_type == "string":
468
+ invalid_value += value + value
469
+ if value_type == "array":
470
+ invalid_value.extend(value)
471
+ invalid_value.extend(value)
472
+ # objects are a special case, since they must be of the same type / class
473
+ # invalid_value.update(value) will end up with the last value in the list,
474
+ # which is a valid value, so another approach is needed
475
+ if value_type == "object":
476
+ for key in invalid_value.keys():
477
+ invalid_value[key] = value.get(key)
478
+ if invalid_value not in values:
479
+ return invalid_value
480
+ return invalid_value
481
+
482
+
483
+ def get_value_out_of_bounds(
484
+ value_schema: Mapping[str, Any], current_value: JSON
485
+ ) -> JSON:
486
+ """
487
+ Return a value just outside the value or length range if specified in the
488
+ provided schema, otherwise None is returned.
489
+ """
490
+ value_type = value_schema["type"]
491
+
492
+ if value_type in ["integer", "number"]:
493
+ if (minimum := value_schema.get("minimum")) is not None:
494
+ if value_schema.get("exclusiveMinimum") is True:
495
+ return minimum
496
+ return minimum - 1
497
+ if (maximum := value_schema.get("maximum")) is not None:
498
+ if value_schema.get("exclusiveMaximum") is True:
499
+ return maximum
500
+ return maximum + 1
501
+ # In OAS 3.1 exclusveMinimum/Maximum are no longer boolean but instead integer
502
+ # or number and minimum/maximum should not be used with exclusiveMinimum/Maximum
503
+ if (exclusive_minimum := value_schema.get("exclusiveMinimum")) is not None:
504
+ return exclusive_minimum
505
+ if (exclusive_maximum := value_schema.get("exclusiveMaximum")) is not None:
506
+ return exclusive_maximum
507
+ if value_type == "array":
508
+ current_list = cast(list[JSON], current_value)
509
+ if minimum := value_schema.get("minItems", 0) > 0:
510
+ return current_list[0 : minimum - 1]
511
+ if (maximum := value_schema.get("maxItems")) is not None:
512
+ invalid_value = current_list if current_list else ["x"]
513
+ while len(invalid_value) <= maximum:
514
+ invalid_value.append(choice(invalid_value)) # pyright: ignore[reportArgumentType]
515
+ return invalid_value # type: ignore[unused-ignore]
516
+ if value_type == "string":
517
+ current_string = cast(str, current_value)
518
+ # if there is a minimum length, send 1 character less
519
+ if minimum := value_schema.get("minLength", 0):
520
+ return current_string[0 : minimum - 1]
521
+ # if there is a maximum length, send 1 character more
522
+ if maximum := value_schema.get("maxLength"):
523
+ invalid_string_value = current_string if current_string else "x"
524
+ # add random characters from the current value to prevent adding new characters
525
+ while len(invalid_string_value) <= maximum:
526
+ invalid_string_value += choice(invalid_string_value)
527
+ return invalid_string_value
528
+ return None