From 17e14d75938fe7e533a11eb14456bdfe7c6f738c Mon Sep 17 00:00:00 2001 From: Artur Maciag Date: Mon, 9 Nov 2026 15:29:03 +0000 Subject: [PATCH 1/3] Ignore reference siblings during OAS 3.0 schema checks --- openapi_schema_validator/validators.py | 96 ++++++++++++++++--- tests/unit/test_shortcut.py | 122 +++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 11 deletions(-) diff --git a/openapi_schema_validator/validators.py b/openapi_schema_validator/validators.py index 9edee4b..e59c473 100644 --- a/openapi_schema_validator/validators.py +++ b/openapi_schema_validator/validators.py @@ -9,6 +9,8 @@ from jsonschema.exceptions import SchemaError from jsonschema.exceptions import ValidationError from jsonschema.protocols import Validator +from jsonschema.validators import Draft4Validator +from jsonschema.validators import Draft7Validator from jsonschema.validators import Draft202012Validator from jsonschema.validators import create from jsonschema.validators import extend @@ -28,6 +30,24 @@ from openapi_schema_validator._types import oas31_type_checker _CHECK_SCHEMA_UNSET = object() +_DRAFT4_META_SCHEMA = OPENAPI_SPECIFICATIONS.contents( + "http://json-schema.org/draft-04/schema#", +) +_OAS30_SCHEMA_CHECKER = extend( + Draft4Validator, {"if": Draft7Validator.VALIDATORS["if"]} +) +# Keep the metaschema private and anonymous so its recursive references use +# this conditional without switching back to the registered Draft 4 validator. +_OAS30_CHECK_SCHEMA = { + "definitions": _DRAFT4_META_SCHEMA["definitions"], + "type": _DRAFT4_META_SCHEMA["type"], + "if": {"required": ["$ref"]}, + "then": {"properties": {"$ref": {"type": "string"}}}, + "else": { + "properties": _DRAFT4_META_SCHEMA["properties"], + "dependencies": _DRAFT4_META_SCHEMA["dependencies"], + }, +} def check_openapi_schema( @@ -46,12 +66,41 @@ def check_openapi_schema( registry=OPENAPI_SPECIFICATIONS, ) - for error in validator_for_metaschema.iter_errors(schema): + _raise_first_schema_error(validator_for_metaschema, schema) + + +def _check_oas30_schema( + cls: Any, + schema: Any, + format_checker: Any = _CHECK_SCHEMA_UNSET, +) -> None: + if format_checker is _CHECK_SCHEMA_UNSET: + format_checker = cls.FORMAT_CHECKER + + validator = _OAS30_SCHEMA_CHECKER( + _OAS30_CHECK_SCHEMA, + format_checker=format_checker, + registry=OPENAPI_SPECIFICATIONS, + ) + _raise_first_schema_error(validator, schema) + + +def _check_oas30_variant_schema( + cls: Any, + schema: Any, + format_checker: Any = Draft4Validator.FORMAT_CHECKER, +) -> None: + # These variants previously inherited jsonschema's Draft 4 format checks. + _check_oas30_schema(cls, schema, format_checker) + + +def _raise_first_schema_error(validator: Validator, schema: Any) -> None: + for error in validator.iter_errors(schema): raise SchemaError.create_from(error) def _oas30_id_of(schema: Any) -> str: - if isinstance(schema, dict): + if isinstance(schema, dict) and "$ref" not in schema: return schema.get("id", "") # type: ignore[no-any-return] return "" @@ -101,9 +150,7 @@ def _oas30_id_of(schema: Any) -> str: def _build_oas30_validator() -> Any: return create( - meta_schema=OPENAPI_SPECIFICATIONS.contents( - "http://json-schema.org/draft-04/schema#", - ), + meta_schema=_DRAFT4_META_SCHEMA, validators=OAS30_VALIDATORS, type_checker=oas_types.oas30_type_checker, format_checker=oas_format.oas30_format_checker, @@ -181,15 +228,40 @@ def _build_oas32_validator() -> Any: }, ) +_jsonschema_evolve = OAS30Validator.evolve + + +def _evolve_oas30(validator: Any, **changes: Any) -> Any: + schema = changes.get("schema", validator.schema) + # jsonschema selects a validator from $schema before filtering keywords. + if ( + isinstance(schema, Mapping) + and "$ref" in schema + and "$schema" in schema + ): + schema = dict(schema) + del schema["$schema"] + changes["schema"] = schema + return _jsonschema_evolve(validator, **changes) + + +for _oas30_validator in ( + OAS30Validator, + OAS30StrictValidator, + OAS30ReadValidator, + OAS30WriteValidator, +): + _oas30_validator.evolve = _evolve_oas30 + OAS31Validator = _build_oas31_validator() OAS32Validator = _build_oas32_validator() -# These validator classes are generated via jsonschema create/extend, so there -# is no simpler hook to inject registry-aware schema checking while preserving -# each class's FORMAT_CHECKER. Override check_schema on each class to keep -# OpenAPI metaschema resolution local and to apply optional ecma-regex -# behavior consistently across OAS 3.0/3.1/3.2. -OAS30Validator.check_schema = classmethod(check_openapi_schema) +# Override the generated methods to use the dialect-specific schema checks +# and keep metaschema resolution local. +OAS30Validator.check_schema = classmethod(_check_oas30_schema) +OAS30StrictValidator.check_schema = classmethod(_check_oas30_variant_schema) +OAS30ReadValidator.check_schema = classmethod(_check_oas30_variant_schema) +OAS30WriteValidator.check_schema = classmethod(_check_oas30_variant_schema) OAS31Validator.check_schema = classmethod(check_openapi_schema) OAS32Validator.check_schema = classmethod(check_openapi_schema) @@ -235,4 +307,6 @@ def enforce_properties( extended_validator.check_schema = classmethod( validator_class.check_schema.__func__ ) + if validator_class.evolve is _evolve_oas30: + extended_validator.evolve = _evolve_oas30 return cast(type[Validator], extended_validator) diff --git a/tests/unit/test_shortcut.py b/tests/unit/test_shortcut.py index 98093c4..22495c0 100644 --- a/tests/unit/test_shortcut.py +++ b/tests/unit/test_shortcut.py @@ -14,6 +14,7 @@ from openapi_schema_validator.settings import reset_settings_cache from openapi_schema_validator.shortcuts import clear_validate_cache from openapi_schema_validator.validators import OAS30ReadValidator +from openapi_schema_validator.validators import OAS30StrictValidator from openapi_schema_validator.validators import OAS30Validator from openapi_schema_validator.validators import OAS30WriteValidator from openapi_schema_validator.validators import OAS31Validator @@ -433,3 +434,124 @@ def test_validate_oas30_ignores_ref_siblings_while_oas31_oas32_apply_them(): cls=validator_cls, enforce_properties_required=True, ) + + +@pytest.mark.parametrize( + "cls", + [ + OAS30Validator, + OAS30StrictValidator, + OAS30ReadValidator, + OAS30WriteValidator, + ], +) +def test_oas30_schema_check_ignores_ref_siblings(cls): + schema = { + "type": "object", + "properties": { + "$ref": { + "$ref": "#/definitions/Name", + "minLength": "ignored", + "exclusiveMinimum": True, + }, + }, + "definitions": {"Name": {"type": "string", "minLength": 2}}, + } + + cls.check_schema(schema) + validate({"$ref": "abc"}, schema, cls=cls) + with pytest.raises(ValidationError, match="is too short"): + validate({"$ref": "a"}, schema, cls=cls) + + +def test_oas30_schema_check_preserves_root_ref_target(): + schema = { + "$ref": "#/definitions/Name", + "minLength": "ignored", + "definitions": {"Name": {"type": "string", "minLength": 2}}, + } + + validate("abc", schema, cls=OAS30Validator) + with pytest.raises(ValidationError, match="is too short"): + validate("a", schema, cls=OAS30Validator) + + +def test_oas30_schema_check_rejects_non_string_ref(): + with pytest.raises(SchemaError, match="is not of type 'string'"): + OAS30Validator.check_schema({"$ref": None}) + + +@pytest.mark.parametrize( + "cls", [OAS30Validator, OAS31Validator, OAS32Validator] +) +def test_schema_check_rejects_malformed_constraints_in_applicable_schemas(cls): + schema = { + "properties": {"name": {"$ref": "#/definitions/Name"}}, + "definitions": {"Name": {"type": "string", "minLength": "invalid"}}, + } + with pytest.raises(SchemaError, match="is not of type 'integer'"): + cls.check_schema(schema) + + +@pytest.mark.parametrize("cls", [OAS31Validator, OAS32Validator]) +def test_schema_check_rejects_malformed_ref_siblings_in_oas31_and_oas32(cls): + schema = { + "$ref": "#/$defs/Name", + "minLength": "invalid", + "$defs": {"Name": {"type": "string"}}, + } + with pytest.raises(SchemaError, match="is not of type 'integer'"): + validate("abc", schema, cls=cls) + + +@pytest.mark.skipif( + not has_ecma_regex(), reason="requires optional ecma-regex" +) +@pytest.mark.parametrize( + "cls", [OAS30StrictValidator, OAS30ReadValidator, OAS30WriteValidator] +) +def test_oas30_variant_schema_check_keeps_draft4_format_defaults(cls): + schema = {"pattern": r"(?Pa)"} + cls.check_schema(schema) + with pytest.raises(SchemaError, match="is not a 'regex'"): + cls.check_schema(schema, format_checker=cls.FORMAT_CHECKER) + + +@pytest.mark.parametrize( + "dialect", [[], "https://json-schema.org/draft/2020-12/schema"] +) +@pytest.mark.parametrize("enforce", [False, True]) +def test_validate_oas30_ignores_ref_sibling_dialect(dialect, enforce): + schema = { + "$ref": "#/definitions/Name", + "$schema": dialect, + "minLength": 10, + "definitions": {"Name": {"type": "string", "nullable": True}}, + } + validate( + None, schema, cls=OAS30Validator, enforce_properties_required=enforce + ) + + +def test_validate_oas30_ignores_nested_ref_sibling_dialect(): + schema = { + "properties": { + "name": { + "$ref": "#/definitions/Name", + "$schema": "https://json-schema.org/draft/2020-12/schema", + }, + }, + "definitions": {"Name": {"type": "string", "nullable": True}}, + } + validate({"name": None}, schema, cls=OAS30Validator) + + +def test_validate_oas30_ignores_ref_sibling_id_with_legacy_resolver(): + schema = { + "$ref": "#/definitions/Name", + "id": [], + "definitions": {"Name": {"type": "string"}}, + } + with pytest.deprecated_call(): + resolver = OAS30Validator(schema).resolver + validate("abc", schema, cls=OAS30Validator, resolver=resolver) From 732203678e95b616a3699fbf52c786a364f7bfb5 Mon Sep 17 00:00:00 2001 From: Artur Maciag Date: Mon, 9 Nov 2026 17:32:36 +0000 Subject: [PATCH 2/3] Check local reference targets in OAS 3.0 schema checks Reference siblings are ignored by the OAS 3.0 schema check, so schemas reachable only through a local reference (next to a root $ref, or under a non-schema key such as components) were no longer checked. Follow reachable JSON pointer references from the root and check targets the structural check did not cover. References that depend on runtime resolution context are left to instance validation: external and anchor references, references inside a subschema with its own id, targets declaring another dialect, and shortcut calls with a legacy resolver or a root $schema. --- docs/validation.rst | 7 + openapi_schema_validator/_references.py | 150 ++++++++++++ openapi_schema_validator/shortcuts.py | 20 +- openapi_schema_validator/validators.py | 23 +- tests/unit/test_oas30_reference_targets.py | 267 +++++++++++++++++++++ 5 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 openapi_schema_validator/_references.py create mode 100644 tests/unit/test_oas30_reference_targets.py diff --git a/docs/validation.rst b/docs/validation.rst index c95164e..c26c841 100644 --- a/docs/validation.rst +++ b/docs/validation.rst @@ -161,6 +161,13 @@ In order to validate OpenAPI 3.0 schema, import and use ``OAS30Validator`` inste validate({"name": "John", "age": None}, schema, cls=OAS30Validator) +In OpenAPI 3.0 an object with ``$ref`` is a Reference Object, and any other +keys next to ``$ref`` are ignored, both when checking the schema and when +validating instances. A ``$schema`` next to ``$ref`` does not switch the +dialect either. Schema checks still follow local JSON pointer references +(``#/...``) and check the schemas they point to, wherever those are stored in +the document. + Default dialect resolution -------------------------- diff --git a/openapi_schema_validator/_references.py b/openapi_schema_validator/_references.py new file mode 100644 index 0000000..694b77c --- /dev/null +++ b/openapi_schema_validator/_references.py @@ -0,0 +1,150 @@ +"""Local reference targets for OpenAPI 3.0 schema checks. + +In OpenAPI 3.0 an object with ``$ref`` is a Reference Object and its other +keys are ignored, so the structural schema check does not descend into +them. A local reference can still point at a schema the structural check +never visited (for example a key next to a root ``$ref``, or a non-schema +key such as ``components``). This module finds those targets so they can +be checked with the same metaschema. + +Only JSON pointer references (``#`` and ``#/...``) resolved against the +root document are followed. References that depend on runtime resolution +context are left to instance validation: external and anchor references, +references inside a subschema that sets its own ``id``, and schemas that +declare a different dialect with ``$schema``. +""" + +from typing import Any +from typing import Iterator +from typing import Mapping +from urllib.parse import unquote + +from jsonschema.validators import validator_for + +# Draft 4 keywords whose values are a schema, a list of schemas, or a +# mapping of schemas. +_SCHEMA_KEYWORDS = ("additionalItems", "additionalProperties", "items", "not") +_SCHEMA_LIST_KEYWORDS = ("allOf", "anyOf", "items", "oneOf") +_SCHEMA_MAP_KEYWORDS = ("dependencies", "patternProperties", "properties") + + +def declares_other_dialect(schema: Any) -> bool: + """Whether ``schema`` switches jsonschema to another validator class. + + A ``$schema`` next to a ``$ref`` is an ignored sibling and does not + count. + """ + return ( + isinstance(schema, Mapping) + and "$ref" not in schema + and isinstance(schema.get("$schema"), str) + and validator_for(schema, default=None) is not None + ) + + +def _children( + schema: Mapping[str, Any], *, include_definitions: bool +) -> Iterator[Mapping[str, Any]]: + for keyword in _SCHEMA_KEYWORDS: + value = schema.get(keyword) + if isinstance(value, Mapping): + yield value + for keyword in _SCHEMA_LIST_KEYWORDS: + value = schema.get(keyword) + if isinstance(value, list): + yield from (item for item in value if isinstance(item, Mapping)) + map_keywords: tuple[str, ...] = _SCHEMA_MAP_KEYWORDS + if include_definitions: + map_keywords = (*map_keywords, "definitions") + for keyword in map_keywords: + value = schema.get(keyword) + if isinstance(value, Mapping): + yield from ( + item for item in value.values() if isinstance(item, Mapping) + ) + + +def _mark_covered(schema: Mapping[str, Any], covered: set[int]) -> None: + # Mirror what the structural check visits: every subschema, including + # definitions, but nothing inside a Reference Object. + pending = [schema] + while pending: + current = pending.pop() + if id(current) in covered: + continue + covered.add(id(current)) + if "$ref" not in current: + pending.extend(_children(current, include_definitions=True)) + + +def _sets_scope(schema: Any) -> bool: + return ( + isinstance(schema, Mapping) + and "$ref" not in schema + and isinstance(schema.get("id"), str) + ) + + +def _resolve_pointer( + document: Mapping[str, Any], ref: str +) -> tuple[Any, bool]: + """Return the pointer target and whether an ``id`` on the way to it + changes its resolution scope.""" + target: Any = document + scoped = False + if ref == "#": + return target, scoped + for token in unquote(ref[2:]).split("/"): + token = token.replace("~1", "/").replace("~0", "~") + if isinstance(target, list): + target = target[int(token)] + else: + target = target[token] + scoped = scoped or _sets_scope(target) + return target, scoped + + +def iter_unchecked_reference_targets( + schema: Mapping[str, Any], +) -> Iterator[Mapping[str, Any]]: + """Yield reachable local reference targets the structural check missed. + + Each target is yielded once. Targets inside a yielded target are + treated as covered by that target's check. + """ + covered: set[int] = set() + _mark_covered(schema, covered) + + pending: list[tuple[Mapping[str, Any], bool]] = [(schema, False)] + visited: set[tuple[int, bool]] = set() + while pending: + current, scoped = pending.pop() + key = (id(current), scoped) + if key in visited: + continue + visited.add(key) + + if "$ref" not in current: + if current is not schema and declares_other_dialect(current): + continue + # Definitions become reachable through references only. + for child in _children(current, include_definitions=False): + pending.append((child, scoped or _sets_scope(child))) + continue + + ref = current["$ref"] + if scoped or not isinstance(ref, str): + continue + if ref != "#" and not ref.startswith("#/"): + continue + try: + target, target_scoped = _resolve_pointer(schema, ref) + except (LookupError, TypeError, ValueError): + # Unresolvable references are reported during validation. + continue + if not isinstance(target, Mapping) or declares_other_dialect(target): + continue + if id(target) not in covered: + _mark_covered(target, covered) + yield target + pending.append((target, target_scoped)) diff --git a/openapi_schema_validator/shortcuts.py b/openapi_schema_validator/shortcuts.py index 103b16b..724bbc0 100644 --- a/openapi_schema_validator/shortcuts.py +++ b/openapi_schema_validator/shortcuts.py @@ -11,11 +11,13 @@ from openapi_schema_validator._caches import ValidatorCache from openapi_schema_validator._dialects import OAS31_BASE_DIALECT_ID from openapi_schema_validator._dialects import OAS32_BASE_DIALECT_ID +from openapi_schema_validator._references import declares_other_dialect from openapi_schema_validator.validators import OAS32Validator from openapi_schema_validator.validators import ( build_enforce_properties_required_validator, ) from openapi_schema_validator.validators import check_openapi_schema +from openapi_schema_validator.validators import supports_reference_check _LOCAL_ONLY_REGISTRY = Registry() _VALIDATOR_CACHE = ValidatorCache() @@ -24,6 +26,8 @@ def _check_schema( cls: type[Validator], schema: dict[str, Any], + *, + legacy_resolver: bool, ) -> None: meta_schema = getattr(cls, "META_SCHEMA", None) # jsonschema's default check_schema path does not accept a custom @@ -34,6 +38,14 @@ def _check_schema( OAS32_BASE_DIALECT_ID, ): check_openapi_schema(cls, schema) + elif supports_reference_check(cls): + # Local references are resolved against ``schema`` only when it is + # the reference root: a legacy resolver can carry another document, + # and a root ``$schema`` hands validation to that dialect. + check_references = not ( + legacy_resolver or declares_other_dialect(schema) + ) + cls.check_schema(schema, check_references=check_references) else: cls.check_schema(schema) @@ -92,6 +104,10 @@ def validate( if not allow_remote_references: validator_kwargs.setdefault("registry", _LOCAL_ONLY_REGISTRY) + legacy_resolver = validator_kwargs.get("resolver") is not None or bool( + args and args[0] is not None + ) + key = _VALIDATOR_CACHE.build_key( schema=schema_dict, cls=cls, @@ -104,7 +120,7 @@ def validate( if cached is None: if check_schema: - _check_schema(cls, schema_dict) + _check_schema(cls, schema_dict, legacy_resolver=legacy_resolver) validator = cls(schema_dict, *args, **validator_kwargs) cached = _VALIDATOR_CACHE.set( @@ -113,7 +129,7 @@ def validate( schema_checked=check_schema, ) elif check_schema and not cached.schema_checked: - _check_schema(cls, schema_dict) + _check_schema(cls, schema_dict, legacy_resolver=legacy_resolver) _VALIDATOR_CACHE.mark_schema_checked(key) else: _VALIDATOR_CACHE.touch(key) diff --git a/openapi_schema_validator/validators.py b/openapi_schema_validator/validators.py index e59c473..20f1dd2 100644 --- a/openapi_schema_validator/validators.py +++ b/openapi_schema_validator/validators.py @@ -24,6 +24,9 @@ from openapi_schema_validator._dialects import OAS32_BASE_DIALECT_ID from openapi_schema_validator._dialects import OAS32_BASE_DIALECT_METASCHEMA from openapi_schema_validator._dialects import register_openapi_dialect +from openapi_schema_validator._references import ( + iter_unchecked_reference_targets, +) from openapi_schema_validator._specifications import ( REGISTRY as OPENAPI_SPECIFICATIONS, ) @@ -73,6 +76,8 @@ def _check_oas30_schema( cls: Any, schema: Any, format_checker: Any = _CHECK_SCHEMA_UNSET, + *, + check_references: bool = True, ) -> None: if format_checker is _CHECK_SCHEMA_UNSET: format_checker = cls.FORMAT_CHECKER @@ -83,15 +88,31 @@ def _check_oas30_schema( registry=OPENAPI_SPECIFICATIONS, ) _raise_first_schema_error(validator, schema) + if not check_references or not isinstance(schema, Mapping): + return + # Reference siblings are ignored above, so check local reference + # targets the structural check did not reach. + for target in iter_unchecked_reference_targets(schema): + _raise_first_schema_error(validator, target) def _check_oas30_variant_schema( cls: Any, schema: Any, format_checker: Any = Draft4Validator.FORMAT_CHECKER, + *, + check_references: bool = True, ) -> None: # These variants previously inherited jsonschema's Draft 4 format checks. - _check_oas30_schema(cls, schema, format_checker) + _check_oas30_schema( + cls, schema, format_checker, check_references=check_references + ) + + +def supports_reference_check(cls: Any) -> bool: + """Whether ``cls.check_schema`` accepts ``check_references``.""" + check = getattr(cls.check_schema, "__func__", None) + return check in (_check_oas30_schema, _check_oas30_variant_schema) def _raise_first_schema_error(validator: Validator, schema: Any) -> None: diff --git a/tests/unit/test_oas30_reference_targets.py b/tests/unit/test_oas30_reference_targets.py new file mode 100644 index 0000000..6b5f7a8 --- /dev/null +++ b/tests/unit/test_oas30_reference_targets.py @@ -0,0 +1,267 @@ +from typing import Any + +import pytest +from jsonschema.exceptions import SchemaError +from jsonschema.exceptions import ValidationError + +from openapi_schema_validator import validate +from openapi_schema_validator._references import ( + iter_unchecked_reference_targets, +) +from openapi_schema_validator.shortcuts import clear_validate_cache +from openapi_schema_validator.validators import OAS30ReadValidator +from openapi_schema_validator.validators import OAS30StrictValidator +from openapi_schema_validator.validators import OAS30Validator +from openapi_schema_validator.validators import OAS30WriteValidator + +OAS30_VALIDATORS = [ + OAS30Validator, + OAS30StrictValidator, + OAS30ReadValidator, + OAS30WriteValidator, +] +DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema" + + +@pytest.fixture(autouse=True) +def clear_cache() -> None: + clear_validate_cache() + + +@pytest.mark.parametrize("cls", OAS30_VALIDATORS) +def test_rejects_malformed_target_under_non_schema_key(cls: Any) -> None: + schema = { + "properties": {"name": {"$ref": "#/components/Name"}}, + "components": {"Name": {"type": "string", "minLength": "invalid"}}, + } + + with pytest.raises(SchemaError, match="is not of type 'integer'"): + cls.check_schema(schema) + with pytest.raises(SchemaError, match="is not of type 'integer'"): + validate({"name": "abc"}, schema, cls=cls) + + +def test_rejects_malformed_definition_next_to_root_ref() -> None: + schema = { + "$ref": "#/definitions/Name", + "definitions": {"Name": {"type": "string", "minLength": "invalid"}}, + } + + with pytest.raises(SchemaError, match="is not of type 'integer'"): + OAS30Validator.check_schema(schema) + + +def test_rejects_malformed_target_reached_transitively() -> None: + schema = { + "$ref": "#/x-a", + "x-a": {"properties": {"b": {"$ref": "#/x-b"}}}, + "x-b": {"items": {"$ref": "#/x-c/0"}}, + "x-c": [{"maxItems": -1}], + } + + with pytest.raises(SchemaError, match="less than the minimum"): + OAS30Validator.check_schema(schema) + + +def test_rejects_malformed_target_reached_from_definition() -> None: + schema = { + "properties": {"a": {"$ref": "#/definitions/A"}}, + "definitions": {"A": {"$ref": "#/x-target"}}, + "x-target": {"type": "string", "minLength": "invalid"}, + } + + with pytest.raises(SchemaError, match="is not of type 'integer'"): + OAS30Validator.check_schema(schema) + + +def test_resolves_escaped_pointer_tokens() -> None: + schema = { + "$ref": "#/x-paths/~1pets~0id/my%20schema", + "x-paths": {"/pets~id": {"my schema": {"minLength": "invalid"}}}, + } + + with pytest.raises(SchemaError, match="is not of type 'integer'"): + OAS30Validator.check_schema(schema) + + +def test_ignores_unreferenced_non_schema_keys() -> None: + schema = { + "type": "object", + "x-unused": {"minLength": "invalid"}, + "properties": {"name": {"$ref": "#/x-name"}}, + "x-name": {"type": "string"}, + } + + OAS30Validator.check_schema(schema) + validate({"name": "abc"}, schema, cls=OAS30Validator) + + +def test_ignores_unused_siblings_of_reached_reference() -> None: + schema = { + "properties": { + "name": {"$ref": "#/x-name", "x-ignored": {"$ref": "#/x-bad"}}, + }, + "x-name": {"type": "string"}, + "x-bad": {"minLength": "invalid"}, + } + + OAS30Validator.check_schema(schema) + + +def test_terminates_on_recursive_references() -> None: + schema = { + "$ref": "#/definitions/Node", + "definitions": { + "Node": { + "type": "object", + "properties": { + "self": {"$ref": "#"}, + "children": { + "type": "array", + "items": {"$ref": "#/definitions/Node"}, + }, + }, + }, + }, + } + + OAS30Validator.check_schema(schema) + validate({"children": [{"children": []}]}, schema, cls=OAS30Validator) + + +def test_checks_each_target_once() -> None: + target: dict[str, Any] = {"type": "string"} + schema = { + "allOf": [{"$ref": "#/x-target"}, {"$ref": "#/x-target"}], + "properties": {"a": {"$ref": "#/x-target"}}, + "x-target": target, + } + + assert list(iter_unchecked_reference_targets(schema)) == [target] + + +def test_skips_targets_already_covered_by_structural_check() -> None: + schema = { + "properties": {"a": {"$ref": "#/definitions/A"}}, + "definitions": {"A": {"type": "string"}}, + } + + assert list(iter_unchecked_reference_targets(schema)) == [] + + +@pytest.mark.parametrize( + "ref", + [ + "#/missing", + "#/x-list/5", + "#/x-list/not-an-index", + "#/x-name/type/deeper", + ], +) +def test_leaves_unresolvable_references_to_validation(ref: str) -> None: + schema = { + "properties": {"name": {"$ref": ref}}, + "x-list": [], + "x-name": {"type": "string"}, + } + + OAS30Validator.check_schema(schema) + + +@pytest.mark.parametrize( + "ref", ["https://example.com/schema.json", "other.json", "#anchor"] +) +def test_does_not_follow_non_pointer_references(ref: str) -> None: + schema = {"properties": {"name": {"$ref": ref}}} + + OAS30Validator.check_schema(schema) + + +def test_skips_references_inside_id_scope() -> None: + # At runtime the reference resolves against the nested ``id`` scope, + # not the root document, so the root's ``x-name`` must not be checked. + schema = { + "properties": { + "wrapper": { + "id": "https://example.com/wrapper.json", + "properties": {"name": {"$ref": "#/x-name"}}, + "x-name": {"type": "string"}, + }, + }, + "x-name": {"minLength": "invalid"}, + } + + OAS30Validator.check_schema(schema) + + +def test_skips_target_declaring_other_dialect() -> None: + schema = { + "properties": {"count": {"$ref": "#/x-count"}}, + "x-count": {"$schema": DRAFT_2020_12, "exclusiveMinimum": 0}, + } + + OAS30Validator.check_schema(schema) + validate({"count": 1}, schema, cls=OAS30Validator) + with pytest.raises(ValidationError, match="less than or equal to"): + validate({"count": 0}, schema, cls=OAS30Validator) + + +def test_direct_check_follows_references_under_root_dialect() -> None: + schema = { + "$schema": DRAFT_2020_12, + "properties": {"name": {"$ref": "#/x-target"}}, + "x-target": {"minLength": "invalid"}, + } + + with pytest.raises(SchemaError, match="is not of type 'integer'"): + OAS30Validator.check_schema(schema) + + +def test_shortcut_defers_to_declared_root_dialect() -> None: + # The shortcut evolves the root, so a root ``$schema`` selects that + # dialect for the whole validation. + schema = { + "$schema": DRAFT_2020_12, + "properties": {"count": {"$ref": "#/x-count"}}, + "x-count": {"exclusiveMinimum": 0}, + } + + validate({"count": 1}, schema, cls=OAS30Validator) + + +@pytest.mark.parametrize("legacy", ["keyword", "positional"]) +def test_shortcut_defers_to_legacy_resolver(legacy: str) -> None: + # Local references resolve against the legacy resolver's document. + schema = {"$ref": "#/x-target", "x-target": {"minLength": "invalid"}} + with pytest.deprecated_call(): + resolver = OAS30Validator({"x-target": {"type": "string"}}).resolver + args = (resolver,) if legacy == "positional" else () + kwargs = {"resolver": resolver} if legacy == "keyword" else {} + + validate("abc", schema, OAS30Validator, *args, **kwargs) + with pytest.raises(SchemaError, match="is not of type 'integer'"): + validate( + "abc", {"minLength": "invalid"}, OAS30Validator, *args, **kwargs + ) + + +@pytest.mark.parametrize("cls", OAS30_VALIDATORS) +def test_check_references_can_be_disabled(cls: Any) -> None: + schema = {"$ref": "#/x-target", "x-target": {"minLength": "invalid"}} + + cls.check_schema(schema, check_references=False) + + +def test_enforce_properties_required_keeps_reference_check() -> None: + schema = { + "properties": {"name": {"$ref": "#/x-name"}}, + "x-name": {"minLength": "invalid"}, + } + + with pytest.raises(SchemaError, match="is not of type 'integer'"): + validate( + {"name": "abc"}, + schema, + cls=OAS30Validator, + enforce_properties_required=True, + ) From 4bdf8a453dd93cf7fddec2df39fd00b9ae1c6c78 Mon Sep 17 00:00:00 2001 From: Artur Maciag Date: Mon, 9 Nov 2026 17:32:37 +0000 Subject: [PATCH 3/3] Reduce OAS 3.0 evolve overhead evolve runs on every descend; avoid the Mapping ABC check for plain dicts and test the rarer $schema key first. --- openapi_schema_validator/validators.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openapi_schema_validator/validators.py b/openapi_schema_validator/validators.py index 20f1dd2..44b6487 100644 --- a/openapi_schema_validator/validators.py +++ b/openapi_schema_validator/validators.py @@ -255,10 +255,11 @@ def _build_oas32_validator() -> Any: def _evolve_oas30(validator: Any, **changes: Any) -> Any: schema = changes.get("schema", validator.schema) # jsonschema selects a validator from $schema before filtering keywords. + # Runs on every descend: check the common dict case without the ABC. if ( - isinstance(schema, Mapping) - and "$ref" in schema + (type(schema) is dict or isinstance(schema, Mapping)) and "$schema" in schema + and "$ref" in schema ): schema = dict(schema) del schema["$schema"]