voluptuous-openapi 0.1.0__tar.gz → 0.3.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.
- {voluptuous_openapi-0.1.0/voluptuous_openapi.egg-info → voluptuous_openapi-0.3.0}/PKG-INFO +1 -1
- {voluptuous_openapi-0.1.0 → voluptuous_openapi-0.3.0}/pyproject.toml +1 -1
- {voluptuous_openapi-0.1.0 → voluptuous_openapi-0.3.0}/tests/test_lib.py +382 -0
- {voluptuous_openapi-0.1.0 → voluptuous_openapi-0.3.0}/tests/test_validation.py +129 -3
- {voluptuous_openapi-0.1.0 → voluptuous_openapi-0.3.0}/voluptuous_openapi/__init__.py +144 -39
- {voluptuous_openapi-0.1.0 → voluptuous_openapi-0.3.0/voluptuous_openapi.egg-info}/PKG-INFO +1 -1
- {voluptuous_openapi-0.1.0 → voluptuous_openapi-0.3.0}/LICENSE +0 -0
- {voluptuous_openapi-0.1.0 → voluptuous_openapi-0.3.0}/README.md +0 -0
- {voluptuous_openapi-0.1.0 → voluptuous_openapi-0.3.0}/setup.cfg +0 -0
- {voluptuous_openapi-0.1.0 → voluptuous_openapi-0.3.0}/voluptuous_openapi.egg-info/SOURCES.txt +0 -0
- {voluptuous_openapi-0.1.0 → voluptuous_openapi-0.3.0}/voluptuous_openapi.egg-info/dependency_links.txt +0 -0
- {voluptuous_openapi-0.1.0 → voluptuous_openapi-0.3.0}/voluptuous_openapi.egg-info/requires.txt +0 -0
- {voluptuous_openapi-0.1.0 → voluptuous_openapi-0.3.0}/voluptuous_openapi.egg-info/top_level.txt +0 -0
|
@@ -410,6 +410,11 @@ def test_key_any():
|
|
|
410
410
|
},
|
|
411
411
|
"required": [],
|
|
412
412
|
"type": "object",
|
|
413
|
+
"anyOf": [
|
|
414
|
+
{"required": ["hours"]},
|
|
415
|
+
{"required": ["minutes"]},
|
|
416
|
+
{"required": ["seconds"]},
|
|
417
|
+
],
|
|
413
418
|
} == convert(
|
|
414
419
|
{
|
|
415
420
|
vol.Required(vol.Any("hours", "minutes", "seconds")): int,
|
|
@@ -634,3 +639,380 @@ def test_convert_to_voluptuous_marker_description(required: list[str]):
|
|
|
634
639
|
("query", "The query to search for"),
|
|
635
640
|
("max_results", "The maximum number of results to return"),
|
|
636
641
|
]
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def test_convert_to_voluptuous_nullable_string_openapi_3_0():
|
|
645
|
+
"""Test OpenAPI 3.0 nullable string."""
|
|
646
|
+
validator = convert_to_voluptuous({"type": "string", "nullable": True})
|
|
647
|
+
validator("test")
|
|
648
|
+
validator(None)
|
|
649
|
+
|
|
650
|
+
with pytest.raises(vol.Invalid):
|
|
651
|
+
validator(123)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def test_convert_to_voluptuous_non_nullable_string_openapi_3_0():
|
|
655
|
+
"""Test OpenAPI 3.0 non-nullable string."""
|
|
656
|
+
validator_type = convert_to_voluptuous({"type": "string", "nullable": False})
|
|
657
|
+
# Wrap in a Schema for proper validation
|
|
658
|
+
validator = vol.Schema(validator_type)
|
|
659
|
+
validator("test")
|
|
660
|
+
validator("") # empty string should be valid
|
|
661
|
+
|
|
662
|
+
with pytest.raises(vol.Invalid):
|
|
663
|
+
validator(None)
|
|
664
|
+
|
|
665
|
+
with pytest.raises(vol.Invalid):
|
|
666
|
+
validator(123)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def test_convert_to_voluptuous_nullable_integer_openapi_3_0():
|
|
670
|
+
"""Test OpenAPI 3.0 nullable integer."""
|
|
671
|
+
validator = convert_to_voluptuous({"type": "integer", "nullable": True})
|
|
672
|
+
validator(42)
|
|
673
|
+
validator(None)
|
|
674
|
+
|
|
675
|
+
with pytest.raises(vol.Invalid):
|
|
676
|
+
validator("string")
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def test_convert_to_voluptuous_nullable_number_openapi_3_0():
|
|
680
|
+
"""Test OpenAPI 3.0 nullable number."""
|
|
681
|
+
validator = convert_to_voluptuous({"type": "number", "nullable": True})
|
|
682
|
+
validator(3.14)
|
|
683
|
+
validator(None)
|
|
684
|
+
|
|
685
|
+
with pytest.raises(vol.Invalid):
|
|
686
|
+
validator("string")
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def test_convert_to_voluptuous_nullable_boolean_openapi_3_0():
|
|
690
|
+
"""Test OpenAPI 3.0 nullable boolean."""
|
|
691
|
+
validator = convert_to_voluptuous({"type": "boolean", "nullable": True})
|
|
692
|
+
validator(True)
|
|
693
|
+
validator(False)
|
|
694
|
+
validator(None)
|
|
695
|
+
|
|
696
|
+
with pytest.raises(vol.Invalid):
|
|
697
|
+
validator("string")
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def test_convert_to_voluptuous_nullable_object_openapi_3_0():
|
|
701
|
+
"""Test OpenAPI 3.0 nullable object."""
|
|
702
|
+
validator = convert_to_voluptuous(
|
|
703
|
+
{"type": "object", "properties": {"name": {"type": "string"}}, "nullable": True}
|
|
704
|
+
)
|
|
705
|
+
validator({"name": "test"})
|
|
706
|
+
validator(None)
|
|
707
|
+
|
|
708
|
+
with pytest.raises(vol.Invalid):
|
|
709
|
+
validator("string")
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def test_convert_to_voluptuous_nullable_array_openapi_3_0():
|
|
713
|
+
"""Test OpenAPI 3.0 nullable array."""
|
|
714
|
+
validator = convert_to_voluptuous(
|
|
715
|
+
{"type": "array", "items": {"type": "string"}, "nullable": True}
|
|
716
|
+
)
|
|
717
|
+
validator(["a", "b"])
|
|
718
|
+
validator(None)
|
|
719
|
+
|
|
720
|
+
with pytest.raises(vol.Invalid):
|
|
721
|
+
validator("string")
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def test_convert_to_voluptuous_nullable_string_openapi_3_1():
|
|
725
|
+
"""Test OpenAPI 3.1 nullable string using type array."""
|
|
726
|
+
validator = convert_to_voluptuous({"type": ["string", "null"]})
|
|
727
|
+
validator("test")
|
|
728
|
+
validator(None)
|
|
729
|
+
|
|
730
|
+
with pytest.raises(vol.Invalid):
|
|
731
|
+
validator(123)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def test_convert_to_voluptuous_nullable_integer_openapi_3_1():
|
|
735
|
+
"""Test OpenAPI 3.1 nullable integer using type array."""
|
|
736
|
+
validator = convert_to_voluptuous({"type": ["integer", "null"]})
|
|
737
|
+
validator(42)
|
|
738
|
+
validator(None)
|
|
739
|
+
|
|
740
|
+
with pytest.raises(vol.Invalid):
|
|
741
|
+
validator("string")
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def test_convert_to_voluptuous_multiple_types_with_null_openapi_3_1():
|
|
745
|
+
"""Test OpenAPI 3.1 multiple types with null."""
|
|
746
|
+
validator = convert_to_voluptuous({"type": ["string", "integer", "null"]})
|
|
747
|
+
validator("test")
|
|
748
|
+
validator(42)
|
|
749
|
+
validator(None)
|
|
750
|
+
|
|
751
|
+
with pytest.raises(vol.Invalid):
|
|
752
|
+
validator(3.14)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def test_convert_to_voluptuous_multiple_types_without_null_openapi_3_1():
|
|
756
|
+
"""Test OpenAPI 3.1 multiple types without null."""
|
|
757
|
+
validator = convert_to_voluptuous({"type": ["string", "integer"]})
|
|
758
|
+
validator("test")
|
|
759
|
+
validator(42)
|
|
760
|
+
|
|
761
|
+
with pytest.raises(vol.Invalid):
|
|
762
|
+
validator(None)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def test_convert_to_voluptuous_only_null_openapi_3_1():
|
|
766
|
+
"""Test OpenAPI 3.1 type array with only null."""
|
|
767
|
+
validator = convert_to_voluptuous({"type": ["null"]})
|
|
768
|
+
validator(None)
|
|
769
|
+
|
|
770
|
+
with pytest.raises(vol.Invalid):
|
|
771
|
+
validator("test")
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def test_convert_to_voluptuous_nullable_string_with_pattern():
|
|
775
|
+
"""Test nullable string with pattern constraint."""
|
|
776
|
+
validator = convert_to_voluptuous(
|
|
777
|
+
{"type": "string", "pattern": r"^test", "nullable": True}
|
|
778
|
+
)
|
|
779
|
+
validator("testing")
|
|
780
|
+
validator(None)
|
|
781
|
+
|
|
782
|
+
with pytest.raises(vol.Invalid):
|
|
783
|
+
validator("nottest")
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def test_convert_to_voluptuous_nullable_integer_with_range_openapi_3_0():
|
|
787
|
+
"""Test nullable integer with range constraint (OpenAPI 3.0 style)."""
|
|
788
|
+
validator = convert_to_voluptuous(
|
|
789
|
+
{"type": "integer", "minimum": 1, "maximum": 10, "nullable": True}
|
|
790
|
+
)
|
|
791
|
+
validator(5)
|
|
792
|
+
validator(None)
|
|
793
|
+
|
|
794
|
+
with pytest.raises(vol.Invalid):
|
|
795
|
+
validator(15)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def test_convert_to_voluptuous_nullable_integer_with_range_openapi_3_1():
|
|
799
|
+
"""Test nullable integer with range constraint (OpenAPI 3.1 style)."""
|
|
800
|
+
validator = convert_to_voluptuous(
|
|
801
|
+
{"type": ["integer", "null"], "minimum": 1, "maximum": 10}
|
|
802
|
+
)
|
|
803
|
+
validator(5)
|
|
804
|
+
validator(None)
|
|
805
|
+
|
|
806
|
+
with pytest.raises(vol.Invalid):
|
|
807
|
+
validator(15)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def test_convert_to_voluptuous_nullable_string_with_length_constraints():
|
|
811
|
+
"""Test nullable string with length constraints."""
|
|
812
|
+
validator = convert_to_voluptuous(
|
|
813
|
+
{"type": "string", "minLength": 3, "maxLength": 10, "nullable": True}
|
|
814
|
+
)
|
|
815
|
+
validator("hello")
|
|
816
|
+
validator(None)
|
|
817
|
+
|
|
818
|
+
with pytest.raises(vol.Invalid):
|
|
819
|
+
validator("hi") # too short
|
|
820
|
+
|
|
821
|
+
with pytest.raises(vol.Invalid):
|
|
822
|
+
validator("verylongstring") # too long
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def test_convert_to_voluptuous_nullable_number_with_range():
|
|
826
|
+
"""Test nullable number with range constraints."""
|
|
827
|
+
validator = convert_to_voluptuous(
|
|
828
|
+
{"type": "number", "minimum": 0.0, "maximum": 100.0, "nullable": True}
|
|
829
|
+
)
|
|
830
|
+
validator(50.5)
|
|
831
|
+
validator(None)
|
|
832
|
+
|
|
833
|
+
with pytest.raises(vol.Invalid):
|
|
834
|
+
validator(-1.0) # too low
|
|
835
|
+
|
|
836
|
+
with pytest.raises(vol.Invalid):
|
|
837
|
+
validator(101.0) # too high
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
TEST_TASK_ITEM = {"content": "a task ", "description": "a description"}
|
|
841
|
+
TASK_SCHEMA = {
|
|
842
|
+
"type": "object",
|
|
843
|
+
"properties": {
|
|
844
|
+
"tasks": {
|
|
845
|
+
"type": "array",
|
|
846
|
+
"items": {
|
|
847
|
+
"type": "object",
|
|
848
|
+
"properties": {
|
|
849
|
+
"content": {
|
|
850
|
+
"type": "string",
|
|
851
|
+
"description": "The content of the task to create.",
|
|
852
|
+
},
|
|
853
|
+
"description": {
|
|
854
|
+
"type": "string",
|
|
855
|
+
"description": "The description of the task.",
|
|
856
|
+
},
|
|
857
|
+
},
|
|
858
|
+
"required": ["content"],
|
|
859
|
+
"additionalProperties": False,
|
|
860
|
+
},
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
@pytest.mark.parametrize(
|
|
867
|
+
"extra_tasks_data, input_data",
|
|
868
|
+
[
|
|
869
|
+
({"minItems": 1, "maxItems": 2}, {"tasks": [TEST_TASK_ITEM]}),
|
|
870
|
+
({"minItems": 1, "maxItems": 2}, {"tasks": [TEST_TASK_ITEM, TEST_TASK_ITEM]}),
|
|
871
|
+
({"minItems": 1}, {"tasks": [TEST_TASK_ITEM]}),
|
|
872
|
+
({"maxItems": 2}, {"tasks": [TEST_TASK_ITEM, TEST_TASK_ITEM]}),
|
|
873
|
+
],
|
|
874
|
+
)
|
|
875
|
+
def test_with_min_max_items_success(extra_tasks_data, input_data):
|
|
876
|
+
task_schema = TASK_SCHEMA.copy()
|
|
877
|
+
task_schema["properties"]["tasks"].update(extra_tasks_data)
|
|
878
|
+
|
|
879
|
+
validator = convert_to_voluptuous(task_schema)
|
|
880
|
+
|
|
881
|
+
validator(input_data)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
@pytest.mark.parametrize(
|
|
885
|
+
"extra_tasks_data, input_data",
|
|
886
|
+
[
|
|
887
|
+
({"minItems": 1, "maxItems": 2}, {"tasks": []}),
|
|
888
|
+
(
|
|
889
|
+
{"minItems": 1, "maxItems": 2},
|
|
890
|
+
{"tasks": [TEST_TASK_ITEM, TEST_TASK_ITEM, TEST_TASK_ITEM]},
|
|
891
|
+
),
|
|
892
|
+
({"minItems": 1}, {"tasks": []}),
|
|
893
|
+
({"maxItems": 2}, {"tasks": [TEST_TASK_ITEM, TEST_TASK_ITEM, TEST_TASK_ITEM]}),
|
|
894
|
+
],
|
|
895
|
+
)
|
|
896
|
+
def test_with_min_max_items_fails_validation(extra_tasks_data, input_data):
|
|
897
|
+
task_schema = TASK_SCHEMA.copy()
|
|
898
|
+
task_schema["properties"]["tasks"].update(extra_tasks_data)
|
|
899
|
+
|
|
900
|
+
validator = convert_to_voluptuous(task_schema)
|
|
901
|
+
|
|
902
|
+
with pytest.raises(vol.Invalid):
|
|
903
|
+
validator(input_data)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def test_required_any_of():
|
|
907
|
+
"""Test schemas with Required(Any(...)) constraints."""
|
|
908
|
+
assert {
|
|
909
|
+
"properties": {
|
|
910
|
+
"color": {"type": "string"},
|
|
911
|
+
"temperature": {"type": "integer"},
|
|
912
|
+
"brightness": {"type": "integer"},
|
|
913
|
+
},
|
|
914
|
+
"required": [],
|
|
915
|
+
"type": "object",
|
|
916
|
+
"anyOf": [
|
|
917
|
+
{"required": ["color"]},
|
|
918
|
+
{"required": ["temperature"]},
|
|
919
|
+
{"required": ["brightness"]},
|
|
920
|
+
],
|
|
921
|
+
} == convert(
|
|
922
|
+
{
|
|
923
|
+
vol.Required(vol.Any("color", "temperature", "brightness")): object,
|
|
924
|
+
vol.Optional("color"): str,
|
|
925
|
+
vol.Optional("temperature"): int,
|
|
926
|
+
vol.Optional("brightness"): int,
|
|
927
|
+
}
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
assert {
|
|
931
|
+
"properties": {
|
|
932
|
+
"color": {"type": "string"},
|
|
933
|
+
"temperature": {"type": "integer"},
|
|
934
|
+
"brightness": {"type": "integer"},
|
|
935
|
+
"mode": {"type": "string"},
|
|
936
|
+
"preset": {"type": "string"},
|
|
937
|
+
},
|
|
938
|
+
"required": [],
|
|
939
|
+
"type": "object",
|
|
940
|
+
"anyOf": [
|
|
941
|
+
{"required": ["color", "mode"]},
|
|
942
|
+
{"required": ["color", "preset"]},
|
|
943
|
+
{"required": ["temperature", "mode"]},
|
|
944
|
+
{"required": ["temperature", "preset"]},
|
|
945
|
+
{"required": ["brightness", "mode"]},
|
|
946
|
+
{"required": ["brightness", "preset"]},
|
|
947
|
+
],
|
|
948
|
+
} == convert(
|
|
949
|
+
{
|
|
950
|
+
vol.Required(vol.Any("color", "temperature", "brightness")): object,
|
|
951
|
+
vol.Required(vol.Any("mode", "preset")): str,
|
|
952
|
+
vol.Optional("color"): str,
|
|
953
|
+
vol.Optional("temperature"): int,
|
|
954
|
+
vol.Optional("brightness"): int,
|
|
955
|
+
vol.Optional("mode"): str,
|
|
956
|
+
vol.Optional("preset"): str,
|
|
957
|
+
}
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
assert {
|
|
961
|
+
"properties": {
|
|
962
|
+
"entity_id": {"type": "string"},
|
|
963
|
+
"color": {"type": "string"},
|
|
964
|
+
"temperature": {"type": "integer"},
|
|
965
|
+
},
|
|
966
|
+
"required": ["entity_id"],
|
|
967
|
+
"type": "object",
|
|
968
|
+
"anyOf": [{"required": ["color"]}, {"required": ["temperature"]}],
|
|
969
|
+
} == convert(
|
|
970
|
+
{
|
|
971
|
+
vol.Required("entity_id"): str,
|
|
972
|
+
vol.Required(vol.Any("color", "temperature")): str,
|
|
973
|
+
vol.Optional("color"): str,
|
|
974
|
+
vol.Optional("temperature"): int,
|
|
975
|
+
}
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def test_required_any_of_description():
|
|
980
|
+
"""Test that the description is preserved in a Required(Any(...)) constraint."""
|
|
981
|
+
assert {
|
|
982
|
+
"properties": {
|
|
983
|
+
"color": {"type": "string", "description": "Light appearance"},
|
|
984
|
+
"temperature": {"type": "string", "description": "Light appearance"},
|
|
985
|
+
},
|
|
986
|
+
"required": [],
|
|
987
|
+
"type": "object",
|
|
988
|
+
"anyOf": [{"required": ["color"]}, {"required": ["temperature"]}],
|
|
989
|
+
} == convert(
|
|
990
|
+
{
|
|
991
|
+
vol.Required(
|
|
992
|
+
vol.Any("color", "temperature"), description="Light appearance"
|
|
993
|
+
): str,
|
|
994
|
+
}
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def test_required_any_of_inner_description():
|
|
999
|
+
"""Test that inner descriptions are preferred in a Required(Any(...)) constraint."""
|
|
1000
|
+
assert {
|
|
1001
|
+
"properties": {
|
|
1002
|
+
"color": {"type": "string", "description": "Inner color description"},
|
|
1003
|
+
"temperature": {"type": "string", "description": "Outer description"},
|
|
1004
|
+
},
|
|
1005
|
+
"required": [],
|
|
1006
|
+
"type": "object",
|
|
1007
|
+
"anyOf": [{"required": ["color"]}, {"required": ["temperature"]}],
|
|
1008
|
+
} == convert(
|
|
1009
|
+
{
|
|
1010
|
+
vol.Required(
|
|
1011
|
+
vol.Any(
|
|
1012
|
+
vol.Optional("color", description="Inner color description"),
|
|
1013
|
+
"temperature",
|
|
1014
|
+
),
|
|
1015
|
+
description="Outer description",
|
|
1016
|
+
): str,
|
|
1017
|
+
}
|
|
1018
|
+
)
|
|
@@ -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
|
|
@@ -481,9 +480,15 @@ def test_object_with_nullable(validator: Validator) -> None:
|
|
|
481
480
|
|
|
482
481
|
validator({"id": 1, "name": "hello"})
|
|
483
482
|
|
|
484
|
-
#
|
|
485
|
-
|
|
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:
|
|
486
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
|
|
487
492
|
|
|
488
493
|
with pytest.raises(InvalidFormat):
|
|
489
494
|
validator(1)
|
|
@@ -495,6 +500,60 @@ def test_object_with_nullable(validator: Validator) -> None:
|
|
|
495
500
|
validator({"id": 1, "name": 1})
|
|
496
501
|
|
|
497
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
|
+
|
|
498
557
|
@pytest.mark.parametrize(
|
|
499
558
|
"validator",
|
|
500
559
|
generate_validators(
|
|
@@ -517,3 +576,70 @@ def test_maybe(validator: Validator) -> None:
|
|
|
517
576
|
|
|
518
577
|
with pytest.raises(InvalidFormat):
|
|
519
578
|
validator({"key": "value"})
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
@pytest.mark.parametrize(
|
|
582
|
+
"validator",
|
|
583
|
+
generate_validators(
|
|
584
|
+
{
|
|
585
|
+
"type": "object",
|
|
586
|
+
"properties": {
|
|
587
|
+
"color": {"type": "string"},
|
|
588
|
+
"temperature": {"type": "integer"},
|
|
589
|
+
"brightness": {
|
|
590
|
+
"type": "integer",
|
|
591
|
+
"minimum": 0,
|
|
592
|
+
"maximum": 100,
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
"anyOf": [
|
|
596
|
+
{"required": ["color"]},
|
|
597
|
+
{"required": ["temperature"]},
|
|
598
|
+
{"required": ["brightness"]},
|
|
599
|
+
],
|
|
600
|
+
},
|
|
601
|
+
vol.Schema(
|
|
602
|
+
{
|
|
603
|
+
vol.Required(vol.Any("color", "temperature", "brightness")): object,
|
|
604
|
+
vol.Optional("color"): str,
|
|
605
|
+
vol.Optional("temperature"): int,
|
|
606
|
+
vol.Optional("brightness"): vol.All(int, vol.Range(min=0, max=100)),
|
|
607
|
+
}
|
|
608
|
+
),
|
|
609
|
+
),
|
|
610
|
+
ids=TEST_IDS,
|
|
611
|
+
)
|
|
612
|
+
def test_any_of_constraint(validator: Validator) -> None:
|
|
613
|
+
"""Test anyOf constraint for requiring at least one of multiple properties."""
|
|
614
|
+
# Test valid cases
|
|
615
|
+
validator({"color": "red"})
|
|
616
|
+
validator({"temperature": 20})
|
|
617
|
+
validator({"brightness": 80})
|
|
618
|
+
validator({"brightness": 0})
|
|
619
|
+
validator({"brightness": 100})
|
|
620
|
+
validator({"color": "blue", "temperature": 25})
|
|
621
|
+
validator({"color": "green", "brightness": 100})
|
|
622
|
+
validator({"temperature": 22, "brightness": 90})
|
|
623
|
+
validator({"color": "purple", "temperature": 21, "brightness": 70})
|
|
624
|
+
|
|
625
|
+
# Test invalid cases
|
|
626
|
+
with pytest.raises(InvalidFormat):
|
|
627
|
+
validator({}) # Missing all required properties
|
|
628
|
+
|
|
629
|
+
with pytest.raises(InvalidFormat):
|
|
630
|
+
validator({"other_field": "value"}) # Missing all required properties
|
|
631
|
+
|
|
632
|
+
with pytest.raises(InvalidFormat):
|
|
633
|
+
validator({"brightness": -1}) # Out of range
|
|
634
|
+
|
|
635
|
+
with pytest.raises(InvalidFormat):
|
|
636
|
+
validator({"brightness": 101}) # Out of range
|
|
637
|
+
|
|
638
|
+
with pytest.raises(InvalidFormat):
|
|
639
|
+
validator({"brightness": "abc"}) # Wrong type
|
|
640
|
+
|
|
641
|
+
with pytest.raises(InvalidFormat):
|
|
642
|
+
validator({"color": 123}) # Wrong type
|
|
643
|
+
|
|
644
|
+
with pytest.raises(InvalidFormat):
|
|
645
|
+
validator({"temperature": "abc"}) # Wrong type
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from collections.abc import Callable, Mapping, Sequence
|
|
4
4
|
from inspect import signature
|
|
5
5
|
from enum import Enum, StrEnum
|
|
6
|
+
import itertools
|
|
6
7
|
import re
|
|
7
8
|
from typing import Any, TypeVar, Union, get_args, get_origin, get_type_hints
|
|
8
9
|
from types import NoneType, UnionType
|
|
@@ -24,8 +25,6 @@ UNSUPPORTED = object()
|
|
|
24
25
|
OPENAPI_UNSUPPORTED_KEYWORDS = {
|
|
25
26
|
"allOf",
|
|
26
27
|
"multipleOf",
|
|
27
|
-
"minItems",
|
|
28
|
-
"maxItems",
|
|
29
28
|
"uniqueItems",
|
|
30
29
|
}
|
|
31
30
|
|
|
@@ -88,6 +87,9 @@ def convert(
|
|
|
88
87
|
if isinstance(schema, Mapping):
|
|
89
88
|
properties = {}
|
|
90
89
|
required = []
|
|
90
|
+
any_of_constraint_groups: list[list[str]] = (
|
|
91
|
+
[]
|
|
92
|
+
) # List of lists, each containing candidate keys for a Required(Any(...))
|
|
91
93
|
|
|
92
94
|
for key, value in schema.items():
|
|
93
95
|
description = None
|
|
@@ -101,22 +103,57 @@ def convert(
|
|
|
101
103
|
if description:
|
|
102
104
|
pval["description"] = key.description
|
|
103
105
|
|
|
104
|
-
if isinstance(key,
|
|
106
|
+
if isinstance(key, vol.Required):
|
|
107
|
+
if not isinstance(key.schema, vol.Any):
|
|
108
|
+
required.append(str(key.schema))
|
|
109
|
+
if key.default is not vol.UNDEFINED:
|
|
110
|
+
pval["default"] = key.default()
|
|
111
|
+
elif isinstance(key, vol.Optional):
|
|
105
112
|
if key.default is not vol.UNDEFINED:
|
|
106
113
|
pval["default"] = key.default()
|
|
107
114
|
|
|
108
115
|
pval = ensure_default(pval)
|
|
109
116
|
|
|
110
117
|
if isinstance(pkey, vol.Any):
|
|
111
|
-
for
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
118
|
+
# Handle Required(Any(...)) pattern for anyOf constraints
|
|
119
|
+
if isinstance(key, vol.Required):
|
|
120
|
+
# Extract candidate keys and their descriptions from Any validator
|
|
121
|
+
candidate_items = []
|
|
122
|
+
for val_item in pkey.validators:
|
|
123
|
+
item_key = ""
|
|
124
|
+
item_desc = None
|
|
125
|
+
if isinstance(val_item, vol.Marker):
|
|
126
|
+
item_key = str(val_item.schema)
|
|
127
|
+
item_desc = val_item.description
|
|
128
|
+
else:
|
|
129
|
+
item_key = str(val_item)
|
|
130
|
+
candidate_items.append({"key": item_key, "desc": item_desc})
|
|
131
|
+
|
|
132
|
+
candidate_keys = [item["key"] for item in candidate_items]
|
|
133
|
+
any_of_constraint_groups.append(candidate_keys)
|
|
134
|
+
|
|
135
|
+
# If the value is not a wildcard, create properties for each
|
|
136
|
+
# candidate key, preserving any descriptions.
|
|
137
|
+
if value is not object:
|
|
138
|
+
for item in candidate_items:
|
|
139
|
+
prop_schema = pval.copy()
|
|
140
|
+
final_description = item["desc"] or description
|
|
141
|
+
if final_description:
|
|
142
|
+
prop_schema["description"] = final_description
|
|
143
|
+
properties[item["key"]] = prop_schema
|
|
144
|
+
else:
|
|
145
|
+
# Handle Optional(Any(...)) - expand to individual properties
|
|
146
|
+
for val_item in pkey.validators:
|
|
147
|
+
if isinstance(val_item, vol.Marker):
|
|
148
|
+
if val_item.description:
|
|
149
|
+
properties[str(val_item.schema)] = pval.copy()
|
|
150
|
+
properties[str(val_item.schema)][
|
|
151
|
+
"description"
|
|
152
|
+
] = val_item.description
|
|
153
|
+
else:
|
|
154
|
+
properties[str(val_item)] = pval
|
|
116
155
|
else:
|
|
117
|
-
properties[str(
|
|
118
|
-
else:
|
|
119
|
-
properties[str(val)] = pval
|
|
156
|
+
properties[str(val_item)] = pval
|
|
120
157
|
elif isinstance(pkey, str):
|
|
121
158
|
properties[pkey] = pval
|
|
122
159
|
else:
|
|
@@ -126,15 +163,21 @@ def convert(
|
|
|
126
163
|
if additional_properties is None:
|
|
127
164
|
additional_properties = pval
|
|
128
165
|
|
|
129
|
-
if isinstance(key, vol.Required) and not isinstance(pkey, vol.Any):
|
|
130
|
-
required.append(str(pkey))
|
|
131
|
-
|
|
132
166
|
val = {"type": "object"}
|
|
133
167
|
if properties or not additional_properties:
|
|
134
168
|
val["properties"] = properties
|
|
135
169
|
val["required"] = required
|
|
136
170
|
if additional_properties:
|
|
137
171
|
val["additionalProperties"] = additional_properties
|
|
172
|
+
|
|
173
|
+
# Generate anyOf constraints from the Cartesian product of constraint groups
|
|
174
|
+
if any_of_constraint_groups:
|
|
175
|
+
# Generate all combinations (Cartesian product) of the constraint groups
|
|
176
|
+
val["anyOf"] = [
|
|
177
|
+
{"required": list(combination)}
|
|
178
|
+
for combination in itertools.product(*any_of_constraint_groups)
|
|
179
|
+
]
|
|
180
|
+
|
|
138
181
|
return val
|
|
139
182
|
|
|
140
183
|
if isinstance(schema, vol.All):
|
|
@@ -426,36 +469,67 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
|
|
|
426
469
|
if (any_of := schema.get("anyOf")) is not None:
|
|
427
470
|
if not isinstance(any_of, list):
|
|
428
471
|
raise ValueError("Invalid schema, anyOf should be a list")
|
|
429
|
-
|
|
472
|
+
# If the anyOf is a list of required properties, it's a constraint on an
|
|
473
|
+
# object and should be handled by the object validator.
|
|
474
|
+
is_required_constraint = all(
|
|
475
|
+
list(s.keys()) == ["required"] and isinstance(s["required"], list)
|
|
476
|
+
for s in any_of
|
|
477
|
+
)
|
|
478
|
+
if not (is_required_constraint and schema.get("type") == "object"):
|
|
479
|
+
return vol.Any(
|
|
480
|
+
*[convert_to_voluptuous(sub_schema) for sub_schema in any_of]
|
|
481
|
+
)
|
|
430
482
|
|
|
431
483
|
if (schema_type := schema.get("type")) is None:
|
|
432
484
|
raise ValueError("Invalid schema, missing type")
|
|
433
485
|
|
|
434
|
-
if
|
|
486
|
+
if schema_type == "null":
|
|
435
487
|
return vol.Schema(None)
|
|
436
488
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
format = schema.get("format")
|
|
443
|
-
if format == "date-time":
|
|
444
|
-
return vol.Datetime()
|
|
489
|
+
# Handle OpenAPI 3.1 style: type can be an array like ["string", "null"]
|
|
490
|
+
if isinstance(schema_type, list):
|
|
491
|
+
if len(schema_type) == 0:
|
|
492
|
+
raise ValueError("Invalid schema, type array cannot be empty")
|
|
445
493
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
if
|
|
449
|
-
|
|
494
|
+
validators = []
|
|
495
|
+
for t in schema_type:
|
|
496
|
+
if t == "null":
|
|
497
|
+
validators.append(None)
|
|
498
|
+
else:
|
|
499
|
+
base_schema = schema.copy()
|
|
500
|
+
base_schema["type"] = t
|
|
501
|
+
validators.append(convert_to_voluptuous(base_schema))
|
|
502
|
+
return vol.Any(*validators)
|
|
450
503
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
max = schema.get("maximum")
|
|
454
|
-
if min is not None or max is not None:
|
|
455
|
-
return vol.All(basic_type, vol.Range(min=min, max=max))
|
|
456
|
-
return basic_type
|
|
504
|
+
if (basic_type := TYPES_MAP_REV.get(schema_type)) is not None:
|
|
505
|
+
validator = basic_type
|
|
457
506
|
|
|
458
|
-
|
|
507
|
+
if schema_type == "string":
|
|
508
|
+
if (pattern := schema.get("pattern")) is not None:
|
|
509
|
+
validator = vol.Match(re.compile(pattern))
|
|
510
|
+
elif (format := schema.get("format")) is not None and format == "date-time":
|
|
511
|
+
validator = vol.Datetime()
|
|
512
|
+
else:
|
|
513
|
+
min_length = schema.get("minLength")
|
|
514
|
+
max_length = schema.get("maxLength")
|
|
515
|
+
if min_length is not None or max_length is not None:
|
|
516
|
+
validator = vol.All(
|
|
517
|
+
basic_type, vol.Length(min=min_length, max=max_length)
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
elif schema_type in ("integer", "number"):
|
|
521
|
+
min_val = schema.get("minimum")
|
|
522
|
+
max_val = schema.get("maximum")
|
|
523
|
+
if min_val is not None or max_val is not None:
|
|
524
|
+
validator = vol.All(basic_type, vol.Range(min=min_val, max=max_val))
|
|
525
|
+
|
|
526
|
+
# Handle OpenAPI 3.0 nullable property
|
|
527
|
+
if schema.get("nullable") is True:
|
|
528
|
+
return vol.Any(validator, None)
|
|
529
|
+
|
|
530
|
+
return validator
|
|
531
|
+
|
|
532
|
+
if schema_type == "object":
|
|
459
533
|
properties = {}
|
|
460
534
|
required_properties = set(schema.get("required", []))
|
|
461
535
|
for key, value in schema.get("properties", {}).items():
|
|
@@ -468,11 +542,42 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
|
|
|
468
542
|
else:
|
|
469
543
|
key_type = vol.Optional
|
|
470
544
|
properties[key_type(key, description=description)] = value_type
|
|
545
|
+
|
|
546
|
+
if (any_of := schema.get("anyOf")) is not None:
|
|
547
|
+
any_of_keys = [
|
|
548
|
+
key for sub_schema in any_of for key in sub_schema.get("required", [])
|
|
549
|
+
]
|
|
550
|
+
if any_of_keys:
|
|
551
|
+
properties[vol.Required(vol.Any(*any_of_keys))] = object
|
|
552
|
+
|
|
553
|
+
validator = None
|
|
471
554
|
if schema.get("additionalProperties") is True:
|
|
472
|
-
|
|
473
|
-
|
|
555
|
+
validator = vol.Schema(properties, extra=vol.ALLOW_EXTRA)
|
|
556
|
+
else:
|
|
557
|
+
validator = vol.Schema(properties)
|
|
558
|
+
|
|
559
|
+
# Handle OpenAPI 3.0 nullable property
|
|
560
|
+
if schema.get("nullable") is True:
|
|
561
|
+
return vol.Any(validator, None)
|
|
562
|
+
|
|
563
|
+
return validator
|
|
564
|
+
|
|
565
|
+
if schema_type == "array":
|
|
566
|
+
item_validator = convert_to_voluptuous(schema["items"])
|
|
567
|
+
min_items = schema.get("minItems")
|
|
568
|
+
max_items = schema.get("maxItems")
|
|
569
|
+
|
|
570
|
+
if min_items is not None or max_items is not None:
|
|
571
|
+
validator = vol.Schema(
|
|
572
|
+
vol.All([item_validator], vol.Length(min=min_items, max=max_items))
|
|
573
|
+
)
|
|
574
|
+
else:
|
|
575
|
+
validator = vol.Schema([item_validator])
|
|
576
|
+
|
|
577
|
+
# Handle OpenAPI 3.0 nullable property
|
|
578
|
+
if schema.get("nullable") is True:
|
|
579
|
+
return vol.Any(validator, None)
|
|
474
580
|
|
|
475
|
-
|
|
476
|
-
return vol.Schema([convert_to_voluptuous(schema["items"])])
|
|
581
|
+
return validator
|
|
477
582
|
|
|
478
583
|
raise ValueError("Unable to convert schema: {}".format(schema))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{voluptuous_openapi-0.1.0 → voluptuous_openapi-0.3.0}/voluptuous_openapi.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{voluptuous_openapi-0.1.0 → voluptuous_openapi-0.3.0}/voluptuous_openapi.egg-info/requires.txt
RENAMED
|
File without changes
|
{voluptuous_openapi-0.1.0 → voluptuous_openapi-0.3.0}/voluptuous_openapi.egg-info/top_level.txt
RENAMED
|
File without changes
|