From 4542ee7bc164d4180cfef6e16425de87880fa039 Mon Sep 17 00:00:00 2001 From: p1c2u Date: Wed, 25 Feb 2026 10:40:10 +0000 Subject: [PATCH 1/3] Add OAS 3.1 jsonSchemaDialect-aware schema meta-validation --- openapi_spec_validator/validation/keywords.py | 95 ++++++++++++++++--- .../validation/validators.py | 2 +- poetry.lock | 17 ++-- pyproject.toml | 2 +- tests/integration/validation/test_dialect.py | 89 +++++++++++++++++ 5 files changed, 184 insertions(+), 21 deletions(-) create mode 100644 tests/integration/validation/test_dialect.py diff --git a/openapi_spec_validator/validation/keywords.py b/openapi_spec_validator/validation/keywords.py index df52f34..37a0ccd 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,54 @@ def __call__( yield from self.default_validator(schema, default_value) +class OpenAPIV31SchemaValidator(SchemaValidator): + default_jsonschema_dialect_id = OAS31_BASE_DIALECT_URI + + def _get_schema_checker( + self, schema: SchemaPath, schema_value: Any + ) -> Callable[[Any], None]: + if isinstance(schema_value, Mapping): + schema_to_check = dict(schema_value) + if "$schema" in schema_to_check: + dialect_source = schema_to_check + else: + jsonschema_dialect_id = self._get_jsonschema_dialect_id(schema) + dialect_source = {"$schema": jsonschema_dialect_id} + schema_to_check = { + **schema_to_check, + "$schema": jsonschema_dialect_id, + } + else: + jsonschema_dialect_id = self._get_jsonschema_dialect_id(schema) + dialect_source = {"$schema": jsonschema_dialect_id} + schema_to_check = schema_value + + validator_cls = validator_for( + dialect_source, + default=cast(Any, None), + ) + if validator_cls is None: + raise ValueError( + f"Unknown JSON Schema dialect: {dialect_source['$schema']!r}" + ) + return partial( + check_openapi_schema, + validator_cls, + format_checker=oas31_format_checker, + ) + + def _get_jsonschema_dialect_id(self, schema: SchemaPath) -> str: + schema_root = self._get_schema_root(schema) + try: + return (schema_root // "jsonSchemaDialect").read_str() + except KeyError: + return self.default_jsonschema_dialect_id + + def _get_schema_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..3e15934 --- /dev/null +++ b/tests/integration/validation/test_dialect.py @@ -0,0 +1,89 @@ +from openapi_spec_validator import OpenAPIV31SpecValidator +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 == [] From eedd01789ae29d17266522922ab9f35b25fe46f1 Mon Sep 17 00:00:00 2001 From: p1c2u Date: Wed, 25 Feb 2026 11:11:52 +0000 Subject: [PATCH 2/3] Schema dialect validator classes cache --- openapi_spec_validator/validation/keywords.py | 63 ++++++++++++---- tests/integration/validation/test_dialect.py | 74 +++++++++++++++++++ 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/openapi_spec_validator/validation/keywords.py b/openapi_spec_validator/validation/keywords.py index 37a0ccd..e3176bd 100644 --- a/openapi_spec_validator/validation/keywords.py +++ b/openapi_spec_validator/validation/keywords.py @@ -246,38 +246,71 @@ def __call__( class OpenAPIV31SchemaValidator(SchemaValidator): default_jsonschema_dialect_id = OAS31_BASE_DIALECT_URI + def __init__(self, registry: "KeywordValidatorRegistry"): + super().__init__(registry) + 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_source = 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_jsonschema_dialect_id(schema) - dialect_source = {"$schema": jsonschema_dialect_id} schema_to_check = { **schema_to_check, "$schema": jsonschema_dialect_id, } + dialect_id = jsonschema_dialect_id else: jsonschema_dialect_id = self._get_jsonschema_dialect_id(schema) - dialect_source = {"$schema": jsonschema_dialect_id} schema_to_check = schema_value + dialect_id = jsonschema_dialect_id - validator_cls = validator_for( - dialect_source, - default=cast(Any, None), - ) - if validator_cls is None: - raise ValueError( - f"Unknown JSON Schema dialect: {dialect_source['$schema']!r}" - ) - return partial( - check_openapi_schema, - validator_cls, - format_checker=oas31_format_checker, + 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_jsonschema_dialect_id(self, schema: SchemaPath) -> str: schema_root = self._get_schema_root(schema) diff --git a/tests/integration/validation/test_dialect.py b/tests/integration/validation/test_dialect.py index 3e15934..64dee33 100644 --- a/tests/integration/validation/test_dialect.py +++ b/tests/integration/validation/test_dialect.py @@ -1,4 +1,5 @@ from openapi_spec_validator import OpenAPIV31SpecValidator +from openapi_spec_validator.validation import keywords as validation_keywords from openapi_spec_validator.validation.exceptions import OpenAPIValidationError @@ -87,3 +88,76 @@ def test_boolean_schema_uses_root_json_schema_dialect(): 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 From 68ed0ae38eb240f0b576e76bf47fbce988670861 Mon Sep 17 00:00:00 2001 From: p1c2u Date: Wed, 25 Feb 2026 11:22:11 +0000 Subject: [PATCH 3/3] Spec document jsonschema dialect cache --- openapi_spec_validator/validation/keywords.py | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/openapi_spec_validator/validation/keywords.py b/openapi_spec_validator/validation/keywords.py index e3176bd..4678f62 100644 --- a/openapi_spec_validator/validation/keywords.py +++ b/openapi_spec_validator/validation/keywords.py @@ -244,10 +244,9 @@ def __call__( class OpenAPIV31SchemaValidator(SchemaValidator): - default_jsonschema_dialect_id = OAS31_BASE_DIALECT_URI - 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 ] = {} @@ -283,14 +282,18 @@ def _get_schema_dialect_id( ) dialect_id = dialect_value else: - jsonschema_dialect_id = self._get_jsonschema_dialect_id(schema) + 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_jsonschema_dialect_id(schema) + jsonschema_dialect_id = self._get_default_jsonschema_dialect_id( + schema + ) schema_to_check = schema_value dialect_id = jsonschema_dialect_id @@ -312,14 +315,19 @@ def _get_validator_class_for_dialect( self._validator_classes_by_dialect[dialect_id] = validator_cls return validator_cls - def _get_jsonschema_dialect_id(self, schema: SchemaPath) -> str: - schema_root = self._get_schema_root(schema) - try: - return (schema_root // "jsonSchemaDialect").read_str() - except KeyError: - return self.default_jsonschema_dialect_id + 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_schema_root(self, schema: SchemaPath) -> SchemaPath: + def _get_spec_root(self, schema: SchemaPath) -> SchemaPath: # jsonschema-path currently has no public API for root traversal. return schema._clone_with_parts(())