Skip to content

Commit 867fea5

Browse files
authored
Merge pull request #305 from josephaxisa/fix/oas30-ignore-ref-siblings
Ignore $ref sibling keywords in OAS 3.0 validators
2 parents 005e080 + 6e7f1d8 commit 867fea5

4 files changed

Lines changed: 257 additions & 3 deletions

File tree

‎openapi_schema_validator/_keywords.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ def required(
232232
for property in required:
233233
if property not in instance:
234234
prop_schema = schema.get("properties", {}).get(property)
235-
if prop_schema:
235+
if prop_schema and "$ref" not in prop_schema:
236236
read_only = prop_schema.get("readOnly", False)
237237
write_only = prop_schema.get("writeOnly", False)
238238
if (
@@ -256,7 +256,7 @@ def read_required(
256256
for property in required:
257257
if property not in instance:
258258
prop_schema = schema.get("properties", {}).get(property)
259-
if prop_schema:
259+
if prop_schema and "$ref" not in prop_schema:
260260
write_only = prop_schema.get("writeOnly", False)
261261
if getattr(validator, "read", True) and write_only:
262262
continue
@@ -274,7 +274,7 @@ def write_required(
274274
for property in required:
275275
if property not in instance:
276276
prop_schema = schema.get("properties", {}).get(property)
277-
if prop_schema:
277+
if prop_schema and "$ref" not in prop_schema:
278278
read_only = prop_schema.get("readOnly", False)
279279
if read_only:
280280
continue

‎openapi_schema_validator/validators.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def _build_oas30_validator() -> Any:
111111
# See https://github.com/python-openapi/openapi-schema-validator/pull/12
112112
# version="oas30",
113113
id_of=_oas30_id_of,
114+
applicable_validators=_legacy_keywords.ignore_ref_siblings,
114115
)
115116

116117

‎tests/integration/test_validators.py‎

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,65 @@ def test_nullable_schema_combos(
764764
validator.validate({"testfield": None})
765765
assert False
766766

767+
def test_ref_ignores_sibling_keywords(self, validator_class):
768+
schema = {
769+
"$ref": "#/components/schemas/ItemTarget",
770+
"required": ["name"],
771+
"components": {
772+
"schemas": {
773+
"PlainId": {
774+
"type": "integer",
775+
},
776+
"NullableName": {
777+
"type": "string",
778+
"nullable": True,
779+
"minLength": 2,
780+
},
781+
"ItemTarget": {
782+
"type": "object",
783+
"required": ["id"],
784+
"properties": {
785+
"id": {
786+
"$ref": "#/components/schemas/PlainId",
787+
"type": "integer",
788+
"nullable": True,
789+
},
790+
"name": {
791+
"$ref": "#/components/schemas/NullableName",
792+
"type": "string",
793+
"nullable": False,
794+
"minLength": 10,
795+
},
796+
"code": {
797+
"$ref": "#/components/schemas/NullableName",
798+
"type": "integer",
799+
},
800+
},
801+
},
802+
},
803+
},
804+
}
805+
validator = validator_class(
806+
schema,
807+
format_checker=oas30_format_checker,
808+
)
809+
810+
assert validator.validate({"id": 1}) is None
811+
assert (
812+
validator.validate({"id": 1, "name": None, "code": "abc"}) is None
813+
)
814+
815+
with pytest.raises(
816+
ValidationError, match="'id' is a required property"
817+
):
818+
validator.validate({"name": "valid_name"})
819+
820+
with pytest.raises(ValidationError, match="None for not nullable"):
821+
validator.validate({"id": None})
822+
823+
with pytest.raises(ValidationError, match="is too short"):
824+
validator.validate({"id": 1, "name": "a"})
825+
767826

768827
class TestOAS30ReadWriteValidatorValidate:
769828
def test_read_only(self):
@@ -886,6 +945,90 @@ def test_write_only_false(self):
886945
)
887946
assert validator.validate({"some_prop": "hello"}) is None
888947

948+
def test_ref_ignores_sibling_read_write_keywords(self):
949+
schema = {
950+
"type": "object",
951+
"required": ["id", "secret", "token"],
952+
"properties": {
953+
"id": {
954+
"$ref": "#/components/schemas/PlainInt",
955+
"readOnly": True,
956+
},
957+
"secret": {
958+
"$ref": "#/components/schemas/PlainString",
959+
"writeOnly": True,
960+
},
961+
"token": {
962+
"$ref": "#/components/schemas/ReadOnlyString",
963+
"readOnly": False,
964+
},
965+
},
966+
"components": {
967+
"schemas": {
968+
"PlainInt": {"type": "integer"},
969+
"PlainString": {"type": "string"},
970+
"ReadOnlyString": {"type": "string", "readOnly": True},
971+
},
972+
},
973+
}
974+
975+
write_validator = OAS30WriteValidator(
976+
schema,
977+
format_checker=oas30_format_checker,
978+
)
979+
read_validator = OAS30ReadValidator(
980+
schema,
981+
format_checker=oas30_format_checker,
982+
)
983+
default_validator = OAS30Validator(
984+
schema,
985+
format_checker=oas30_format_checker,
986+
)
987+
988+
# Sibling readOnly: True on `id` and writeOnly: True on `secret`
989+
# are ignored
990+
assert (
991+
read_validator.validate({"id": 1, "secret": "abc", "token": "tok"})
992+
is None
993+
)
994+
assert (
995+
default_validator.validate(
996+
{"id": 1, "secret": "abc", "token": "tok"}
997+
)
998+
is None
999+
)
1000+
1001+
# Target ReadOnlyString still enforces readOnly: True on write
1002+
with pytest.raises(
1003+
ValidationError,
1004+
match="Tried to write read-only property with tok",
1005+
):
1006+
write_validator.validate(
1007+
{"id": 1, "secret": "abc", "token": "tok"}
1008+
)
1009+
1010+
# Sibling readOnly: True on `id` does not exempt `id` from
1011+
# write_required or default required
1012+
with pytest.raises(
1013+
ValidationError, match="'id' is a required property"
1014+
):
1015+
write_validator.validate({"secret": "abc"})
1016+
with pytest.raises(
1017+
ValidationError, match="'id' is a required property"
1018+
):
1019+
default_validator.validate({"secret": "abc", "token": "tok"})
1020+
1021+
# Sibling writeOnly: True on `secret` does not exempt `secret` from
1022+
# read_required or default required
1023+
with pytest.raises(
1024+
ValidationError, match="'secret' is a required property"
1025+
):
1026+
read_validator.validate({"id": 1, "token": "tok"})
1027+
with pytest.raises(
1028+
ValidationError, match="'secret' is a required property"
1029+
):
1030+
default_validator.validate({"id": 1, "token": "tok"})
1031+
8891032

8901033
class TestOAS31ValidatorFormatChecker:
8911034
@pytest.fixture
@@ -1047,6 +1190,40 @@ def test_schema_ref(self, validator_class, format_checker):
10471190
error = "'id' is a required property"
10481191
assert error in str(excinfo.value)
10491192

1193+
def test_schema_ref_applies_sibling_keywords(
1194+
self, validator_class, format_checker
1195+
):
1196+
schema = {
1197+
"$ref": "#/$defs/Pet",
1198+
"required": ["name"],
1199+
"$defs": {
1200+
"Pet": {
1201+
"type": "object",
1202+
"required": ["id"],
1203+
"properties": {
1204+
"id": {"type": "integer", "format": "int64"},
1205+
"name": {"type": "string"},
1206+
},
1207+
}
1208+
},
1209+
}
1210+
validator = validator_class(
1211+
schema,
1212+
format_checker=format_checker,
1213+
)
1214+
1215+
assert validator.validate({"id": 1, "name": "John"}) is None
1216+
1217+
with pytest.raises(
1218+
ValidationError, match="'name' is a required property"
1219+
):
1220+
validator.validate({"id": 1})
1221+
1222+
with pytest.raises(
1223+
ValidationError, match="'id' is a required property"
1224+
):
1225+
validator.validate({"name": "John"})
1226+
10501227
@pytest.mark.parametrize(
10511228
"value",
10521229
[
@@ -1330,6 +1507,32 @@ def test_strict_binary_format_rejects_str(self):
13301507
with pytest.raises(ValidationError, match="is not a 'binary'"):
13311508
validator.validate("not base64")
13321509

1510+
def test_strict_validator_ref_ignores_sibling_keywords(self):
1511+
schema = {
1512+
"$ref": "#/components/schemas/NullableText",
1513+
"type": "string",
1514+
"nullable": False,
1515+
"minLength": 10,
1516+
"components": {
1517+
"schemas": {
1518+
"NullableText": {
1519+
"type": "string",
1520+
"nullable": True,
1521+
"minLength": 2,
1522+
}
1523+
}
1524+
},
1525+
}
1526+
validator = OAS30StrictValidator(
1527+
schema, format_checker=oas30_strict_format_checker
1528+
)
1529+
1530+
assert validator.validate("abc") is None
1531+
assert validator.validate(None) is None
1532+
1533+
with pytest.raises(ValidationError, match="is too short"):
1534+
validator.validate("a")
1535+
13331536

13341537
class TestValidatorForDiscovery:
13351538
def test_oas31_base_dialect_resolves_to_oas31_validator(self):

‎tests/unit/test_shortcut.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,3 +383,53 @@ def test_enforce_properties_required(
383383
validate(
384384
instance, schema, cls=cls, enforce_properties_required=enforce
385385
)
386+
387+
388+
def test_validate_oas30_ignores_ref_siblings_while_oas31_oas32_apply_them():
389+
schema = {
390+
"$ref": "#/$defs/ItemTarget",
391+
"required": ["name"],
392+
"properties": {
393+
"extra": {"type": "string"},
394+
},
395+
"$defs": {
396+
"ItemTarget": {
397+
"type": "object",
398+
"required": ["id"],
399+
"properties": {
400+
"id": {"type": "integer"},
401+
"name": {"type": "string"},
402+
},
403+
}
404+
},
405+
}
406+
407+
assert validate({"id": 1}, schema, cls=OAS30Validator) is None
408+
assert (
409+
validate(
410+
{"id": 1, "name": "abc"},
411+
schema,
412+
cls=OAS30Validator,
413+
enforce_properties_required=True,
414+
)
415+
is None
416+
)
417+
418+
for validator_cls in (OAS31Validator, OAS32Validator):
419+
with pytest.raises(
420+
ValidationError, match="'name' is a required property"
421+
):
422+
validate({"id": 1}, schema, cls=validator_cls)
423+
assert (
424+
validate({"id": 1, "name": "abc"}, schema, cls=validator_cls)
425+
is None
426+
)
427+
with pytest.raises(
428+
ValidationError, match="'extra' is a required property"
429+
):
430+
validate(
431+
{"id": 1, "name": "abc"},
432+
schema,
433+
cls=validator_cls,
434+
enforce_properties_required=True,
435+
)

0 commit comments

Comments
 (0)