voluptuous-openapi 0.0.7__tar.gz → 0.2.0__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voluptuous-openapi
3
- Version: 0.0.7
3
+ Version: 0.2.0
4
4
  Summary: Convert voluptuous schemas to OpenAPI Schema object
5
5
  Author-email: Denis Shulyaka <Shulyaka@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ requires = ["setuptools>=77.0"]
4
4
 
5
5
  [project]
6
6
  name = "voluptuous-openapi"
7
- version = "0.0.7"
7
+ version = "0.2.0"
8
8
  license = "Apache-2.0"
9
9
  description = "Convert voluptuous schemas to OpenAPI Schema object"
10
10
  readme = "README.md"
@@ -4,7 +4,12 @@ from typing import Any, TypeVar
4
4
  import pytest
5
5
  import voluptuous as vol
6
6
 
7
- from voluptuous_openapi import UNSUPPORTED, convert, convert_to_voluptuous
7
+ from voluptuous_openapi import (
8
+ UNSUPPORTED,
9
+ convert,
10
+ convert_to_voluptuous,
11
+ OpenApiVersion,
12
+ )
8
13
 
9
14
 
10
15
  def test_int_schema():
@@ -226,6 +231,15 @@ def test_maybe():
226
231
  } == convert(vol.Schema(vol.Maybe(str)))
227
232
 
228
233
 
234
+ def test_maybe_v3_1():
235
+ assert {
236
+ "anyOf": [
237
+ {"type": "null"},
238
+ {"type": "string"},
239
+ ],
240
+ } == convert(vol.Schema(vol.Maybe(str)), openapi_version=OpenApiVersion.V3_1)
241
+
242
+
229
243
  def test_custom_serializer():
230
244
  def custem_serializer(schema):
231
245
  if schema is str:
@@ -257,6 +271,12 @@ def test_constant():
257
271
  "nullable": True,
258
272
  "description": "Must be null",
259
273
  } == convert(vol.Schema(type(None)))
274
+ assert {
275
+ "type": "null",
276
+ } == convert(vol.Schema(None), openapi_version=OpenApiVersion.V3_1)
277
+ assert {
278
+ "type": "null",
279
+ } == convert(vol.Schema(type(None)), openapi_version=OpenApiVersion.V3_1)
260
280
 
261
281
 
262
282
  def test_enum():
@@ -327,6 +347,11 @@ def test_any_of():
327
347
  "anyOf": [{"type": "number"}, {"type": "integer"}],
328
348
  "nullable": True,
329
349
  } == convert(vol.Any(vol.Maybe(float), vol.Maybe(int)))
350
+ assert {
351
+ "anyOf": [{"type": "null"}, {"type": "number"}, {"type": "integer"}],
352
+ } == convert(
353
+ vol.Any(vol.Maybe(float), vol.Maybe(int)), openapi_version=OpenApiVersion.V3_1
354
+ )
330
355
 
331
356
 
332
357
  def test_all_of():
@@ -421,6 +446,9 @@ def test_function():
421
446
  assert {"type": "number", "nullable": True} == convert(
422
447
  vol.Schema(validator_nullable)
423
448
  )
449
+ assert {"anyOf": [{"type": "number"}, {"type": "null"}]} == convert(
450
+ vol.Schema(validator_nullable), openapi_version=OpenApiVersion.V3_1
451
+ )
424
452
 
425
453
  def validator_union(data: float | int):
426
454
  return data
@@ -606,3 +634,199 @@ def test_convert_to_voluptuous_marker_description(required: list[str]):
606
634
  ("query", "The query to search for"),
607
635
  ("max_results", "The maximum number of results to return"),
608
636
  ]
637
+
638
+
639
+ def test_convert_to_voluptuous_nullable_string_openapi_3_0():
640
+ """Test OpenAPI 3.0 nullable string."""
641
+ validator = convert_to_voluptuous({"type": "string", "nullable": True})
642
+ validator("test")
643
+ validator(None)
644
+
645
+ with pytest.raises(vol.Invalid):
646
+ validator(123)
647
+
648
+
649
+ def test_convert_to_voluptuous_non_nullable_string_openapi_3_0():
650
+ """Test OpenAPI 3.0 non-nullable string."""
651
+ validator_type = convert_to_voluptuous({"type": "string", "nullable": False})
652
+ # Wrap in a Schema for proper validation
653
+ validator = vol.Schema(validator_type)
654
+ validator("test")
655
+ validator("") # empty string should be valid
656
+
657
+ with pytest.raises(vol.Invalid):
658
+ validator(None)
659
+
660
+ with pytest.raises(vol.Invalid):
661
+ validator(123)
662
+
663
+
664
+ def test_convert_to_voluptuous_nullable_integer_openapi_3_0():
665
+ """Test OpenAPI 3.0 nullable integer."""
666
+ validator = convert_to_voluptuous({"type": "integer", "nullable": True})
667
+ validator(42)
668
+ validator(None)
669
+
670
+ with pytest.raises(vol.Invalid):
671
+ validator("string")
672
+
673
+
674
+ def test_convert_to_voluptuous_nullable_number_openapi_3_0():
675
+ """Test OpenAPI 3.0 nullable number."""
676
+ validator = convert_to_voluptuous({"type": "number", "nullable": True})
677
+ validator(3.14)
678
+ validator(None)
679
+
680
+ with pytest.raises(vol.Invalid):
681
+ validator("string")
682
+
683
+
684
+ def test_convert_to_voluptuous_nullable_boolean_openapi_3_0():
685
+ """Test OpenAPI 3.0 nullable boolean."""
686
+ validator = convert_to_voluptuous({"type": "boolean", "nullable": True})
687
+ validator(True)
688
+ validator(False)
689
+ validator(None)
690
+
691
+ with pytest.raises(vol.Invalid):
692
+ validator("string")
693
+
694
+
695
+ def test_convert_to_voluptuous_nullable_object_openapi_3_0():
696
+ """Test OpenAPI 3.0 nullable object."""
697
+ validator = convert_to_voluptuous(
698
+ {"type": "object", "properties": {"name": {"type": "string"}}, "nullable": True}
699
+ )
700
+ validator({"name": "test"})
701
+ validator(None)
702
+
703
+ with pytest.raises(vol.Invalid):
704
+ validator("string")
705
+
706
+
707
+ def test_convert_to_voluptuous_nullable_array_openapi_3_0():
708
+ """Test OpenAPI 3.0 nullable array."""
709
+ validator = convert_to_voluptuous(
710
+ {"type": "array", "items": {"type": "string"}, "nullable": True}
711
+ )
712
+ validator(["a", "b"])
713
+ validator(None)
714
+
715
+ with pytest.raises(vol.Invalid):
716
+ validator("string")
717
+
718
+
719
+ def test_convert_to_voluptuous_nullable_string_openapi_3_1():
720
+ """Test OpenAPI 3.1 nullable string using type array."""
721
+ validator = convert_to_voluptuous({"type": ["string", "null"]})
722
+ validator("test")
723
+ validator(None)
724
+
725
+ with pytest.raises(vol.Invalid):
726
+ validator(123)
727
+
728
+
729
+ def test_convert_to_voluptuous_nullable_integer_openapi_3_1():
730
+ """Test OpenAPI 3.1 nullable integer using type array."""
731
+ validator = convert_to_voluptuous({"type": ["integer", "null"]})
732
+ validator(42)
733
+ validator(None)
734
+
735
+ with pytest.raises(vol.Invalid):
736
+ validator("string")
737
+
738
+
739
+ def test_convert_to_voluptuous_multiple_types_with_null_openapi_3_1():
740
+ """Test OpenAPI 3.1 multiple types with null."""
741
+ validator = convert_to_voluptuous({"type": ["string", "integer", "null"]})
742
+ validator("test")
743
+ validator(42)
744
+ validator(None)
745
+
746
+ with pytest.raises(vol.Invalid):
747
+ validator(3.14)
748
+
749
+
750
+ def test_convert_to_voluptuous_multiple_types_without_null_openapi_3_1():
751
+ """Test OpenAPI 3.1 multiple types without null."""
752
+ validator = convert_to_voluptuous({"type": ["string", "integer"]})
753
+ validator("test")
754
+ validator(42)
755
+
756
+ with pytest.raises(vol.Invalid):
757
+ validator(None)
758
+
759
+
760
+ def test_convert_to_voluptuous_only_null_openapi_3_1():
761
+ """Test OpenAPI 3.1 type array with only null."""
762
+ validator = convert_to_voluptuous({"type": ["null"]})
763
+ validator(None)
764
+
765
+ with pytest.raises(vol.Invalid):
766
+ validator("test")
767
+
768
+
769
+ def test_convert_to_voluptuous_nullable_string_with_pattern():
770
+ """Test nullable string with pattern constraint."""
771
+ validator = convert_to_voluptuous(
772
+ {"type": "string", "pattern": r"^test", "nullable": True}
773
+ )
774
+ validator("testing")
775
+ validator(None)
776
+
777
+ with pytest.raises(vol.Invalid):
778
+ validator("nottest")
779
+
780
+
781
+ def test_convert_to_voluptuous_nullable_integer_with_range_openapi_3_0():
782
+ """Test nullable integer with range constraint (OpenAPI 3.0 style)."""
783
+ validator = convert_to_voluptuous(
784
+ {"type": "integer", "minimum": 1, "maximum": 10, "nullable": True}
785
+ )
786
+ validator(5)
787
+ validator(None)
788
+
789
+ with pytest.raises(vol.Invalid):
790
+ validator(15)
791
+
792
+
793
+ def test_convert_to_voluptuous_nullable_integer_with_range_openapi_3_1():
794
+ """Test nullable integer with range constraint (OpenAPI 3.1 style)."""
795
+ validator = convert_to_voluptuous(
796
+ {"type": ["integer", "null"], "minimum": 1, "maximum": 10}
797
+ )
798
+ validator(5)
799
+ validator(None)
800
+
801
+ with pytest.raises(vol.Invalid):
802
+ validator(15)
803
+
804
+
805
+ def test_convert_to_voluptuous_nullable_string_with_length_constraints():
806
+ """Test nullable string with length constraints."""
807
+ validator = convert_to_voluptuous(
808
+ {"type": "string", "minLength": 3, "maxLength": 10, "nullable": True}
809
+ )
810
+ validator("hello")
811
+ validator(None)
812
+
813
+ with pytest.raises(vol.Invalid):
814
+ validator("hi") # too short
815
+
816
+ with pytest.raises(vol.Invalid):
817
+ validator("verylongstring") # too long
818
+
819
+
820
+ def test_convert_to_voluptuous_nullable_number_with_range():
821
+ """Test nullable number with range constraints."""
822
+ validator = convert_to_voluptuous(
823
+ {"type": "number", "minimum": 0.0, "maximum": 100.0, "nullable": True}
824
+ )
825
+ validator(50.5)
826
+ validator(None)
827
+
828
+ with pytest.raises(vol.Invalid):
829
+ validator(-1.0) # too low
830
+
831
+ with pytest.raises(vol.Invalid):
832
+ validator(101.0) # too high
@@ -9,7 +9,6 @@ and are tested by exercising with both valid and invalid data.
9
9
  """
10
10
 
11
11
  from collections.abc import Callable, Generator
12
- import datetime
13
12
 
14
13
  import pytest
15
14
  import voluptuous as vol
@@ -17,7 +16,7 @@ import openapi_schema_validator
17
16
  from typing import Any
18
17
  import logging
19
18
 
20
- from voluptuous_openapi import convert, convert_to_voluptuous
19
+ from voluptuous_openapi import convert, convert_to_voluptuous, OpenApiVersion
21
20
  from jsonschema.exceptions import ValidationError
22
21
 
23
22
 
@@ -36,7 +35,7 @@ def voluptuous_validator(schema: vol.Schema) -> Validator:
36
35
 
37
36
  def validator(data: Any) -> Any:
38
37
  try:
39
- _LOGGER.debug("Validating %s with schema %s", data, schema)
38
+ _LOGGER.debug("Validating voluptuous %s with schema %s", data, schema)
40
39
  return schema(data)
41
40
  except (vol.Invalid, ValueError) as e:
42
41
  raise InvalidFormat(str(e))
@@ -49,7 +48,7 @@ def openapi_validator(schema: dict) -> Any:
49
48
 
50
49
  def validator(data: Any) -> Any:
51
50
  try:
52
- _LOGGER.debug("Validating %s with schema %s", data, schema)
51
+ _LOGGER.debug("Validating openai %s with schema %s", data, schema)
53
52
  openapi_schema_validator.validate(data, schema)
54
53
  return data
55
54
  except ValidationError as e:
@@ -71,8 +70,11 @@ def generate_validators(
71
70
  yield openapi_validator(openapi_schema)
72
71
  yield voluptuous_validator(voluptuous_schema)
73
72
 
74
- # Converted schema validations
75
- yield openapi_validator(convert(voluptuous_schema))
73
+ # Converted schema validations. We use OpenAPI version 3.1 because it has equivalent
74
+ # semantics to voluptuous.
75
+ yield openapi_validator(
76
+ convert(voluptuous_schema, openapi_version=OpenApiVersion.V3_1)
77
+ )
76
78
  yield voluptuous_validator(convert_to_voluptuous(openapi_schema))
77
79
 
78
80
 
@@ -343,6 +345,26 @@ def test_allow_extra(validator: Validator) -> None:
343
345
  validator(123)
344
346
 
345
347
 
348
+ @pytest.mark.parametrize(
349
+ "validator",
350
+ generate_validators(
351
+ {"type": "null"},
352
+ vol.Schema(None),
353
+ ),
354
+ ids=TEST_IDS,
355
+ )
356
+ def test_none(validator: Validator) -> None:
357
+ """Test null or None values in the schema."""
358
+
359
+ validator(None)
360
+
361
+ with pytest.raises(InvalidFormat):
362
+ validator("abc")
363
+
364
+ with pytest.raises(InvalidFormat):
365
+ validator(1.0)
366
+
367
+
346
368
  @pytest.mark.parametrize(
347
369
  "validator",
348
370
  generate_validators(
@@ -385,3 +407,172 @@ def test_one_of(validator: Validator) -> None:
385
407
 
386
408
  with pytest.raises(InvalidFormat):
387
409
  validator(1.4)
410
+
411
+ with pytest.raises(InvalidFormat):
412
+ validator({"key": "value"})
413
+
414
+
415
+ @pytest.mark.parametrize(
416
+ "validator",
417
+ generate_validators(
418
+ {"anyOf": [{"type": "string"}, {"type": "integer"}]},
419
+ vol.Any(str, int),
420
+ ),
421
+ ids=TEST_IDS,
422
+ )
423
+ def test_any_of(validator: Validator) -> None:
424
+ """Test anyOf multiple types."""
425
+
426
+ validator(1)
427
+ validator(10)
428
+ validator("hello")
429
+
430
+ with pytest.raises(InvalidFormat):
431
+ validator(1.4)
432
+
433
+ with pytest.raises(InvalidFormat):
434
+ validator({"key": "value"})
435
+
436
+
437
+ @pytest.mark.parametrize(
438
+ "validator",
439
+ generate_validators(
440
+ {"anyOf": [{"type": "string"}, {"type": "null"}]},
441
+ vol.Any(str, None),
442
+ ),
443
+ ids=TEST_IDS,
444
+ )
445
+ def test_any_of_with_null(validator: Validator) -> None:
446
+ """Test anyOf multiple types that includes null."""
447
+
448
+ validator("hello")
449
+ validator("")
450
+ # 'None' is allowed with type: null in openapi
451
+ validator(None)
452
+
453
+ with pytest.raises(InvalidFormat):
454
+ validator(1)
455
+
456
+ with pytest.raises(InvalidFormat):
457
+ validator(1.4)
458
+
459
+ with pytest.raises(InvalidFormat):
460
+ validator({"key": "value"})
461
+
462
+
463
+ @pytest.mark.parametrize(
464
+ "validator",
465
+ generate_validators(
466
+ {
467
+ "type": "object",
468
+ "properties": {
469
+ "id": {"type": "integer"},
470
+ "name": {"type": "string", "nullable": True},
471
+ },
472
+ "required": ["id"],
473
+ },
474
+ vol.Schema({vol.Required("id"): int, vol.Optional("name"): str}),
475
+ ),
476
+ ids=TEST_IDS,
477
+ )
478
+ def test_object_with_nullable(validator: Validator) -> None:
479
+ """Test an object with a nullable field."""
480
+
481
+ validator({"id": 1, "name": "hello"})
482
+
483
+ # Note: The openapi-schema-validator library doesn't properly support
484
+ # OpenAPI 3.0's nullable property, so None values will fail for the
485
+ # native OpenAPI validator but work for converted validators
486
+ try:
487
+ validator({"id": 1, "name": None})
488
+ # If this succeeds, it means the validator properly handles nullable
489
+ except InvalidFormat:
490
+ # This is expected for the native OpenAPI validator due to library limitations
491
+ pass
492
+
493
+ with pytest.raises(InvalidFormat):
494
+ validator(1)
495
+
496
+ with pytest.raises(InvalidFormat):
497
+ validator({"name": "hello"})
498
+
499
+ with pytest.raises(InvalidFormat):
500
+ validator({"id": 1, "name": 1})
501
+
502
+
503
+ def test_convert_to_voluptuous_nullable_field():
504
+ """Test that convert_to_voluptuous properly handles nullable fields."""
505
+ # Test OpenAPI 3.0 nullable syntax
506
+ openapi_schema = {
507
+ "type": "object",
508
+ "properties": {
509
+ "id": {"type": "integer"},
510
+ "name": {"type": "string", "nullable": True},
511
+ },
512
+ "required": ["id"],
513
+ }
514
+
515
+ validator = voluptuous_validator(convert_to_voluptuous(openapi_schema))
516
+
517
+ # Test valid cases
518
+ validator({"id": 1, "name": "hello"})
519
+ validator({"id": 1, "name": None}) # This should work with our fix
520
+ validator({"id": 1}) # Optional field can be omitted
521
+
522
+ # Test invalid cases
523
+ with pytest.raises(InvalidFormat):
524
+ validator({"name": "hello"}) # Missing required id
525
+
526
+ with pytest.raises(InvalidFormat):
527
+ validator({"id": 1, "name": 1}) # Wrong type for name
528
+
529
+
530
+ def test_convert_to_voluptuous_nullable_field_openapi_3_1():
531
+ """Test that convert_to_voluptuous properly handles OpenAPI 3.1 nullable syntax."""
532
+ # Test OpenAPI 3.1 type array syntax
533
+ openapi_schema = {
534
+ "type": "object",
535
+ "properties": {
536
+ "id": {"type": "integer"},
537
+ "name": {"type": ["string", "null"]},
538
+ },
539
+ "required": ["id"],
540
+ }
541
+
542
+ validator = voluptuous_validator(convert_to_voluptuous(openapi_schema))
543
+
544
+ # Test valid cases
545
+ validator({"id": 1, "name": "hello"})
546
+ validator({"id": 1, "name": None}) # This should work with our fix
547
+ validator({"id": 1}) # Optional field can be omitted
548
+
549
+ # Test invalid cases
550
+ with pytest.raises(InvalidFormat):
551
+ validator({"name": "hello"}) # Missing required id
552
+
553
+ with pytest.raises(InvalidFormat):
554
+ validator({"id": 1, "name": 1}) # Wrong type for name
555
+
556
+
557
+ @pytest.mark.parametrize(
558
+ "validator",
559
+ generate_validators(
560
+ {"anyOf": [{"type": "string"}, {"type": "null"}]},
561
+ vol.Maybe(str),
562
+ ),
563
+ ids=TEST_IDS,
564
+ )
565
+ def test_maybe(validator: Validator) -> None:
566
+ """Test voluptuous Maybe type that allows None."""
567
+
568
+ validator("hello")
569
+ validator(None)
570
+
571
+ with pytest.raises(InvalidFormat):
572
+ validator(1)
573
+
574
+ with pytest.raises(InvalidFormat):
575
+ validator(1.4)
576
+
577
+ with pytest.raises(InvalidFormat):
578
+ validator({"key": "value"})
@@ -2,7 +2,7 @@
2
2
 
3
3
  from collections.abc import Callable, Mapping, Sequence
4
4
  from inspect import signature
5
- from enum import Enum
5
+ from enum import Enum, StrEnum
6
6
  import re
7
7
  from typing import Any, TypeVar, Union, get_args, get_origin, get_type_hints
8
8
  from types import NoneType, UnionType
@@ -22,7 +22,6 @@ UNSUPPORTED = object()
22
22
 
23
23
  # These are not supported when converting from OpenAPI to voluptuous
24
24
  OPENAPI_UNSUPPORTED_KEYWORDS = {
25
- "anyOf",
26
25
  "allOf",
27
26
  "multipleOf",
28
27
  "minItems",
@@ -31,10 +30,31 @@ OPENAPI_UNSUPPORTED_KEYWORDS = {
31
30
  }
32
31
 
33
32
 
34
- def convert(schema: Any, *, custom_serializer: Callable | None = None) -> dict:
33
+ class OpenApiVersion(StrEnum):
34
+ """The OpenAPI version.
35
+
36
+ This is used to change the behavior when converting schemas to OpenAPI.
37
+ """
38
+
39
+ V3 = "3.0"
40
+ V3_1 = "3.1.0"
41
+
42
+
43
+ def convert(
44
+ schema: Any,
45
+ *,
46
+ custom_serializer: Callable | None = None,
47
+ openapi_version: OpenApiVersion = OpenApiVersion.V3,
48
+ ) -> dict:
35
49
  """Convert a voluptuous schema to a OpenAPI Schema object."""
36
50
  # pylint: disable=too-many-return-statements,too-many-branches
37
51
 
52
+ def convert_with_args(schema: Any) -> dict:
53
+ """Convert schema for recusing and propagating arguments."""
54
+ return convert(
55
+ schema, custom_serializer=custom_serializer, openapi_version=openapi_version
56
+ )
57
+
38
58
  def ensure_default(value: dict[str:Any]):
39
59
  """Make sure that type is set."""
40
60
  if all(x not in value for x in ("type", "anyOf", "oneOf", "allOf", "not")):
@@ -77,7 +97,7 @@ def convert(schema: Any, *, custom_serializer: Callable | None = None) -> dict:
77
97
  else:
78
98
  pkey = key
79
99
 
80
- pval = convert(value, custom_serializer=custom_serializer)
100
+ pval = convert_with_args(value)
81
101
  if description:
82
102
  pval["description"] = key.description
83
103
 
@@ -122,7 +142,7 @@ def convert(schema: Any, *, custom_serializer: Callable | None = None) -> dict:
122
142
  fallback = False
123
143
  allOf = []
124
144
  for validator in schema.validators:
125
- v = convert(validator, custom_serializer=custom_serializer)
145
+ v = convert_with_args(validator)
126
146
  if (
127
147
  not v
128
148
  or v in allOf
@@ -208,17 +228,19 @@ def convert(schema: Any, *, custom_serializer: Callable | None = None) -> dict:
208
228
 
209
229
  if isinstance(schema, vol.Any):
210
230
  schema = schema.validators
211
- if None in schema or NoneType in schema:
231
+ # Infer the enum type based on the type of the first value, but default
232
+ # to a string as a fallback.
233
+ if (
234
+ None in schema or NoneType in schema
235
+ ) and openapi_version == OpenApiVersion.V3:
212
236
  schema = [val for val in schema if val is not None and val is not NoneType]
213
237
  nullable = True
214
238
  else:
215
239
  nullable = False
216
240
  if len(schema) == 1:
217
- result = convert(schema[0], custom_serializer=custom_serializer)
241
+ result = convert_with_args(schema[0])
218
242
  else:
219
- anyOf = [
220
- convert(val, custom_serializer=custom_serializer) for val in schema
221
- ]
243
+ anyOf = [convert_with_args(val) for val in schema]
222
244
 
223
245
  # Merge nested anyOf
224
246
  tmpAnyOf = []
@@ -302,6 +324,8 @@ def convert(schema: Any, *, custom_serializer: Callable | None = None) -> dict:
302
324
  return {"type": TYPES_MAP[type(schema)], "enum": [schema]}
303
325
 
304
326
  if schema is None:
327
+ if openapi_version == OpenApiVersion.V3_1:
328
+ return {"type": "null"}
305
329
  return {"type": "object", "nullable": True, "description": "Must be null"}
306
330
 
307
331
  if (
@@ -315,16 +339,11 @@ def convert(schema: Any, *, custom_serializer: Callable | None = None) -> dict:
315
339
  if len(schema) == 1:
316
340
  return {
317
341
  "type": "array",
318
- "items": ensure_default(
319
- convert(schema[0], custom_serializer=custom_serializer)
320
- ),
342
+ "items": ensure_default(convert_with_args(schema[0])),
321
343
  }
322
344
  return {
323
345
  "type": "array",
324
- "items": [
325
- ensure_default(convert(s, custom_serializer=custom_serializer))
326
- for s in schema.items()
327
- ],
346
+ "items": [ensure_default(convert_with_args(s)) for s in schema.items()],
328
347
  }
329
348
 
330
349
  if schema in TYPES_MAP:
@@ -334,7 +353,7 @@ def convert(schema: Any, *, custom_serializer: Callable | None = None) -> dict:
334
353
  if get_args(schema)[1] is Any or isinstance(get_args(schema)[1], TypeVar):
335
354
  schema = dict
336
355
  else:
337
- return convert({get_args(schema)[0]: get_args(schema)[1]})
356
+ return convert_with_args({get_args(schema)[0]: get_args(schema)[1]})
338
357
 
339
358
  if isinstance(schema, type):
340
359
  if schema is dict:
@@ -360,6 +379,8 @@ def convert(schema: Any, *, custom_serializer: Callable | None = None) -> dict:
360
379
  return {"type": enum_type, "enum": enum_values, "nullable": True}
361
380
  return {"type": enum_type, "enum": enum_values}
362
381
  elif schema is NoneType:
382
+ if openapi_version == OpenApiVersion.V3_1:
383
+ return {"type": "null"}
363
384
  return {"type": "object", "nullable": True, "description": "Must be null"}
364
385
 
365
386
  if schema is object:
@@ -380,7 +401,7 @@ def convert(schema: Any, *, custom_serializer: Callable | None = None) -> dict:
380
401
  else:
381
402
  return {}
382
403
 
383
- return ensure_default(convert(schema, custom_serializer=custom_serializer))
404
+ return ensure_default(convert_with_args(schema))
384
405
 
385
406
  raise ValueError("Unable to convert schema: {}".format(schema))
386
407
 
@@ -398,33 +419,65 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
398
419
  if (one_of := schema.get("oneOf")) is not None:
399
420
  if not isinstance(one_of, list):
400
421
  raise ValueError("Invalid schema, oneOf should be a list")
422
+ # This implements anyOf semantics sice it matches any of the subschemas,
423
+ # not just one of them.
401
424
  return vol.Any(*[convert_to_voluptuous(sub_schema) for sub_schema in one_of])
402
425
 
426
+ if (any_of := schema.get("anyOf")) is not None:
427
+ if not isinstance(any_of, list):
428
+ raise ValueError("Invalid schema, anyOf should be a list")
429
+ return vol.Any(*[convert_to_voluptuous(sub_schema) for sub_schema in any_of])
430
+
403
431
  if (schema_type := schema.get("type")) is None:
404
432
  raise ValueError("Invalid schema, missing type")
405
433
 
406
- if (basic_type := TYPES_MAP_REV.get(schema_type)) is not None:
407
- if schema_type == "string":
408
- if (pattern := schema.get("pattern")) is not None:
409
- return vol.Match(re.compile(pattern))
434
+ if schema_type == "null":
435
+ return vol.Schema(None)
410
436
 
411
- format = schema.get("format")
412
- if format == "date-time":
413
- return vol.Datetime()
437
+ # Handle OpenAPI 3.1 style: type can be an array like ["string", "null"]
438
+ if isinstance(schema_type, list):
439
+ if len(schema_type) == 0:
440
+ raise ValueError("Invalid schema, type array cannot be empty")
414
441
 
415
- min = schema.get("minLength")
416
- max = schema.get("maxLength")
417
- if min is not None or max is not None:
418
- return vol.All(basic_type, vol.Length(min=min, max=max))
442
+ validators = []
443
+ for t in schema_type:
444
+ if t == "null":
445
+ validators.append(None)
446
+ else:
447
+ base_schema = schema.copy()
448
+ base_schema["type"] = t
449
+ validators.append(convert_to_voluptuous(base_schema))
450
+ return vol.Any(*validators)
419
451
 
420
- if schema_type == "integer" or schema_type == "number":
421
- min = schema.get("minimum")
422
- max = schema.get("maximum")
423
- if min is not None or max is not None:
424
- return vol.All(basic_type, vol.Range(min=min, max=max))
425
- return basic_type
452
+ if (basic_type := TYPES_MAP_REV.get(schema_type)) is not None:
453
+ validator = basic_type
426
454
 
427
- if schema["type"] == "object":
455
+ if schema_type == "string":
456
+ if (pattern := schema.get("pattern")) is not None:
457
+ validator = vol.Match(re.compile(pattern))
458
+ elif (format := schema.get("format")) is not None and format == "date-time":
459
+ validator = vol.Datetime()
460
+ else:
461
+ min_length = schema.get("minLength")
462
+ max_length = schema.get("maxLength")
463
+ if min_length is not None or max_length is not None:
464
+ validator = vol.All(
465
+ basic_type, vol.Length(min=min_length, max=max_length)
466
+ )
467
+
468
+ elif schema_type in ("integer", "number"):
469
+ min_val = schema.get("minimum")
470
+ max_val = schema.get("maximum")
471
+ if min_val is not None or max_val is not None:
472
+ validator = vol.All(basic_type, vol.Range(min=min_val, max=max_val))
473
+
474
+ # Handle OpenAPI 3.0 nullable property
475
+ if schema.get("nullable") is True:
476
+ return vol.Any(validator, None)
477
+
478
+ return validator
479
+
480
+ if schema_type == "object":
428
481
  properties = {}
429
482
  required_properties = set(schema.get("required", []))
430
483
  for key, value in schema.get("properties", {}).items():
@@ -437,11 +490,26 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
437
490
  else:
438
491
  key_type = vol.Optional
439
492
  properties[key_type(key, description=description)] = value_type
493
+
494
+ validator = None
440
495
  if schema.get("additionalProperties") is True:
441
- return vol.Schema(properties, extra=vol.ALLOW_EXTRA)
442
- return vol.Schema(properties)
496
+ validator = vol.Schema(properties, extra=vol.ALLOW_EXTRA)
497
+ else:
498
+ validator = vol.Schema(properties)
499
+
500
+ # Handle OpenAPI 3.0 nullable property
501
+ if schema.get("nullable") is True:
502
+ return vol.Any(validator, None)
503
+
504
+ return validator
505
+
506
+ if schema_type == "array":
507
+ validator = vol.Schema([convert_to_voluptuous(schema["items"])])
508
+
509
+ # Handle OpenAPI 3.0 nullable property
510
+ if schema.get("nullable") is True:
511
+ return vol.Any(validator, None)
443
512
 
444
- if schema["type"] == "array":
445
- return vol.Schema([convert_to_voluptuous(schema["items"])])
513
+ return validator
446
514
 
447
515
  raise ValueError("Unable to convert schema: {}".format(schema))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voluptuous-openapi
3
- Version: 0.0.7
3
+ Version: 0.2.0
4
4
  Summary: Convert voluptuous schemas to OpenAPI Schema object
5
5
  Author-email: Denis Shulyaka <Shulyaka@gmail.com>
6
6
  License-Expression: Apache-2.0