diff --git a/openapi_spec_validator/validation/keywords.py b/openapi_spec_validator/validation/keywords.py index df52f34..4678f62 100644 --- a/openapi_spec_validator/validation/keywords.py +++ b/openapi_spec_validator/validation/keywords.py @@ -3,6 +3,7 @@ from collections.abc import Iterator from collections.abc import Mapping from collections.abc import Sequence +from functools import partial from typing import TYPE_CHECKING from typing import Any from typing import cast @@ -11,11 +12,13 @@ from jsonschema.exceptions import SchemaError from jsonschema.exceptions import ValidationError from jsonschema.protocols import Validator +from jsonschema.validators import validator_for from jsonschema_path.paths import SchemaPath from openapi_schema_validator import oas30_format_checker from openapi_schema_validator import oas31_format_checker from openapi_schema_validator.validators import OAS30Validator from openapi_schema_validator.validators import OAS31Validator +from openapi_schema_validator.validators import check_openapi_schema from openapi_spec_validator.validation.exceptions import ( DuplicateOperationIDError, @@ -34,6 +37,8 @@ KeywordValidatorRegistry, ) +OAS31_BASE_DIALECT_URI = "https://spec.openapis.org/oas/3.1/dialect/base" + class KeywordValidator: def __init__(self, registry: "KeywordValidatorRegistry"): @@ -101,6 +106,32 @@ def _collect_properties(self, schema: SchemaPath) -> set[str]: return props + def _get_schema_checker( + self, schema: SchemaPath, schema_value: Any + ) -> Callable[[Any], None]: + return cast( + Callable[[Any], None], + getattr( + self.default_validator.value_validator_cls, + "check_schema", + ), + ) + + def _validate_schema_meta( + self, schema: SchemaPath, schema_value: Any + ) -> OpenAPIValidationError | None: + try: + schema_checker = self._get_schema_checker(schema, schema_value) + except ValueError as exc: + return OpenAPIValidationError(str(exc)) + try: + schema_checker(schema_value) + except (SchemaError, ValidationError) as err: + return cast( + OpenAPIValidationError, OpenAPIValidationError.create_from(err) + ) + return None + def __call__( self, schema: SchemaPath, @@ -114,23 +145,17 @@ def __call__( ) return + schema_id = id(schema_value) if not meta_checked: assert self.meta_checked_schema_ids is not None - schema_id = id(schema_value) if schema_id not in self.meta_checked_schema_ids: - try: - schema_check = getattr( - self.default_validator.value_validator_cls, - "check_schema", - ) - schema_check(schema_value) - except (SchemaError, ValidationError) as err: - yield OpenAPIValidationError.create_from(err) - return self.meta_checked_schema_ids.append(schema_id) + err = self._validate_schema_meta(schema, schema_value) + if err is not None: + yield err + return assert self.visited_schema_ids is not None - schema_id = id(schema_value) if schema_id in self.visited_schema_ids: return self.visited_schema_ids.append(schema_id) @@ -218,6 +243,95 @@ def __call__( yield from self.default_validator(schema, default_value) +class OpenAPIV31SchemaValidator(SchemaValidator): + def __init__(self, registry: "KeywordValidatorRegistry"): + super().__init__(registry) + self._default_jsonschema_dialect_id: str | None = None + self._validator_classes_by_dialect: dict[ + str, type[Validator] | None + ] = {} + + def _get_schema_checker( + self, schema: SchemaPath, schema_value: Any + ) -> Callable[[Any], None]: + dialect_id = self._get_schema_dialect_id( + schema, + schema_value, + ) + + validator_cls = self._get_validator_class_for_dialect(dialect_id) + if validator_cls is None: + raise ValueError(f"Unknown JSON Schema dialect: {dialect_id!r}") + + return partial( + check_openapi_schema, + validator_cls, + format_checker=oas31_format_checker, + ) + + def _get_schema_dialect_id( + self, schema: SchemaPath, schema_value: Any + ) -> str: + if isinstance(schema_value, Mapping): + schema_to_check = dict(schema_value) + if "$schema" in schema_to_check: + dialect_value = schema_to_check["$schema"] + if not isinstance(dialect_value, str): + raise ValueError( + "Unknown JSON Schema dialect: " f"{dialect_value!r}" + ) + dialect_id = dialect_value + else: + jsonschema_dialect_id = ( + self._get_default_jsonschema_dialect_id(schema) + ) + schema_to_check = { + **schema_to_check, + "$schema": jsonschema_dialect_id, + } + dialect_id = jsonschema_dialect_id + else: + jsonschema_dialect_id = self._get_default_jsonschema_dialect_id( + schema + ) + schema_to_check = schema_value + dialect_id = jsonschema_dialect_id + + return dialect_id + + def _get_validator_class_for_dialect( + self, dialect_id: str + ) -> type[Validator] | None: + if dialect_id in self._validator_classes_by_dialect: + return self._validator_classes_by_dialect[dialect_id] + + validator_cls = cast( + type[Validator] | None, + validator_for( + {"$schema": dialect_id}, + default=cast(Any, None), + ), + ) + self._validator_classes_by_dialect[dialect_id] = validator_cls + return validator_cls + + def _get_default_jsonschema_dialect_id(self, schema: SchemaPath) -> str: + if self._default_jsonschema_dialect_id is not None: + return self._default_jsonschema_dialect_id + + spec_root = self._get_spec_root(schema) + dialect_id = (spec_root / "jsonSchemaDialect").read_str( + default=OAS31_BASE_DIALECT_URI + ) + + self._default_jsonschema_dialect_id = dialect_id + return dialect_id + + def _get_spec_root(self, schema: SchemaPath) -> SchemaPath: + # jsonschema-path currently has no public API for root traversal. + return schema._clone_with_parts(()) + + class SchemasValidator(KeywordValidator): @property def schema_validator(self) -> SchemaValidator: diff --git a/openapi_spec_validator/validation/validators.py b/openapi_spec_validator/validation/validators.py index 3487d2a..80a76ae 100644 --- a/openapi_spec_validator/validation/validators.py +++ b/openapi_spec_validator/validation/validators.py @@ -144,7 +144,7 @@ class OpenAPIV31SpecValidator(SpecValidator): "path": keywords.PathValidator, "response": keywords.OpenAPIV3ResponseValidator, "responses": keywords.ResponsesValidator, - "schema": keywords.SchemaValidator, + "schema": keywords.OpenAPIV31SchemaValidator, "schemas": keywords.SchemasValidator, } root_keywords = ["paths", "components"] diff --git a/poetry.lock b/poetry.lock index 85696f0..6f04526 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1045,19 +1045,20 @@ files = [ [[package]] name = "openapi-schema-validator" -version = "0.7.0" +version = "0.7.2" description = "OpenAPI schema validation for Python" optional = false python-versions = "<4.0.0,>=3.10.0" groups = ["main"] files = [ - {file = "openapi_schema_validator-0.7.0-py3-none-any.whl", hash = "sha256:dc3e91e97cbfa75c59272a25455d56ba5a9fb9ebcb7cb4be219a7a11543d0494"}, - {file = "openapi_schema_validator-0.7.0.tar.gz", hash = "sha256:003e93f61cfce036c1fbcf4f6d04f047332591e780100ba4a9fcd649458a09a8"}, + {file = "openapi_schema_validator-0.7.2-py3-none-any.whl", hash = "sha256:8f92a1442000f8e15beffdd33b59620523237d56a2f2e783c07e4f4c20d88fbd"}, + {file = "openapi_schema_validator-0.7.2.tar.gz", hash = "sha256:8515cafc62c3f6374c3d0517f1b0ea69650f07fd81d759238199eac2d26eef0c"}, ] [package.dependencies] jsonschema = ">=4.19.1,<5.0.0" jsonschema-specifications = ">=2024.10.1" +referencing = ">=0.37.0,<0.38.0" rfc3339-validator = "*" [[package]] @@ -1576,14 +1577,14 @@ files = [ [[package]] name = "referencing" -version = "0.36.2" +version = "0.37.0" description = "JSON Referencing + Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"}, - {file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"}, + {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, + {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, ] [package.dependencies] @@ -2221,4 +2222,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "f3ca9f61089b06c5ec79ae19f66a157a3ed967810735a632fcd153d85a5e7c72" +content-hash = "2351557df9ba38427c372cdc99fd542b2eb4233946c787ebed01f1d5cf3dffff" diff --git a/pyproject.toml b/pyproject.toml index e875954..d4edc80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] dependencies = [ "jsonschema >=4.24.0,<4.25.0", - "openapi-schema-validator >=0.7.0,<0.8.0", + "openapi-schema-validator >=0.7.2,<0.8.0", "jsonschema-path >=0.4.2,<0.5.0", "lazy-object-proxy >=1.7.1,<2.0", ] diff --git a/tests/integration/validation/test_dialect.py b/tests/integration/validation/test_dialect.py new file mode 100644 index 0000000..64dee33 --- /dev/null +++ b/tests/integration/validation/test_dialect.py @@ -0,0 +1,163 @@ +from openapi_spec_validator import OpenAPIV31SpecValidator +from openapi_spec_validator.validation import keywords as validation_keywords +from openapi_spec_validator.validation.exceptions import OpenAPIValidationError + + +def make_spec( + component_schema: dict[str, object] | bool, + json_schema_dialect: str | None = None, +) -> dict[str, object]: + spec: dict[str, object] = { + "openapi": "3.1.0", + "info": { + "title": "Test API", + "version": "0.0.1", + }, + "paths": {}, + "components": { + "schemas": { + "Component": component_schema, + }, + }, + } + if json_schema_dialect is not None: + spec["jsonSchemaDialect"] = json_schema_dialect + return spec + + +def test_root_json_schema_dialect_is_honored(): + spec = make_spec( + {"type": "object"}, + json_schema_dialect="https://json-schema.org/draft/2019-09/schema", + ) + + errors = list(OpenAPIV31SpecValidator(spec).iter_errors()) + assert errors == [] + + +def test_schema_dialect_overrides_root_json_schema_dialect(): + root_dialect = "https://json-schema.org/draft/2019-09/schema" + schema_dialect = "https://json-schema.org/draft/2020-12/schema" + spec = make_spec( + { + "$schema": schema_dialect, + "type": "object", + }, + json_schema_dialect=root_dialect, + ) + + errors = list(OpenAPIV31SpecValidator(spec).iter_errors()) + + assert errors == [] + + +def test_unknown_dialect_raises_error(): + spec = make_spec( + {"type": "object"}, + json_schema_dialect="https://example.com/custom", + ) + + errors = list(OpenAPIV31SpecValidator(spec).iter_errors()) + + assert len(errors) == 1 + assert isinstance(errors[0], OpenAPIValidationError) + assert "Unknown JSON Schema dialect" in errors[0].message + + +def test_meta_check_error_stops_further_schema_traversal(): + spec = make_spec( + { + "type": 1, + "required": ["missing_property"], + }, + json_schema_dialect="https://json-schema.org/draft/2020-12/schema", + ) + + errors = list(OpenAPIV31SpecValidator(spec).iter_errors()) + + assert len(errors) == 1 + assert "is not valid under any of the given schemas" in errors[0].message + + +def test_boolean_schema_uses_root_json_schema_dialect(): + spec = make_spec( + True, + json_schema_dialect="https://json-schema.org/draft/2019-09/schema", + ) + + errors = list(OpenAPIV31SpecValidator(spec).iter_errors()) + + assert errors == [] + + +def test_meta_schema_checker_cache_reuses_known_dialect(monkeypatch): + spec: dict[str, object] = { + "openapi": "3.1.0", + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "info": { + "title": "Test API", + "version": "0.0.1", + }, + "paths": {}, + "components": { + "schemas": { + "A": {"type": "object"}, + "B": {"type": "object"}, + }, + }, + } + + original_validator_for = validation_keywords.validator_for + calls = {"count": 0} + + def counting_validator_for(*args, **kwargs): + calls["count"] += 1 + return original_validator_for(*args, **kwargs) + + monkeypatch.setattr( + validation_keywords, + "validator_for", + counting_validator_for, + ) + + errors = list(OpenAPIV31SpecValidator(spec).iter_errors()) + + assert errors == [] + assert calls["count"] == 1 + + +def test_meta_schema_checker_cache_reuses_unknown_dialect(monkeypatch): + spec: dict[str, object] = { + "openapi": "3.1.0", + "jsonSchemaDialect": "https://example.com/custom", + "info": { + "title": "Test API", + "version": "0.0.1", + }, + "paths": {}, + "components": { + "schemas": { + "A": {"type": "object"}, + "B": {"type": "object"}, + }, + }, + } + + original_validator_for = validation_keywords.validator_for + calls = {"count": 0} + + def counting_validator_for(*args, **kwargs): + calls["count"] += 1 + return original_validator_for(*args, **kwargs) + + monkeypatch.setattr( + validation_keywords, + "validator_for", + counting_validator_for, + ) + + errors = list(OpenAPIV31SpecValidator(spec).iter_errors()) + + assert len(errors) == 2 + assert all("Unknown JSON Schema dialect" in err.message for err in errors) + assert calls["count"] == 1