voluptuous-openapi 0.3.0__tar.gz → 0.4.1__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.3.0/voluptuous_openapi.egg-info → voluptuous_openapi-0.4.1}/PKG-INFO +2 -2
- {voluptuous_openapi-0.3.0 → voluptuous_openapi-0.4.1}/README.md +1 -1
- {voluptuous_openapi-0.3.0 → voluptuous_openapi-0.4.1}/pyproject.toml +1 -1
- {voluptuous_openapi-0.3.0 → voluptuous_openapi-0.4.1}/tests/test_lib.py +429 -0
- {voluptuous_openapi-0.3.0 → voluptuous_openapi-0.4.1}/voluptuous_openapi/__init__.py +163 -14
- {voluptuous_openapi-0.3.0 → voluptuous_openapi-0.4.1/voluptuous_openapi.egg-info}/PKG-INFO +2 -2
- {voluptuous_openapi-0.3.0 → voluptuous_openapi-0.4.1}/LICENSE +0 -0
- {voluptuous_openapi-0.3.0 → voluptuous_openapi-0.4.1}/setup.cfg +0 -0
- {voluptuous_openapi-0.3.0 → voluptuous_openapi-0.4.1}/tests/test_validation.py +0 -0
- {voluptuous_openapi-0.3.0 → voluptuous_openapi-0.4.1}/voluptuous_openapi.egg-info/SOURCES.txt +0 -0
- {voluptuous_openapi-0.3.0 → voluptuous_openapi-0.4.1}/voluptuous_openapi.egg-info/dependency_links.txt +0 -0
- {voluptuous_openapi-0.3.0 → voluptuous_openapi-0.4.1}/voluptuous_openapi.egg-info/requires.txt +0 -0
- {voluptuous_openapi-0.3.0 → voluptuous_openapi-0.4.1}/voluptuous_openapi.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: voluptuous-openapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
@@ -55,7 +55,7 @@ You can pass a custom serializer to be able to process custom validators. If the
|
|
|
55
55
|
|
|
56
56
|
```python
|
|
57
57
|
|
|
58
|
-
from
|
|
58
|
+
from voluptuous_openapi import UNSUPPORTED, convert
|
|
59
59
|
|
|
60
60
|
def custom_convert(value):
|
|
61
61
|
if value is my_custom_validator:
|
|
@@ -42,7 +42,7 @@ You can pass a custom serializer to be able to process custom validators. If the
|
|
|
42
42
|
|
|
43
43
|
```python
|
|
44
44
|
|
|
45
|
-
from
|
|
45
|
+
from voluptuous_openapi import UNSUPPORTED, convert
|
|
46
46
|
|
|
47
47
|
def custom_convert(value):
|
|
48
48
|
if value is my_custom_validator:
|
|
@@ -1016,3 +1016,432 @@ def test_required_any_of_inner_description():
|
|
|
1016
1016
|
): str,
|
|
1017
1017
|
}
|
|
1018
1018
|
)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def test_simple_ref_defs() -> None:
|
|
1022
|
+
"""Test basic JSON pointer reference resolution using the $defs keyword."""
|
|
1023
|
+
schema = {
|
|
1024
|
+
"$defs": {"MyInt": {"type": "integer"}},
|
|
1025
|
+
"type": "object",
|
|
1026
|
+
"properties": {"age": {"$ref": "#/$defs/MyInt"}},
|
|
1027
|
+
}
|
|
1028
|
+
validator = convert_to_voluptuous(schema)
|
|
1029
|
+
# Check it validates valid input
|
|
1030
|
+
res = validator({"age": 42})
|
|
1031
|
+
assert res == {"age": 42}
|
|
1032
|
+
|
|
1033
|
+
# Check it raises vol.Invalid on invalid input
|
|
1034
|
+
with pytest.raises(vol.Invalid):
|
|
1035
|
+
validator({"age": "hello"})
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def test_definitions_compatibility() -> None:
|
|
1039
|
+
"""Test compatibility with older JSON Schema drafts using definitions instead of $defs."""
|
|
1040
|
+
schema = {
|
|
1041
|
+
"definitions": {"MyInt": {"type": "integer"}},
|
|
1042
|
+
"type": "object",
|
|
1043
|
+
"properties": {"age": {"$ref": "#/definitions/MyInt"}},
|
|
1044
|
+
}
|
|
1045
|
+
validator = convert_to_voluptuous(schema)
|
|
1046
|
+
res = validator({"age": 42})
|
|
1047
|
+
assert res == {"age": 42}
|
|
1048
|
+
|
|
1049
|
+
with pytest.raises(vol.Invalid):
|
|
1050
|
+
validator({"age": "not-an-int"})
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def test_chained_refs() -> None:
|
|
1054
|
+
"""Test chained references where one reference points to another reference."""
|
|
1055
|
+
schema = {
|
|
1056
|
+
"$defs": {"Level2": {"type": "integer"}, "Level1": {"$ref": "#/$defs/Level2"}},
|
|
1057
|
+
"type": "object",
|
|
1058
|
+
"properties": {"num": {"$ref": "#/$defs/Level1"}},
|
|
1059
|
+
}
|
|
1060
|
+
validator = convert_to_voluptuous(schema)
|
|
1061
|
+
res = validator({"num": 42})
|
|
1062
|
+
assert res == {"num": 42}
|
|
1063
|
+
|
|
1064
|
+
with pytest.raises(vol.Invalid):
|
|
1065
|
+
validator({"num": "yes"})
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def test_root_self_ref_valid() -> None:
|
|
1069
|
+
"""Test root self-reference (#) with valid recursively nested structures."""
|
|
1070
|
+
schema = {"type": "object", "properties": {"child": {"$ref": "#"}}}
|
|
1071
|
+
validator = convert_to_voluptuous(schema)
|
|
1072
|
+
|
|
1073
|
+
# Single level
|
|
1074
|
+
assert validator({"child": {}}) == {"child": {}}
|
|
1075
|
+
|
|
1076
|
+
# Nested level
|
|
1077
|
+
assert validator({"child": {"child": {"child": {}}}}) == {
|
|
1078
|
+
"child": {"child": {"child": {}}}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def test_root_self_ref_invalid() -> None:
|
|
1083
|
+
"""Test root self-reference (#) with invalid type values."""
|
|
1084
|
+
schema = {"type": "object", "properties": {"child": {"$ref": "#"}}}
|
|
1085
|
+
validator = convert_to_voluptuous(schema)
|
|
1086
|
+
|
|
1087
|
+
with pytest.raises(vol.Invalid):
|
|
1088
|
+
validator({"child": "not-an-object"})
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def test_recursive_tree_valid() -> None:
|
|
1092
|
+
"""Test recursive tree node reference validation with valid structures."""
|
|
1093
|
+
schema = {
|
|
1094
|
+
"$defs": {
|
|
1095
|
+
"Node": {
|
|
1096
|
+
"type": "object",
|
|
1097
|
+
"properties": {
|
|
1098
|
+
"value": {"type": "string"},
|
|
1099
|
+
"children": {"type": "array", "items": {"$ref": "#/$defs/Node"}},
|
|
1100
|
+
},
|
|
1101
|
+
"required": ["value"],
|
|
1102
|
+
}
|
|
1103
|
+
},
|
|
1104
|
+
"$ref": "#/$defs/Node",
|
|
1105
|
+
}
|
|
1106
|
+
validator = convert_to_voluptuous(schema)
|
|
1107
|
+
|
|
1108
|
+
# Leaf node
|
|
1109
|
+
assert validator({"value": "leaf"}) == {"value": "leaf"}
|
|
1110
|
+
|
|
1111
|
+
# Deep recursive tree
|
|
1112
|
+
tree = {
|
|
1113
|
+
"value": "root",
|
|
1114
|
+
"children": [
|
|
1115
|
+
{"value": "child1", "children": [{"value": "grandchild1"}]},
|
|
1116
|
+
{"value": "child2"},
|
|
1117
|
+
],
|
|
1118
|
+
}
|
|
1119
|
+
assert validator(tree) == tree
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def test_recursive_tree_invalid() -> None:
|
|
1123
|
+
"""Test recursive tree node reference validation with invalid type values."""
|
|
1124
|
+
schema = {
|
|
1125
|
+
"$defs": {
|
|
1126
|
+
"Node": {
|
|
1127
|
+
"type": "object",
|
|
1128
|
+
"properties": {
|
|
1129
|
+
"value": {"type": "string"},
|
|
1130
|
+
"children": {"type": "array", "items": {"$ref": "#/$defs/Node"}},
|
|
1131
|
+
},
|
|
1132
|
+
"required": ["value"],
|
|
1133
|
+
}
|
|
1134
|
+
},
|
|
1135
|
+
"$ref": "#/$defs/Node",
|
|
1136
|
+
}
|
|
1137
|
+
validator = convert_to_voluptuous(schema)
|
|
1138
|
+
|
|
1139
|
+
invalid_tree = {"value": "root", "children": [{"value": 123}]} # Should be string
|
|
1140
|
+
with pytest.raises(vol.Invalid):
|
|
1141
|
+
validator(invalid_tree)
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def test_anonymized_field_definition_valid() -> None:
|
|
1145
|
+
"""Test anonymized FieldDefinition schema with valid primitive and complex structures."""
|
|
1146
|
+
schema = {
|
|
1147
|
+
"$defs": {
|
|
1148
|
+
"FieldDefinition": {
|
|
1149
|
+
"type": "object",
|
|
1150
|
+
"properties": {
|
|
1151
|
+
"type": {
|
|
1152
|
+
"type": "string",
|
|
1153
|
+
"enum": ["BOOLEAN", "STRING", "INTEGER", "STRUCT", "LIST"],
|
|
1154
|
+
},
|
|
1155
|
+
"structFields": {
|
|
1156
|
+
"type": "object",
|
|
1157
|
+
"additionalProperties": {"$ref": "#/$defs/FieldDefinition"},
|
|
1158
|
+
},
|
|
1159
|
+
"listElement": {"$ref": "#/$defs/FieldDefinition"},
|
|
1160
|
+
},
|
|
1161
|
+
"required": ["type"],
|
|
1162
|
+
}
|
|
1163
|
+
},
|
|
1164
|
+
"$ref": "#/$defs/FieldDefinition",
|
|
1165
|
+
}
|
|
1166
|
+
validator = convert_to_voluptuous(schema)
|
|
1167
|
+
|
|
1168
|
+
# Simple integer type definition
|
|
1169
|
+
assert validator({"type": "INTEGER"}) == {"type": "INTEGER"}
|
|
1170
|
+
|
|
1171
|
+
# Complex struct containing other field definitions (including a list element)
|
|
1172
|
+
complex_def = {
|
|
1173
|
+
"type": "STRUCT",
|
|
1174
|
+
"structFields": {
|
|
1175
|
+
"name": {"type": "STRING"},
|
|
1176
|
+
"tags": {"type": "LIST", "listElement": {"type": "STRING"}},
|
|
1177
|
+
},
|
|
1178
|
+
}
|
|
1179
|
+
assert validator(complex_def) == complex_def
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def test_anonymized_field_definition_invalid() -> None:
|
|
1183
|
+
"""Test anonymized FieldDefinition schema with invalid enum values and recursive types."""
|
|
1184
|
+
schema = {
|
|
1185
|
+
"$defs": {
|
|
1186
|
+
"FieldDefinition": {
|
|
1187
|
+
"type": "object",
|
|
1188
|
+
"properties": {
|
|
1189
|
+
"type": {
|
|
1190
|
+
"type": "string",
|
|
1191
|
+
"enum": ["BOOLEAN", "STRING", "INTEGER", "STRUCT", "LIST"],
|
|
1192
|
+
},
|
|
1193
|
+
"structFields": {
|
|
1194
|
+
"type": "object",
|
|
1195
|
+
"additionalProperties": {"$ref": "#/$defs/FieldDefinition"},
|
|
1196
|
+
},
|
|
1197
|
+
"listElement": {"$ref": "#/$defs/FieldDefinition"},
|
|
1198
|
+
},
|
|
1199
|
+
"required": ["type"],
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
"$ref": "#/$defs/FieldDefinition",
|
|
1203
|
+
}
|
|
1204
|
+
validator = convert_to_voluptuous(schema)
|
|
1205
|
+
|
|
1206
|
+
# Fails if enum value is wrong
|
|
1207
|
+
with pytest.raises(vol.Invalid):
|
|
1208
|
+
validator({"type": "FLOAT"})
|
|
1209
|
+
|
|
1210
|
+
# Fails if recursive validation fails
|
|
1211
|
+
with pytest.raises(vol.Invalid):
|
|
1212
|
+
validator(
|
|
1213
|
+
{
|
|
1214
|
+
"type": "STRUCT",
|
|
1215
|
+
"structFields": {"invalid_field": {"type": 123}}, # type must be string
|
|
1216
|
+
}
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def test_anonymized_ghp_home_automation_valid() -> None:
|
|
1221
|
+
"""Test anonymized GhpHomeAutomation schema with valid automation rules."""
|
|
1222
|
+
schema = {
|
|
1223
|
+
"$defs": {
|
|
1224
|
+
"Comparison": {
|
|
1225
|
+
"type": "object",
|
|
1226
|
+
"properties": {
|
|
1227
|
+
"fieldToCompare": {"type": "string"},
|
|
1228
|
+
"operator": {"type": "string", "enum": ["EQUALS", "LESS_THAN"]},
|
|
1229
|
+
"nestedComparison": {"$ref": "#/$defs/Comparison"},
|
|
1230
|
+
"operands": {"type": "array", "items": {"$ref": "#/$defs/Operand"}},
|
|
1231
|
+
},
|
|
1232
|
+
},
|
|
1233
|
+
"Operand": {
|
|
1234
|
+
"type": "object",
|
|
1235
|
+
"properties": {
|
|
1236
|
+
"comparison": {"$ref": "#/$defs/Comparison"},
|
|
1237
|
+
"value": {"type": "string"},
|
|
1238
|
+
},
|
|
1239
|
+
},
|
|
1240
|
+
"ConditionBlock": {
|
|
1241
|
+
"type": "object",
|
|
1242
|
+
"properties": {
|
|
1243
|
+
"comparison": {"$ref": "#/$defs/Comparison"},
|
|
1244
|
+
"description": {"type": "string"},
|
|
1245
|
+
},
|
|
1246
|
+
},
|
|
1247
|
+
},
|
|
1248
|
+
"type": "object",
|
|
1249
|
+
"properties": {
|
|
1250
|
+
"conditions": {"type": "array", "items": {"$ref": "#/$defs/ConditionBlock"}}
|
|
1251
|
+
},
|
|
1252
|
+
}
|
|
1253
|
+
validator = convert_to_voluptuous(schema)
|
|
1254
|
+
|
|
1255
|
+
valid_automation = {
|
|
1256
|
+
"conditions": [
|
|
1257
|
+
{
|
|
1258
|
+
"description": "Check temperature",
|
|
1259
|
+
"comparison": {
|
|
1260
|
+
"fieldToCompare": "temp",
|
|
1261
|
+
"operator": "LESS_THAN",
|
|
1262
|
+
"operands": [{"value": "72"}],
|
|
1263
|
+
},
|
|
1264
|
+
}
|
|
1265
|
+
]
|
|
1266
|
+
}
|
|
1267
|
+
assert validator(valid_automation) == valid_automation
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def test_anonymized_ghp_home_automation_invalid() -> None:
|
|
1271
|
+
"""Test anonymized GhpHomeAutomation schema with invalid operator constraints."""
|
|
1272
|
+
schema = {
|
|
1273
|
+
"$defs": {
|
|
1274
|
+
"Comparison": {
|
|
1275
|
+
"type": "object",
|
|
1276
|
+
"properties": {
|
|
1277
|
+
"fieldToCompare": {"type": "string"},
|
|
1278
|
+
"operator": {"type": "string", "enum": ["EQUALS", "LESS_THAN"]},
|
|
1279
|
+
"nestedComparison": {"$ref": "#/$defs/Comparison"},
|
|
1280
|
+
"operands": {"type": "array", "items": {"$ref": "#/$defs/Operand"}},
|
|
1281
|
+
},
|
|
1282
|
+
},
|
|
1283
|
+
"Operand": {
|
|
1284
|
+
"type": "object",
|
|
1285
|
+
"properties": {
|
|
1286
|
+
"comparison": {"$ref": "#/$defs/Comparison"},
|
|
1287
|
+
"value": {"type": "string"},
|
|
1288
|
+
},
|
|
1289
|
+
},
|
|
1290
|
+
"ConditionBlock": {
|
|
1291
|
+
"type": "object",
|
|
1292
|
+
"properties": {
|
|
1293
|
+
"comparison": {"$ref": "#/$defs/Comparison"},
|
|
1294
|
+
"description": {"type": "string"},
|
|
1295
|
+
},
|
|
1296
|
+
},
|
|
1297
|
+
},
|
|
1298
|
+
"type": "object",
|
|
1299
|
+
"properties": {
|
|
1300
|
+
"conditions": {"type": "array", "items": {"$ref": "#/$defs/ConditionBlock"}}
|
|
1301
|
+
},
|
|
1302
|
+
}
|
|
1303
|
+
validator = convert_to_voluptuous(schema)
|
|
1304
|
+
|
|
1305
|
+
# Invalid operator in nested comparison
|
|
1306
|
+
invalid_automation = {
|
|
1307
|
+
"conditions": [
|
|
1308
|
+
{
|
|
1309
|
+
"comparison": {
|
|
1310
|
+
"fieldToCompare": "temp",
|
|
1311
|
+
"operator": "GREATER_THAN", # Not in comparison enum ["EQUALS", "LESS_THAN"]
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
]
|
|
1315
|
+
}
|
|
1316
|
+
with pytest.raises(vol.Invalid):
|
|
1317
|
+
validator(invalid_automation)
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def test_convert_schema_with_reference() -> None:
|
|
1321
|
+
"""Test that converting a voluptuous schema containing a reference back to OpenAPI works."""
|
|
1322
|
+
schema = {
|
|
1323
|
+
"$defs": {
|
|
1324
|
+
"PositiveInteger": {
|
|
1325
|
+
"type": "integer",
|
|
1326
|
+
"minimum": 0,
|
|
1327
|
+
}
|
|
1328
|
+
},
|
|
1329
|
+
"type": "object",
|
|
1330
|
+
"properties": {
|
|
1331
|
+
"value": {"$ref": "#/$defs/PositiveInteger"},
|
|
1332
|
+
},
|
|
1333
|
+
}
|
|
1334
|
+
vol_schema = convert_to_voluptuous(schema)
|
|
1335
|
+
|
|
1336
|
+
# Verify that the returned vol_schema validates correctly
|
|
1337
|
+
assert vol_schema({"value": 42}) == {"value": 42}
|
|
1338
|
+
with pytest.raises(vol.Invalid):
|
|
1339
|
+
vol_schema({"value": -5})
|
|
1340
|
+
|
|
1341
|
+
# Verify that convert() successfully serializes the voluptuous schema
|
|
1342
|
+
# and denormalizes the reference.
|
|
1343
|
+
res = convert(vol_schema)
|
|
1344
|
+
assert res == {
|
|
1345
|
+
"type": "object",
|
|
1346
|
+
"properties": {
|
|
1347
|
+
"value": {"type": "integer", "minimum": 0},
|
|
1348
|
+
},
|
|
1349
|
+
"required": [],
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def test_convert_schema_with_nested_reference() -> None:
|
|
1354
|
+
"""Test that converting a voluptuous schema containing a nested reference back to OpenAPI works."""
|
|
1355
|
+
schema = {
|
|
1356
|
+
"$defs": {
|
|
1357
|
+
"PositiveInteger": {
|
|
1358
|
+
"type": "integer",
|
|
1359
|
+
"minimum": 0,
|
|
1360
|
+
}
|
|
1361
|
+
},
|
|
1362
|
+
"type": "object",
|
|
1363
|
+
"properties": {
|
|
1364
|
+
"nested": {
|
|
1365
|
+
"type": "object",
|
|
1366
|
+
"properties": {
|
|
1367
|
+
"value": {"$ref": "#/$defs/PositiveInteger"},
|
|
1368
|
+
},
|
|
1369
|
+
}
|
|
1370
|
+
},
|
|
1371
|
+
}
|
|
1372
|
+
vol_schema = convert_to_voluptuous(schema)
|
|
1373
|
+
|
|
1374
|
+
# Verify that the returned vol_schema validates correctly
|
|
1375
|
+
assert vol_schema({"nested": {"value": 42}}) == {"nested": {"value": 42}}
|
|
1376
|
+
with pytest.raises(vol.Invalid):
|
|
1377
|
+
vol_schema({"nested": {"value": -5}})
|
|
1378
|
+
|
|
1379
|
+
# Verify that convert() successfully serializes the voluptuous schema and denormalizes the nested reference.
|
|
1380
|
+
res = convert(vol_schema)
|
|
1381
|
+
assert res == {
|
|
1382
|
+
"type": "object",
|
|
1383
|
+
"properties": {
|
|
1384
|
+
"nested": {
|
|
1385
|
+
"type": "object",
|
|
1386
|
+
"properties": {
|
|
1387
|
+
"value": {"type": "integer", "minimum": 0},
|
|
1388
|
+
},
|
|
1389
|
+
"required": [],
|
|
1390
|
+
}
|
|
1391
|
+
},
|
|
1392
|
+
"required": [],
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def test_convert_recursive_schema() -> None:
|
|
1397
|
+
"""Test that converting a voluptuous schema containing a recursive reference back to OpenAPI works."""
|
|
1398
|
+
schema = {
|
|
1399
|
+
"$defs": {
|
|
1400
|
+
"Node": {
|
|
1401
|
+
"type": "object",
|
|
1402
|
+
"properties": {
|
|
1403
|
+
"value": {"type": "string"},
|
|
1404
|
+
"child": {"$ref": "#/$defs/Node"},
|
|
1405
|
+
},
|
|
1406
|
+
}
|
|
1407
|
+
},
|
|
1408
|
+
"$ref": "#/$defs/Node",
|
|
1409
|
+
}
|
|
1410
|
+
vol_schema = convert_to_voluptuous(schema)
|
|
1411
|
+
|
|
1412
|
+
# Verify that the returned vol_schema validates correctly
|
|
1413
|
+
valid_data = {"value": "root", "child": {"value": "child"}}
|
|
1414
|
+
assert vol_schema(valid_data) == valid_data
|
|
1415
|
+
|
|
1416
|
+
# Verify that convert() successfully serializes the voluptuous schema,
|
|
1417
|
+
# denormalizes the reference, and breaks the cycle at "child".
|
|
1418
|
+
res = convert(vol_schema)
|
|
1419
|
+
assert res == {
|
|
1420
|
+
"type": "object",
|
|
1421
|
+
"properties": {
|
|
1422
|
+
"value": {"type": "string"},
|
|
1423
|
+
"child": {"type": "string"},
|
|
1424
|
+
},
|
|
1425
|
+
"required": [],
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def test_convert_custom_callable_class_instance() -> None:
|
|
1430
|
+
"""Test that convert() handles custom callable validator instances."""
|
|
1431
|
+
|
|
1432
|
+
class CustomValidator:
|
|
1433
|
+
def __call__(self, value: int) -> int:
|
|
1434
|
+
if value < 0:
|
|
1435
|
+
raise vol.Invalid("Must be positive")
|
|
1436
|
+
return value
|
|
1437
|
+
|
|
1438
|
+
vol_schema = vol.Schema(CustomValidator())
|
|
1439
|
+
|
|
1440
|
+
# Verify native validation works
|
|
1441
|
+
assert vol_schema(42) == 42
|
|
1442
|
+
with pytest.raises(vol.Invalid):
|
|
1443
|
+
vol_schema(-5)
|
|
1444
|
+
|
|
1445
|
+
# Verify serialization extracts the type hint from __call__ parameter
|
|
1446
|
+
res = convert(vol_schema)
|
|
1447
|
+
assert res == {"type": "integer"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Module to convert voluptuous schemas to dictionaries."""
|
|
2
2
|
|
|
3
3
|
from collections.abc import Callable, Mapping, Sequence
|
|
4
|
-
from inspect import signature
|
|
4
|
+
from inspect import isroutine, signature
|
|
5
5
|
from enum import Enum, StrEnum
|
|
6
6
|
import itertools
|
|
7
7
|
import re
|
|
@@ -28,6 +28,9 @@ OPENAPI_UNSUPPORTED_KEYWORDS = {
|
|
|
28
28
|
"uniqueItems",
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
# The standard JSON Schema reference keyword
|
|
32
|
+
JSON_SCHEMA_REF = "$ref"
|
|
33
|
+
|
|
31
34
|
|
|
32
35
|
class OpenApiVersion(StrEnum):
|
|
33
36
|
"""The OpenAPI version.
|
|
@@ -44,6 +47,7 @@ def convert(
|
|
|
44
47
|
*,
|
|
45
48
|
custom_serializer: Callable | None = None,
|
|
46
49
|
openapi_version: OpenApiVersion = OpenApiVersion.V3,
|
|
50
|
+
_seen_refs: set[str] | None = None,
|
|
47
51
|
) -> dict:
|
|
48
52
|
"""Convert a voluptuous schema to a OpenAPI Schema object."""
|
|
49
53
|
# pylint: disable=too-many-return-statements,too-many-branches
|
|
@@ -51,9 +55,28 @@ def convert(
|
|
|
51
55
|
def convert_with_args(schema: Any) -> dict:
|
|
52
56
|
"""Convert schema for recusing and propagating arguments."""
|
|
53
57
|
return convert(
|
|
54
|
-
schema,
|
|
58
|
+
schema,
|
|
59
|
+
custom_serializer=custom_serializer,
|
|
60
|
+
openapi_version=openapi_version,
|
|
61
|
+
_seen_refs=_seen_refs,
|
|
55
62
|
)
|
|
56
63
|
|
|
64
|
+
if isinstance(schema, LazySchema):
|
|
65
|
+
# Recursively resolve and denormalize references. We track visited references
|
|
66
|
+
# in _seen_refs to detect cycle/recursion loops. If a cycle is detected, we
|
|
67
|
+
# break it by returning {} (representing Any).
|
|
68
|
+
if _seen_refs is None:
|
|
69
|
+
_seen_refs = set()
|
|
70
|
+
if schema.ref in _seen_refs:
|
|
71
|
+
return {}
|
|
72
|
+
_seen_refs.add(schema.ref)
|
|
73
|
+
try:
|
|
74
|
+
target_schema = resolve_ref(schema.ref, schema.root_schema)
|
|
75
|
+
vol_target = convert_to_voluptuous(target_schema, schema.root_schema)
|
|
76
|
+
return convert_with_args(vol_target)
|
|
77
|
+
finally:
|
|
78
|
+
_seen_refs.remove(schema.ref)
|
|
79
|
+
|
|
57
80
|
def ensure_default(value: dict[str:Any]):
|
|
58
81
|
"""Make sure that type is set."""
|
|
59
82
|
if all(x not in value for x in ("type", "anyOf", "oneOf", "allOf", "not")):
|
|
@@ -430,9 +453,16 @@ def convert(
|
|
|
430
453
|
return {"type": "object", "additionalProperties": True}
|
|
431
454
|
|
|
432
455
|
if callable(schema):
|
|
433
|
-
schema
|
|
434
|
-
|
|
435
|
-
|
|
456
|
+
if isinstance(schema, type) or isroutine(schema):
|
|
457
|
+
hints = get_type_hints(schema)
|
|
458
|
+
else:
|
|
459
|
+
# For custom callable class instances (such as LazySchema), we inspect
|
|
460
|
+
# the __call__ method instead. We cannot fully serialize arbitrary Python
|
|
461
|
+
# callables back to OpenAPI, but we can try to extract the type annotation
|
|
462
|
+
# of their first parameter to preserve type information in the generated schema.
|
|
463
|
+
hints = get_type_hints(schema.__call__)
|
|
464
|
+
params = list(signature(schema).parameters.keys())
|
|
465
|
+
schema = hints.get(params[0], Any) if params else Any
|
|
436
466
|
if schema is Any or isinstance(schema, TypeVar):
|
|
437
467
|
return {}
|
|
438
468
|
if isinstance(schema, UnionType) or get_origin(schema) is Union:
|
|
@@ -449,12 +479,123 @@ def convert(
|
|
|
449
479
|
raise ValueError("Unable to convert schema: {}".format(schema))
|
|
450
480
|
|
|
451
481
|
|
|
452
|
-
def
|
|
453
|
-
"""
|
|
482
|
+
def resolve_ref(ref: str, root_schema: dict) -> dict:
|
|
483
|
+
"""Resolve a JSON pointer reference within the root_schema.
|
|
484
|
+
|
|
485
|
+
A JSON pointer (defined in RFC 6901) is a string syntax for identifying a
|
|
486
|
+
specific value within a JSON document. It uses '/' to separate keys or list
|
|
487
|
+
indices. This function resolves local pointer references starting with '#'.
|
|
488
|
+
|
|
489
|
+
Example:
|
|
490
|
+
Given root_schema = {"$defs": {"User": {"type": "object"}}},
|
|
491
|
+
resolve_ref("#/$defs/User", root_schema) returns {"type": "object"}.
|
|
492
|
+
"""
|
|
493
|
+
if not ref.startswith("#"):
|
|
494
|
+
raise ValueError(f"External/relative $ref not supported: {ref}")
|
|
495
|
+
if ref == "#" or ref == "":
|
|
496
|
+
return root_schema
|
|
497
|
+
|
|
498
|
+
parts = ref.lstrip("#").split("/")
|
|
499
|
+
# If the ref was '#/something', lstrip('#') is '/something', and split('/')
|
|
500
|
+
# produces ['', 'something']. We pop the first empty element.
|
|
501
|
+
if parts[0] == "":
|
|
502
|
+
parts.pop(0)
|
|
503
|
+
|
|
504
|
+
current = root_schema
|
|
505
|
+
for part in parts:
|
|
506
|
+
# RFC 6901 specifies '~1' represents '/' and '~0' represents '~'
|
|
507
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
508
|
+
if isinstance(current, dict) and part in current:
|
|
509
|
+
current = current[part]
|
|
510
|
+
elif isinstance(current, list):
|
|
511
|
+
try:
|
|
512
|
+
idx = int(part)
|
|
513
|
+
current = current[idx]
|
|
514
|
+
except (ValueError, IndexError):
|
|
515
|
+
raise ValueError(f"Could not resolve ref {ref} in list at index {part}")
|
|
516
|
+
else:
|
|
517
|
+
raise ValueError(f"Could not resolve ref {ref} at key {part}")
|
|
518
|
+
|
|
519
|
+
return current
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class LazySchema:
|
|
523
|
+
"""A wrapper validator that resolves and compiles a schema reference lazily.
|
|
524
|
+
|
|
525
|
+
This leverages voluptuous's ability to use any Python callable as a validator.
|
|
526
|
+
By returning a LazySchema instance during compilation, we avoid infinite
|
|
527
|
+
recursion/loops for circular or self-referential schemas. The schema is
|
|
528
|
+
resolved and compiled only on its first invocation, and cached for future runs.
|
|
529
|
+
|
|
530
|
+
Serialization Round-Trip Caveats:
|
|
531
|
+
When converting a voluptuous schema containing a LazySchema back to an OpenAPI
|
|
532
|
+
schema:
|
|
533
|
+
1. Non-recursive references are fully denormalized (resolved and expanded).
|
|
534
|
+
2. Recursive/circular references are broken at the recursion boundary and return
|
|
535
|
+
an empty schema {} (representing Any, which is defaulted to a string type by
|
|
536
|
+
the library's ensure_default helper) to prevent infinite recursion/loops.
|
|
537
|
+
"""
|
|
538
|
+
|
|
539
|
+
def __init__(self, ref: str, root_schema: dict):
|
|
540
|
+
self.ref = ref
|
|
541
|
+
self.root_schema = root_schema
|
|
542
|
+
self._compiled_schema = None
|
|
543
|
+
|
|
544
|
+
def __call__(self, value: Any) -> Any:
|
|
545
|
+
"""Validate the value against the lazily compiled schema.
|
|
546
|
+
|
|
547
|
+
On the first validation call, this resolves the JSON pointer reference
|
|
548
|
+
and compiles it into a voluptuous validator. Subsequent validation calls
|
|
549
|
+
bypass the compilation and reuse the cached validator.
|
|
550
|
+
"""
|
|
551
|
+
if self._compiled_schema is None:
|
|
552
|
+
target_schema = resolve_ref(self.ref, self.root_schema)
|
|
553
|
+
self._compiled_schema = convert_to_voluptuous(
|
|
554
|
+
target_schema, self.root_schema
|
|
555
|
+
)
|
|
556
|
+
return self._compiled_schema(value)
|
|
557
|
+
|
|
558
|
+
def __eq__(self, other: Any) -> bool:
|
|
559
|
+
if not isinstance(other, LazySchema):
|
|
560
|
+
return False
|
|
561
|
+
return self.ref == other.ref and self.root_schema == other.root_schema
|
|
562
|
+
|
|
563
|
+
def __hash__(self) -> int:
|
|
564
|
+
return hash(self.ref)
|
|
565
|
+
|
|
566
|
+
def __repr__(self) -> str:
|
|
567
|
+
return f"LazySchema({self.ref!r})"
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def convert_to_voluptuous(schema: dict, root_schema: dict | None = None) -> Any:
|
|
571
|
+
"""Convert an OpenAPI/JSON Schema object to a voluptuous schema.
|
|
572
|
+
|
|
573
|
+
Args:
|
|
574
|
+
schema: The OpenAPI/JSON Schema dictionary to convert.
|
|
575
|
+
root_schema: The root schema document, which is propagated recursively
|
|
576
|
+
to resolve local JSON pointer references ($ref). If not provided,
|
|
577
|
+
defaults to the current schema.
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
A voluptuous Schema, a type validator, or a custom validator callable.
|
|
581
|
+
"""
|
|
454
582
|
|
|
455
583
|
if not isinstance(schema, dict):
|
|
456
584
|
raise ValueError("Invalid schema, expected a dictionary")
|
|
457
585
|
|
|
586
|
+
if root_schema is None:
|
|
587
|
+
root_schema = schema
|
|
588
|
+
|
|
589
|
+
if JSON_SCHEMA_REF in schema:
|
|
590
|
+
return LazySchema(schema[JSON_SCHEMA_REF], root_schema)
|
|
591
|
+
|
|
592
|
+
if (enum_values := schema.get("enum")) is not None:
|
|
593
|
+
if not isinstance(enum_values, list):
|
|
594
|
+
raise ValueError("Invalid schema, enum should be a list")
|
|
595
|
+
if schema.get("nullable") is True:
|
|
596
|
+
return vol.Any(vol.In(enum_values), None)
|
|
597
|
+
return vol.In(enum_values)
|
|
598
|
+
|
|
458
599
|
for keyword in OPENAPI_UNSUPPORTED_KEYWORDS:
|
|
459
600
|
if keyword in schema:
|
|
460
601
|
raise ValueError(f"{keyword} is not supported")
|
|
@@ -464,7 +605,9 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
|
|
|
464
605
|
raise ValueError("Invalid schema, oneOf should be a list")
|
|
465
606
|
# This implements anyOf semantics sice it matches any of the subschemas,
|
|
466
607
|
# not just one of them.
|
|
467
|
-
return vol.Any(
|
|
608
|
+
return vol.Any(
|
|
609
|
+
*[convert_to_voluptuous(sub_schema, root_schema) for sub_schema in one_of]
|
|
610
|
+
)
|
|
468
611
|
|
|
469
612
|
if (any_of := schema.get("anyOf")) is not None:
|
|
470
613
|
if not isinstance(any_of, list):
|
|
@@ -477,7 +620,10 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
|
|
|
477
620
|
)
|
|
478
621
|
if not (is_required_constraint and schema.get("type") == "object"):
|
|
479
622
|
return vol.Any(
|
|
480
|
-
*[
|
|
623
|
+
*[
|
|
624
|
+
convert_to_voluptuous(sub_schema, root_schema)
|
|
625
|
+
for sub_schema in any_of
|
|
626
|
+
]
|
|
481
627
|
)
|
|
482
628
|
|
|
483
629
|
if (schema_type := schema.get("type")) is None:
|
|
@@ -498,7 +644,7 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
|
|
|
498
644
|
else:
|
|
499
645
|
base_schema = schema.copy()
|
|
500
646
|
base_schema["type"] = t
|
|
501
|
-
validators.append(convert_to_voluptuous(base_schema))
|
|
647
|
+
validators.append(convert_to_voluptuous(base_schema, root_schema))
|
|
502
648
|
return vol.Any(*validators)
|
|
503
649
|
|
|
504
650
|
if (basic_type := TYPES_MAP_REV.get(schema_type)) is not None:
|
|
@@ -533,7 +679,7 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
|
|
|
533
679
|
properties = {}
|
|
534
680
|
required_properties = set(schema.get("required", []))
|
|
535
681
|
for key, value in schema.get("properties", {}).items():
|
|
536
|
-
value_type = convert_to_voluptuous(value)
|
|
682
|
+
value_type = convert_to_voluptuous(value, root_schema)
|
|
537
683
|
description: str | None = None
|
|
538
684
|
if isinstance(value, dict):
|
|
539
685
|
description = value.get("description")
|
|
@@ -550,9 +696,12 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
|
|
|
550
696
|
if any_of_keys:
|
|
551
697
|
properties[vol.Required(vol.Any(*any_of_keys))] = object
|
|
552
698
|
|
|
553
|
-
|
|
554
|
-
if
|
|
699
|
+
extra_schema = schema.get("additionalProperties")
|
|
700
|
+
if extra_schema is True:
|
|
555
701
|
validator = vol.Schema(properties, extra=vol.ALLOW_EXTRA)
|
|
702
|
+
elif isinstance(extra_schema, dict):
|
|
703
|
+
properties[vol.Extra] = convert_to_voluptuous(extra_schema, root_schema)
|
|
704
|
+
validator = vol.Schema(properties)
|
|
556
705
|
else:
|
|
557
706
|
validator = vol.Schema(properties)
|
|
558
707
|
|
|
@@ -563,7 +712,7 @@ def convert_to_voluptuous(schema: dict) -> vol.Schema:
|
|
|
563
712
|
return validator
|
|
564
713
|
|
|
565
714
|
if schema_type == "array":
|
|
566
|
-
item_validator = convert_to_voluptuous(schema["items"])
|
|
715
|
+
item_validator = convert_to_voluptuous(schema["items"], root_schema)
|
|
567
716
|
min_items = schema.get("minItems")
|
|
568
717
|
max_items = schema.get("maxItems")
|
|
569
718
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: voluptuous-openapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
@@ -55,7 +55,7 @@ You can pass a custom serializer to be able to process custom validators. If the
|
|
|
55
55
|
|
|
56
56
|
```python
|
|
57
57
|
|
|
58
|
-
from
|
|
58
|
+
from voluptuous_openapi import UNSUPPORTED, convert
|
|
59
59
|
|
|
60
60
|
def custom_convert(value):
|
|
61
61
|
if value is my_custom_validator:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{voluptuous_openapi-0.3.0 → voluptuous_openapi-0.4.1}/voluptuous_openapi.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{voluptuous_openapi-0.3.0 → voluptuous_openapi-0.4.1}/voluptuous_openapi.egg-info/requires.txt
RENAMED
|
File without changes
|
{voluptuous_openapi-0.3.0 → voluptuous_openapi-0.4.1}/voluptuous_openapi.egg-info/top_level.txt
RENAMED
|
File without changes
|