voluptuous-openapi 0.0.4__tar.gz → 0.0.6__tar.gz

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.
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: voluptuous-openapi
3
- Version: 0.0.4
3
+ Version: 0.0.6
4
4
  Summary: Convert voluptuous schemas to OpenAPI Schema object
5
- Home-page: http://github.com/Shulyaka/voluptuous-openapi
5
+ Home-page: https://github.com/home-assistant-libs/voluptuous-openapi
6
6
  Author: Denis Shulyaka
7
7
  Author-email: Shulyaka@gmail.com
8
8
  License: Apache License 2.0
@@ -2,9 +2,9 @@ from setuptools import setup
2
2
 
3
3
  setup(
4
4
  name="voluptuous-openapi",
5
- version="0.0.4",
5
+ version="0.0.6",
6
6
  description="Convert voluptuous schemas to OpenAPI Schema object",
7
- url="http://github.com/Shulyaka/voluptuous-openapi",
7
+ url="https://github.com/home-assistant-libs/voluptuous-openapi",
8
8
  author="Denis Shulyaka",
9
9
  author_email="Shulyaka@gmail.com",
10
10
  license="Apache License 2.0",
@@ -1,9 +1,10 @@
1
1
  from enum import Enum
2
+ from typing import Any, TypeVar
2
3
 
4
+ import pytest
3
5
  import voluptuous as vol
4
- from typing import Any, TypeVar
5
6
 
6
- from voluptuous_openapi import UNSUPPORTED, convert
7
+ from voluptuous_openapi import UNSUPPORTED, convert, convert_to_voluptuous
7
8
 
8
9
 
9
10
  def test_int_schema():
@@ -50,11 +51,18 @@ def test_datetime():
50
51
 
51
52
 
52
53
  def test_in():
53
- assert {"enum": ["beer", "wine"]} == convert(vol.Schema(vol.In(["beer", "wine"])))
54
+ assert {"type": "string", "enum": ["beer", "wine"]} == convert(
55
+ vol.Schema(vol.In(["beer", "wine"]))
56
+ )
57
+
58
+
59
+ def test_in_integer():
60
+ assert {"type": "integer", "enum": [1, 2]} == convert(vol.Schema(vol.In([1, 2])))
54
61
 
55
62
 
56
63
  def test_in_dict():
57
64
  assert {
65
+ "type": "string",
58
66
  "enum": ["en_US", "zh_CN"],
59
67
  } == convert(
60
68
  vol.Schema(
@@ -112,6 +120,19 @@ def test_dict():
112
120
  vol.Schema({})
113
121
  )
114
122
 
123
+ def string(x: str) -> str:
124
+ return x
125
+
126
+ assert {"type": "object", "additionalProperties": {"type": "string"}} == convert(
127
+ vol.Schema({string: string})
128
+ )
129
+ assert {"type": "object", "additionalProperties": True} == convert(
130
+ vol.Schema(object)
131
+ )
132
+ assert {"type": "object", "additionalProperties": True} == convert(
133
+ vol.Schema({string: object})
134
+ )
135
+
115
136
 
116
137
  def test_tuple():
117
138
  assert {"type": "array", "items": {"type": "string"}} == convert(vol.Schema(tuple))
@@ -221,17 +242,39 @@ def test_custom_serializer():
221
242
 
222
243
 
223
244
  def test_constant():
224
- for value in True, False, "Hello", 1, None:
225
- assert {"enum": [value]} == convert(vol.Schema(value))
226
- assert {"enum": [None]} == convert(vol.Schema(type(None)))
245
+ assert {"type": "boolean", "enum": [True]} == convert(vol.Schema(True))
246
+ assert {"type": "boolean", "enum": [False]} == convert(vol.Schema(False))
247
+ assert {"type": "string", "enum": ["Hello"]} == convert(vol.Schema("Hello"))
248
+ assert {"type": "integer", "enum": [1]} == convert(vol.Schema(1))
249
+ assert {"type": "number", "enum": [1.5]} == convert(vol.Schema(1.5))
250
+ assert {
251
+ "type": "object",
252
+ "nullable": True,
253
+ "description": "Must be null",
254
+ } == convert(vol.Schema(None))
255
+ assert {
256
+ "type": "object",
257
+ "nullable": True,
258
+ "description": "Must be null",
259
+ } == convert(vol.Schema(type(None)))
227
260
 
228
261
 
229
262
  def test_enum():
230
- class TestEnum(Enum):
263
+ class StringEnum(Enum):
231
264
  ONE = "one"
265
+ TWO = "two"
266
+
267
+ assert {"type": "string", "enum": ["one", "two"]} == convert(
268
+ vol.Schema(vol.Coerce(StringEnum))
269
+ )
270
+
271
+ class IntEnum(Enum):
272
+ ONE = 1
232
273
  TWO = 2
233
274
 
234
- assert {"enum": ["one", 2]} == convert(vol.Schema(vol.Coerce(TestEnum)))
275
+ assert {"type": "integer", "enum": [1, 2]} == convert(
276
+ vol.Schema(vol.Coerce(IntEnum))
277
+ )
235
278
 
236
279
 
237
280
  def test_list():
@@ -256,12 +299,58 @@ def test_any_of():
256
299
  vol.Any(float, int)
257
300
  )
258
301
 
302
+ assert {"anyOf": [{"type": "number"}, {"type": "integer"}]} == convert(
303
+ vol.Any(float, int, float, int, int)
304
+ )
305
+
306
+ assert {"type": "object", "additionalProperties": True} == convert(
307
+ vol.Any(float, int, object)
308
+ )
309
+
310
+ assert {"type": "integer", "nullable": True, "enum": [1, 2]} == convert(
311
+ vol.Schema(vol.In([1, 2, None]))
312
+ )
313
+
314
+ assert {"type": "integer", "enum": [1, 2, 3]} == convert(
315
+ vol.Schema(vol.Any(1, 2, 3))
316
+ )
317
+
318
+ assert {
319
+ "anyOf": [{"type": "number"}, {"type": "integer"}, {"type": "string"}]
320
+ } == convert(
321
+ vol.Any(
322
+ vol.Any(float, int), vol.Any(int, float), vol.Any(float, vol.Any(int, str))
323
+ )
324
+ )
325
+
326
+ assert {
327
+ "anyOf": [{"type": "number"}, {"type": "integer"}],
328
+ "nullable": True,
329
+ } == convert(vol.Any(vol.Maybe(float), vol.Maybe(int)))
330
+
259
331
 
260
332
  def test_all_of():
261
333
  assert {"allOf": [{"minimum": 5}, {"minimum": 10}]} == convert(
262
334
  vol.All(vol.Range(min=5), vol.Range(min=10))
263
335
  )
264
336
 
337
+ assert {"type": "string"} == convert(vol.All(object, str))
338
+
339
+ assert {"type": "object", "additionalProperties": {"type": "string"}} == convert(
340
+ vol.All(object, {str: str})
341
+ )
342
+
343
+ assert {"maximum": 10, "minimum": 5, "type": "number"} == convert(
344
+ vol.All(vol.Range(min=5), vol.Range(max=10))
345
+ )
346
+
347
+ assert {"maximum": 10, "minimum": 5, "type": "number"} == convert(
348
+ vol.All(
349
+ vol.All(vol.Range(min=5), float),
350
+ vol.All(vol.All(vol.Range(max=10), float), float),
351
+ )
352
+ )
353
+
265
354
 
266
355
  def test_key_any():
267
356
  assert {
@@ -286,6 +375,24 @@ def test_key_any():
286
375
  )
287
376
  )
288
377
 
378
+ assert {
379
+ "properties": {
380
+ "conversation_command": {"type": "string"},
381
+ "hours": {"type": "integer"},
382
+ "minutes": {"type": "integer"},
383
+ "name": {"type": "string"},
384
+ "seconds": {"type": "integer"},
385
+ },
386
+ "required": [],
387
+ "type": "object",
388
+ } == convert(
389
+ {
390
+ vol.Required(vol.Any("hours", "minutes", "seconds")): int,
391
+ vol.Optional("name"): str,
392
+ vol.Optional("conversation_command"): str,
393
+ }
394
+ )
395
+
289
396
 
290
397
  def test_function():
291
398
  def validator(data):
@@ -376,3 +483,99 @@ def test_function():
376
483
  assert {"type": "object", "additionalProperties": {"type": "integer"}} == convert(
377
484
  validator_dict_int
378
485
  )
486
+
487
+
488
+ def test_nested_in_list():
489
+ assert {
490
+ "properties": {
491
+ "drink": {
492
+ "type": "array",
493
+ "items": {"type": "string", "enum": ["beer", "wine"]},
494
+ },
495
+ },
496
+ "required": [],
497
+ "type": "object",
498
+ } == convert(vol.Schema({vol.Optional("drink"): [vol.In(["beer", "wine"])]}))
499
+
500
+ assert {"type": "integer", "enum": [1, 2, 3]} == convert(
501
+ vol.Schema(vol.In([1, 2, 3]))
502
+ )
503
+
504
+
505
+ def test_reverse_int_schema():
506
+ assert convert_to_voluptuous({"type": "integer"}) == int
507
+
508
+
509
+ def test_reverse_str_schema():
510
+ assert convert_to_voluptuous({"type": "string"}) == str
511
+
512
+
513
+ def test_reverse_float_schema():
514
+ assert convert_to_voluptuous({"type": "number"}) == float
515
+
516
+
517
+ def test_reverse_bool_schema():
518
+ assert convert_to_voluptuous({"type": "boolean"}) == bool
519
+
520
+
521
+ def test_reverse_datetime():
522
+ validator = convert_to_voluptuous(
523
+ {
524
+ "type": "string",
525
+ "format": "date-time",
526
+ }
527
+ )
528
+ validator("2025-01-01T12:32:55.11Z")
529
+
530
+ with pytest.raises(vol.Invalid):
531
+ validator("2021-01-01")
532
+ with pytest.raises(vol.Invalid):
533
+ validator("abc")
534
+
535
+
536
+ def test_reverse_unknown_type():
537
+ with pytest.raises(ValueError):
538
+ convert_to_voluptuous({})
539
+
540
+ with pytest.raises(ValueError):
541
+ convert_to_voluptuous({"type": "unknown"})
542
+
543
+
544
+ def test_convert_to_voluptuous_wrong_type() -> None:
545
+ """Test calling with the wrong type"""
546
+
547
+ with pytest.raises(ValueError):
548
+ convert_to_voluptuous({"oneOf": ["integer"]})
549
+
550
+ with pytest.raises(ValueError):
551
+ convert_to_voluptuous({"oneOf": "integer"})
552
+
553
+ with pytest.raises(ValueError):
554
+ convert_to_voluptuous("a")
555
+
556
+
557
+ def test_unsupported_features() -> None:
558
+ """Test converting a mixed aray type."""
559
+
560
+ with pytest.raises(ValueError):
561
+ convert_to_voluptuous({"type": "integer", "multipleOf": 2})
562
+
563
+ with pytest.raises(ValueError):
564
+ convert_to_voluptuous({"type": "array", "items": {"minItems": 1}})
565
+
566
+
567
+ def test_mixed_type_list() -> None:
568
+ """Test converting a mixed aray type."""
569
+ validator = convert_to_voluptuous(
570
+ {"type": "array", "items": {"oneOf": [{"type": "string"}, {"type": "integer"}]}}
571
+ )
572
+
573
+ validator(["a", "b"])
574
+ validator([1, 2])
575
+ validator(["a", 1, "b", 2])
576
+
577
+ with pytest.raises(vol.Invalid):
578
+ validator("abc")
579
+
580
+ with pytest.raises(vol.Invalid):
581
+ validator(123)
@@ -0,0 +1,387 @@
1
+ """Tests for voluptuous schema and openapi schemas that exercise validation code.
2
+
3
+ Each test in this file defines an equivalent schema in both `openapi` and
4
+ `voluptuous` formats. The schema is then converted to the other format and
5
+ validation code is run against all variations of schema types.
6
+
7
+ The motivation is because voluptuous schemas cannot be introspected directly
8
+ and are tested by exercising with both valid and invalid data.
9
+ """
10
+
11
+ from collections.abc import Callable, Generator
12
+ import datetime
13
+
14
+ import pytest
15
+ import voluptuous as vol
16
+ import openapi_schema_validator
17
+ from typing import Any
18
+ import logging
19
+
20
+ from voluptuous_openapi import convert, convert_to_voluptuous
21
+ from jsonschema.exceptions import ValidationError
22
+
23
+
24
+ _LOGGER = logging.getLogger(__name__)
25
+
26
+ # Validator type used to represent a validation function for a specific schema type
27
+ Validator = Callable[[Any], Any]
28
+
29
+
30
+ class InvalidFormat(Exception):
31
+ """Validation exception thrown on invalid input test data."""
32
+
33
+
34
+ def voluptuous_validator(schema: vol.Schema) -> Validator:
35
+ """Create a Validator for a voluptuous schema."""
36
+
37
+ def validator(data: Any) -> Any:
38
+ try:
39
+ _LOGGER.debug("Validating %s with schema %s", data, schema)
40
+ return schema(data)
41
+ except (vol.Invalid, ValueError) as e:
42
+ raise InvalidFormat(str(e))
43
+
44
+ return validator
45
+
46
+
47
+ def openapi_validator(schema: dict) -> Any:
48
+ """Create a Validator for an OpenAPI schema."""
49
+
50
+ def validator(data: Any) -> Any:
51
+ try:
52
+ _LOGGER.debug("Validating %s with schema %s", data, schema)
53
+ openapi_schema_validator.validate(data, schema)
54
+ return data
55
+ except ValidationError as e:
56
+ raise InvalidFormat(str(e))
57
+
58
+ return validator
59
+
60
+
61
+ # Order of id created by `generate_validators`
62
+ TEST_IDS = ["openapi", "voluptuous", "voluptuous_to_openapi", "openapi_to_voluptuous"]
63
+
64
+
65
+ def generate_validators(
66
+ openapi_schema: dict, voluptuous_schema: vol.Schema
67
+ ) -> Generator[Validator]:
68
+ """Create validation functions for the various schema types."""
69
+
70
+ # Native schema validations
71
+ yield openapi_validator(openapi_schema)
72
+ yield voluptuous_validator(voluptuous_schema)
73
+
74
+ # Converted schema validations
75
+ yield openapi_validator(convert(voluptuous_schema))
76
+ yield voluptuous_validator(convert_to_voluptuous(openapi_schema))
77
+
78
+
79
+ @pytest.mark.parametrize(
80
+ "validator",
81
+ generate_validators(
82
+ {"type": "string"},
83
+ str,
84
+ ),
85
+ ids=TEST_IDS,
86
+ )
87
+ def test_string(validator: Validator) -> None:
88
+ """Test string schema."""
89
+
90
+ validator("hello")
91
+ validator("A" * 10)
92
+ validator("A" * 12)
93
+ validator("123")
94
+ # Note voluptuos coerces everything to string but openapi does not,
95
+ # so not validated here.
96
+
97
+
98
+ @pytest.mark.parametrize(
99
+ "validator",
100
+ generate_validators(
101
+ {"type": "string", "minLength": 1, "maxLength": 10},
102
+ vol.All(str, vol.Length(min=1, max=10)),
103
+ ),
104
+ ids=TEST_IDS,
105
+ )
106
+ def test_string_min_max_length(validator: Validator) -> None:
107
+ """Test string min and max length."""
108
+
109
+ validator("hello")
110
+ validator("A" * 10)
111
+
112
+ with pytest.raises(InvalidFormat):
113
+ validator(123)
114
+
115
+ with pytest.raises(InvalidFormat):
116
+ validator("")
117
+
118
+ with pytest.raises(InvalidFormat):
119
+ validator("A" * 12)
120
+
121
+
122
+ @pytest.mark.parametrize(
123
+ "validator",
124
+ generate_validators(
125
+ {"type": "integer"},
126
+ int,
127
+ ),
128
+ ids=TEST_IDS,
129
+ )
130
+ def test_int(validator: Validator) -> None:
131
+ """Test int schema."""
132
+
133
+ validator(1)
134
+ validator(10)
135
+ validator(0)
136
+
137
+ with pytest.raises(InvalidFormat):
138
+ validator("abc")
139
+
140
+
141
+ @pytest.mark.parametrize(
142
+ "validator",
143
+ generate_validators(
144
+ {"type": "integer", "minimum": 1, "maximum": 10},
145
+ vol.All(int, vol.Range(min=1, max=10)),
146
+ ),
147
+ ids=TEST_IDS,
148
+ )
149
+ def test_int_range(validator: Validator) -> None:
150
+ """Test an int range"""
151
+
152
+ validator(1)
153
+ validator(10)
154
+
155
+ with pytest.raises(InvalidFormat):
156
+ validator(0)
157
+
158
+ with pytest.raises(InvalidFormat):
159
+ validator(11)
160
+
161
+ with pytest.raises(InvalidFormat):
162
+ validator(5.5)
163
+
164
+ with pytest.raises(InvalidFormat):
165
+ validator("abc")
166
+
167
+
168
+ @pytest.mark.parametrize(
169
+ "validator",
170
+ generate_validators(
171
+ {"type": "number"},
172
+ float,
173
+ ),
174
+ ids=TEST_IDS,
175
+ )
176
+ def test_float(validator: Validator) -> None:
177
+ """Test float schema."""
178
+
179
+ validator(1.0)
180
+ validator(5.5)
181
+ validator(10.0)
182
+
183
+ with pytest.raises(InvalidFormat):
184
+ validator("abc")
185
+
186
+
187
+ @pytest.mark.parametrize(
188
+ "validator",
189
+ generate_validators(
190
+ {"type": "number", "minimum": 1, "maximum": 10},
191
+ vol.All(float, vol.Range(min=1, max=10)),
192
+ ),
193
+ ids=TEST_IDS,
194
+ )
195
+ def test_float_range(validator: Validator) -> None:
196
+ """Test float range schema."""
197
+
198
+ validator(1.0)
199
+ validator(5.5)
200
+ validator(10.0)
201
+
202
+ with pytest.raises(InvalidFormat):
203
+ validator(0.0)
204
+
205
+ with pytest.raises(InvalidFormat):
206
+ validator(10.1)
207
+
208
+ with pytest.raises(InvalidFormat):
209
+ validator("abc")
210
+
211
+
212
+ @pytest.mark.parametrize(
213
+ "validator",
214
+ generate_validators(
215
+ {"type": "string", "pattern": r"^\d{3}-\d{2}-\d{4}$"},
216
+ vol.All(str, vol.Match(r"^\d{3}-\d{2}-\d{4}$")),
217
+ ),
218
+ ids=TEST_IDS,
219
+ )
220
+ def test_match_pattern(validator: Validator) -> None:
221
+ """Test matching a regular expression pattern."""
222
+
223
+ validator("555-10-2020")
224
+
225
+ with pytest.raises(InvalidFormat):
226
+ validator("555-1-2020")
227
+
228
+ with pytest.raises(InvalidFormat):
229
+ validator("555")
230
+
231
+ with pytest.raises(InvalidFormat):
232
+ validator("abc")
233
+
234
+
235
+ @pytest.mark.parametrize(
236
+ "validator",
237
+ generate_validators(
238
+ {"type": "array", "items": {"type": "string"}},
239
+ vol.All([str]),
240
+ ),
241
+ ids=TEST_IDS,
242
+ )
243
+ def test_string_list(validator: Validator) -> None:
244
+ """Test a list of strings."""
245
+
246
+ validator(["a"])
247
+ validator(["a", "b"])
248
+
249
+ with pytest.raises(InvalidFormat):
250
+ validator("abc")
251
+
252
+ with pytest.raises(InvalidFormat):
253
+ validator(123)
254
+
255
+
256
+ @pytest.mark.parametrize(
257
+ "validator",
258
+ generate_validators(
259
+ {
260
+ "type": "object",
261
+ "properties": {"id": {"type": "integer"}, "name": {"type": "string"}},
262
+ "required": ["id"],
263
+ },
264
+ vol.Schema({vol.Required("id"): int, vol.Optional("name"): str}),
265
+ ),
266
+ ids=TEST_IDS,
267
+ )
268
+ def test_object(validator: Validator) -> None:
269
+ """Test an object."""
270
+ validator({"id": 1, "name": "hello"})
271
+ validator({"id": 1})
272
+
273
+ with pytest.raises(InvalidFormat):
274
+ validator({"id": "abc", "name": "hello"})
275
+
276
+ with pytest.raises(InvalidFormat):
277
+ validator({"name": "hello"})
278
+
279
+ with pytest.raises(InvalidFormat):
280
+ validator("abc")
281
+
282
+ with pytest.raises(InvalidFormat):
283
+ validator(123)
284
+
285
+
286
+ @pytest.mark.parametrize(
287
+ "validator",
288
+ generate_validators(
289
+ {
290
+ "type": "object",
291
+ "properties": {
292
+ "id": {"type": "integer"},
293
+ "content": {
294
+ "type": "object",
295
+ "properties": {
296
+ "name": {"type": "string"},
297
+ },
298
+ },
299
+ },
300
+ },
301
+ vol.Schema(
302
+ {
303
+ vol.Required("id"): int,
304
+ vol.Optional("content"): vol.Schema({vol.Optional("name"): str}),
305
+ }
306
+ ),
307
+ ),
308
+ ids=TEST_IDS,
309
+ )
310
+ def test_nested_object(validator: Validator) -> None:
311
+ """Test an object nested in an object."""
312
+ validator({"id": 1, "content": {"name": "hello"}})
313
+ validator({"id": 1, "content": {}})
314
+ validator({"id": 1})
315
+
316
+ with pytest.raises(InvalidFormat):
317
+ validator({"id": 1, "content": {"name": 1234}})
318
+
319
+ with pytest.raises(InvalidFormat):
320
+ validator(123)
321
+
322
+
323
+ @pytest.mark.parametrize(
324
+ "validator",
325
+ generate_validators(
326
+ {
327
+ "type": "object",
328
+ "properties": {"id": {"type": "integer"}},
329
+ "additionalProperties": True,
330
+ },
331
+ vol.Schema(
332
+ {vol.Required("id"): int, vol.Optional("name"): str}, extra=vol.ALLOW_EXTRA
333
+ ),
334
+ ),
335
+ ids=TEST_IDS,
336
+ )
337
+ def test_allow_extra(validator: Validator) -> None:
338
+ """Test additional properties are allowed."""
339
+ validator({"id": 1})
340
+ validator({"id": 1, "extra-key": "hello"})
341
+
342
+ with pytest.raises(InvalidFormat):
343
+ validator(123)
344
+
345
+
346
+ @pytest.mark.parametrize(
347
+ "validator",
348
+ generate_validators(
349
+ {
350
+ "type": "object",
351
+ "properties": {"id": {"type": "integer"}},
352
+ "additionalProperties": False,
353
+ },
354
+ vol.Schema({vol.Required("id"): int, vol.Optional("name"): str}),
355
+ ),
356
+ ids=TEST_IDS,
357
+ )
358
+ def test_no_extra(validator: Validator) -> None:
359
+ """Test additional properties are not allowed."""
360
+ validator({"id": 1})
361
+
362
+ # TODO: Note this does not currently fail when converting from openapi to voluptuous because
363
+ # additionalProperties: False is not set. Fix that then uncomment here.
364
+ # with pytest.raises(InvalidFormat):
365
+ # validator({"id": 1, "extra-key": "hello"})
366
+
367
+ with pytest.raises(InvalidFormat):
368
+ validator(123)
369
+
370
+
371
+ @pytest.mark.parametrize(
372
+ "validator",
373
+ generate_validators(
374
+ {"oneOf": [{"type": "string"}, {"type": "integer"}]},
375
+ vol.Any(str, int),
376
+ ),
377
+ ids=TEST_IDS,
378
+ )
379
+ def test_one_of(validator: Validator) -> None:
380
+ """Test oneOf multiple types."""
381
+
382
+ validator(1)
383
+ validator(10)
384
+ validator("hello")
385
+
386
+ with pytest.raises(InvalidFormat):
387
+ validator(1.4)