Skip to content

Commit d0c79f2

Browse files
committed
Ignore reference siblings during OAS 3.0 schema checks
1 parent 867fea5 commit d0c79f2

2 files changed

Lines changed: 207 additions & 11 deletions

File tree

‎openapi_schema_validator/validators.py‎

Lines changed: 85 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from jsonschema.exceptions import SchemaError
1010
from jsonschema.exceptions import ValidationError
1111
from jsonschema.protocols import Validator
12+
from jsonschema.validators import Draft4Validator
13+
from jsonschema.validators import Draft7Validator
1214
from jsonschema.validators import Draft202012Validator
1315
from jsonschema.validators import create
1416
from jsonschema.validators import extend
@@ -28,6 +30,24 @@
2830
from openapi_schema_validator._types import oas31_type_checker
2931

3032
_CHECK_SCHEMA_UNSET = object()
33+
_DRAFT4_META_SCHEMA = OPENAPI_SPECIFICATIONS.contents(
34+
"http://json-schema.org/draft-04/schema#",
35+
)
36+
_OAS30_SCHEMA_CHECKER = extend(
37+
Draft4Validator, {"if": Draft7Validator.VALIDATORS["if"]}
38+
)
39+
# Keep the metaschema private and anonymous so its recursive references use
40+
# this conditional without switching back to the registered Draft 4 validator.
41+
_OAS30_CHECK_SCHEMA = {
42+
"definitions": _DRAFT4_META_SCHEMA["definitions"],
43+
"type": _DRAFT4_META_SCHEMA["type"],
44+
"if": {"required": ["$ref"]},
45+
"then": {"properties": {"$ref": {"type": "string"}}},
46+
"else": {
47+
"properties": _DRAFT4_META_SCHEMA["properties"],
48+
"dependencies": _DRAFT4_META_SCHEMA["dependencies"],
49+
},
50+
}
3151

3252

3353
def check_openapi_schema(
@@ -46,12 +66,41 @@ def check_openapi_schema(
4666
registry=OPENAPI_SPECIFICATIONS,
4767
)
4868

49-
for error in validator_for_metaschema.iter_errors(schema):
69+
_raise_first_schema_error(validator_for_metaschema, schema)
70+
71+
72+
def _check_oas30_schema(
73+
cls: Any,
74+
schema: Any,
75+
format_checker: Any = _CHECK_SCHEMA_UNSET,
76+
) -> None:
77+
if format_checker is _CHECK_SCHEMA_UNSET:
78+
format_checker = cls.FORMAT_CHECKER
79+
80+
validator = _OAS30_SCHEMA_CHECKER(
81+
_OAS30_CHECK_SCHEMA,
82+
format_checker=format_checker,
83+
registry=OPENAPI_SPECIFICATIONS,
84+
)
85+
_raise_first_schema_error(validator, schema)
86+
87+
88+
def _check_oas30_variant_schema(
89+
cls: Any,
90+
schema: Any,
91+
format_checker: Any = Draft4Validator.FORMAT_CHECKER,
92+
) -> None:
93+
# These variants previously inherited jsonschema's Draft 4 format checks.
94+
_check_oas30_schema(cls, schema, format_checker)
95+
96+
97+
def _raise_first_schema_error(validator: Validator, schema: Any) -> None:
98+
for error in validator.iter_errors(schema):
5099
raise SchemaError.create_from(error)
51100

52101

53102
def _oas30_id_of(schema: Any) -> str:
54-
if isinstance(schema, dict):
103+
if isinstance(schema, dict) and "$ref" not in schema:
55104
return schema.get("id", "") # type: ignore[no-any-return]
56105
return ""
57106

@@ -101,9 +150,7 @@ def _oas30_id_of(schema: Any) -> str:
101150

102151
def _build_oas30_validator() -> Any:
103152
return create(
104-
meta_schema=OPENAPI_SPECIFICATIONS.contents(
105-
"http://json-schema.org/draft-04/schema#",
106-
),
153+
meta_schema=_DRAFT4_META_SCHEMA,
107154
validators=OAS30_VALIDATORS,
108155
type_checker=oas_types.oas30_type_checker,
109156
format_checker=oas_format.oas30_format_checker,
@@ -181,15 +228,40 @@ def _build_oas32_validator() -> Any:
181228
},
182229
)
183230

231+
_jsonschema_evolve = OAS30Validator.evolve
232+
233+
234+
def _evolve_oas30(validator: Any, **changes: Any) -> Any:
235+
schema = changes.get("schema", validator.schema)
236+
# jsonschema selects a validator from $schema before filtering keywords.
237+
if (
238+
isinstance(schema, Mapping)
239+
and "$ref" in schema
240+
and "$schema" in schema
241+
):
242+
schema = dict(schema)
243+
del schema["$schema"]
244+
changes["schema"] = schema
245+
return _jsonschema_evolve(validator, **changes)
246+
247+
248+
for _oas30_validator in (
249+
OAS30Validator,
250+
OAS30StrictValidator,
251+
OAS30ReadValidator,
252+
OAS30WriteValidator,
253+
):
254+
_oas30_validator.evolve = _evolve_oas30
255+
184256
OAS31Validator = _build_oas31_validator()
185257
OAS32Validator = _build_oas32_validator()
186258

187-
# These validator classes are generated via jsonschema create/extend, so there
188-
# is no simpler hook to inject registry-aware schema checking while preserving
189-
# each class's FORMAT_CHECKER. Override check_schema on each class to keep
190-
# OpenAPI metaschema resolution local and to apply optional ecma-regex
191-
# behavior consistently across OAS 3.0/3.1/3.2.
192-
OAS30Validator.check_schema = classmethod(check_openapi_schema)
259+
# Override the generated methods to use the dialect-specific schema checks
260+
# and keep metaschema resolution local.
261+
OAS30Validator.check_schema = classmethod(_check_oas30_schema)
262+
OAS30StrictValidator.check_schema = classmethod(_check_oas30_variant_schema)
263+
OAS30ReadValidator.check_schema = classmethod(_check_oas30_variant_schema)
264+
OAS30WriteValidator.check_schema = classmethod(_check_oas30_variant_schema)
193265
OAS31Validator.check_schema = classmethod(check_openapi_schema)
194266
OAS32Validator.check_schema = classmethod(check_openapi_schema)
195267

@@ -235,4 +307,6 @@ def enforce_properties(
235307
extended_validator.check_schema = classmethod(
236308
validator_class.check_schema.__func__
237309
)
310+
if validator_class.evolve is _evolve_oas30:
311+
extended_validator.evolve = _evolve_oas30
238312
return cast(type[Validator], extended_validator)

‎tests/unit/test_shortcut.py‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from openapi_schema_validator.settings import reset_settings_cache
1515
from openapi_schema_validator.shortcuts import clear_validate_cache
1616
from openapi_schema_validator.validators import OAS30ReadValidator
17+
from openapi_schema_validator.validators import OAS30StrictValidator
1718
from openapi_schema_validator.validators import OAS30Validator
1819
from openapi_schema_validator.validators import OAS30WriteValidator
1920
from openapi_schema_validator.validators import OAS31Validator
@@ -433,3 +434,124 @@ def test_validate_oas30_ignores_ref_siblings_while_oas31_oas32_apply_them():
433434
cls=validator_cls,
434435
enforce_properties_required=True,
435436
)
437+
438+
439+
@pytest.mark.parametrize(
440+
"cls",
441+
[
442+
OAS30Validator,
443+
OAS30StrictValidator,
444+
OAS30ReadValidator,
445+
OAS30WriteValidator,
446+
],
447+
)
448+
def test_oas30_schema_check_ignores_ref_siblings(cls):
449+
schema = {
450+
"type": "object",
451+
"properties": {
452+
"$ref": {
453+
"$ref": "#/definitions/Name",
454+
"minLength": "ignored",
455+
"exclusiveMinimum": True,
456+
},
457+
},
458+
"definitions": {"Name": {"type": "string", "minLength": 2}},
459+
}
460+
461+
cls.check_schema(schema)
462+
validate({"$ref": "abc"}, schema, cls=cls)
463+
with pytest.raises(ValidationError, match="is too short"):
464+
validate({"$ref": "a"}, schema, cls=cls)
465+
466+
467+
def test_oas30_schema_check_preserves_root_ref_target():
468+
schema = {
469+
"$ref": "#/definitions/Name",
470+
"minLength": "ignored",
471+
"definitions": {"Name": {"type": "string", "minLength": 2}},
472+
}
473+
474+
validate("abc", schema, cls=OAS30Validator)
475+
with pytest.raises(ValidationError, match="is too short"):
476+
validate("a", schema, cls=OAS30Validator)
477+
478+
479+
def test_oas30_schema_check_rejects_non_string_ref():
480+
with pytest.raises(SchemaError, match="is not of type 'string'"):
481+
OAS30Validator.check_schema({"$ref": None})
482+
483+
484+
@pytest.mark.parametrize(
485+
"cls", [OAS30Validator, OAS31Validator, OAS32Validator]
486+
)
487+
def test_schema_check_rejects_malformed_constraints_in_applicable_schemas(cls):
488+
schema = {
489+
"properties": {"name": {"$ref": "#/definitions/Name"}},
490+
"definitions": {"Name": {"type": "string", "minLength": "invalid"}},
491+
}
492+
with pytest.raises(SchemaError, match="is not of type 'integer'"):
493+
cls.check_schema(schema)
494+
495+
496+
@pytest.mark.parametrize("cls", [OAS31Validator, OAS32Validator])
497+
def test_schema_check_rejects_malformed_ref_siblings_in_oas31_and_oas32(cls):
498+
schema = {
499+
"$ref": "#/$defs/Name",
500+
"minLength": "invalid",
501+
"$defs": {"Name": {"type": "string"}},
502+
}
503+
with pytest.raises(SchemaError, match="is not of type 'integer'"):
504+
validate("abc", schema, cls=cls)
505+
506+
507+
@pytest.mark.skipif(
508+
not has_ecma_regex(), reason="requires optional ecma-regex"
509+
)
510+
@pytest.mark.parametrize(
511+
"cls", [OAS30StrictValidator, OAS30ReadValidator, OAS30WriteValidator]
512+
)
513+
def test_oas30_variant_schema_check_keeps_draft4_format_defaults(cls):
514+
schema = {"pattern": r"(?P<name>a)"}
515+
cls.check_schema(schema)
516+
with pytest.raises(SchemaError, match="is not a 'regex'"):
517+
cls.check_schema(schema, format_checker=cls.FORMAT_CHECKER)
518+
519+
520+
@pytest.mark.parametrize(
521+
"dialect", [[], "https://json-schema.org/draft/2020-12/schema"]
522+
)
523+
@pytest.mark.parametrize("enforce", [False, True])
524+
def test_validate_oas30_ignores_ref_sibling_dialect(dialect, enforce):
525+
schema = {
526+
"$ref": "#/definitions/Name",
527+
"$schema": dialect,
528+
"minLength": 10,
529+
"definitions": {"Name": {"type": "string", "nullable": True}},
530+
}
531+
validate(
532+
None, schema, cls=OAS30Validator, enforce_properties_required=enforce
533+
)
534+
535+
536+
def test_validate_oas30_ignores_nested_ref_sibling_dialect():
537+
schema = {
538+
"properties": {
539+
"name": {
540+
"$ref": "#/definitions/Name",
541+
"$schema": "https://json-schema.org/draft/2020-12/schema",
542+
},
543+
},
544+
"definitions": {"Name": {"type": "string", "nullable": True}},
545+
}
546+
validate({"name": None}, schema, cls=OAS30Validator)
547+
548+
549+
def test_validate_oas30_ignores_ref_sibling_id_with_legacy_resolver():
550+
schema = {
551+
"$ref": "#/definitions/Name",
552+
"id": [],
553+
"definitions": {"Name": {"type": "string"}},
554+
}
555+
with pytest.deprecated_call():
556+
resolver = OAS30Validator(schema).resolver
557+
validate("abc", schema, cls=OAS30Validator, resolver=resolver)

0 commit comments

Comments
 (0)