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 9edee4b..44b6487 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 @@ -22,12 +24,33 @@ 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, ) 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 +69,59 @@ 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, + *, + check_references: bool = True, +) -> 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) + 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_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: + 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 +171,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 +249,41 @@ 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. + # Runs on every descend: check the common dict case without the ABC. + if ( + (type(schema) is dict or isinstance(schema, Mapping)) + and "$schema" in schema + and "$ref" 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 +329,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_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, + ) 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)