diff --git a/README.rst b/README.rst index 71054b7..58e09b7 100644 --- a/README.rst +++ b/README.rst @@ -100,6 +100,26 @@ To validate an OpenAPI v3.1 schema: By default, the latest OpenAPI schema syntax is expected. +The OpenAPI 3.1 base dialect URI is registered for +``jsonschema.validators.validator_for`` resolution. +Schemas declaring +``"$schema": "https://spec.openapis.org/oas/3.1/dialect/base"`` +resolve directly to ``OAS31Validator`` without unresolved-metaschema +fallback warnings. + +.. code-block:: python + + from jsonschema.validators import validator_for + + from openapi_schema_validator import OAS31Validator + + schema = { + "$schema": "https://spec.openapis.org/oas/3.1/dialect/base", + "type": "object", + } + + assert validator_for(schema) is OAS31Validator + Strict vs Pragmatic Validators ============================== diff --git a/docs/validation.rst b/docs/validation.rst index beddc18..3c81323 100644 --- a/docs/validation.rst +++ b/docs/validation.rst @@ -67,6 +67,25 @@ if you want to disambiguate the expected schema version, import and use ``OAS31V validate({"name": "John", "age": 23}, schema, cls=OAS31Validator) +The OpenAPI 3.1 base dialect URI is registered for +``jsonschema.validators.validator_for`` resolution. +If your schema declares +``"$schema": "https://spec.openapis.org/oas/3.1/dialect/base"``, +``validator_for`` resolves directly to ``OAS31Validator`` without +unresolved-metaschema fallback warnings. + +.. code-block:: python + + from jsonschema.validators import validator_for + + from openapi_schema_validator import OAS31Validator + + schema = { + "$schema": "https://spec.openapis.org/oas/3.1/dialect/base", + "type": "object", + } + assert validator_for(schema) is OAS31Validator + For OpenAPI 3.2, use ``OAS32Validator`` (behaves identically to ``OAS31Validator``, since 3.2 uses the same JSON Schema dialect). In order to validate OpenAPI 3.0 schema, import and use ``OAS30Validator`` instead of ``OAS31Validator``. @@ -193,7 +212,8 @@ Example usage: .. code-block:: python - from openapi_schema_validator import OAS30Validator, OAS30StrictValidator + from openapi_schema_validator import OAS30StrictValidator + from openapi_schema_validator import OAS30Validator # Pragmatic (default) - accepts bytes for binary format validator = OAS30Validator({"type": "string", "format": "binary"}) diff --git a/openapi_schema_validator/_dialects.py b/openapi_schema_validator/_dialects.py new file mode 100644 index 0000000..836724c --- /dev/null +++ b/openapi_schema_validator/_dialects.py @@ -0,0 +1,41 @@ +from typing import Any + +from jsonschema.validators import validates + +from openapi_schema_validator._specifications import ( + REGISTRY as OPENAPI_SPECIFICATIONS, +) + +__all__ = [ + "OAS31_BASE_DIALECT_ID", + "OAS31_BASE_DIALECT_METASCHEMA", + "register_openapi_dialect", +] + +OAS31_BASE_DIALECT_ID = "https://spec.openapis.org/oas/3.1/dialect/base" +OAS31_BASE_DIALECT_METASCHEMA = OPENAPI_SPECIFICATIONS.contents( + OAS31_BASE_DIALECT_ID, +) + +_REGISTERED_VALIDATORS: dict[tuple[str, str], Any] = {} + + +def register_openapi_dialect( + *, + validator: Any, + dialect_id: str, + version_name: str, + metaschema: Any, +) -> Any: + key = (dialect_id, version_name) + registered_validator = _REGISTERED_VALIDATORS.get(key) + + if registered_validator is validator: + return validator + if registered_validator is not None: + return registered_validator + + validator.META_SCHEMA = metaschema + validator = validates(version_name)(validator) + _REGISTERED_VALIDATORS[key] = validator + return validator diff --git a/openapi_schema_validator/_specifications.py b/openapi_schema_validator/_specifications.py new file mode 100644 index 0000000..319192c --- /dev/null +++ b/openapi_schema_validator/_specifications.py @@ -0,0 +1,35 @@ +import json +from importlib.resources import files +from typing import Any +from typing import Iterator + +from jsonschema_specifications import REGISTRY as JSONSCHEMA_REGISTRY +from referencing import Resource + +__all__ = ["REGISTRY"] + + +def _iter_schema_files() -> Iterator[Any]: + schema_root = files(__package__).joinpath("schemas") + stack = [schema_root] + + while stack: + current = stack.pop() + for child in current.iterdir(): + if child.name.startswith("."): + continue + if child.is_dir(): + stack.append(child) + continue + yield child + + +def _load_schemas() -> Iterator[Resource]: + for path in _iter_schema_files(): + contents = json.loads(path.read_text(encoding="utf-8")) + yield Resource.from_contents(contents) + + +#: A `referencing.Registry` containing all official jsonschema resources +#: plus openapi resources. +REGISTRY = (_load_schemas() @ JSONSCHEMA_REGISTRY).crawl() diff --git a/openapi_schema_validator/schemas/oas3.1/metaschema.json b/openapi_schema_validator/schemas/oas3.1/metaschema.json new file mode 100644 index 0000000..eae8386 --- /dev/null +++ b/openapi_schema_validator/schemas/oas3.1/metaschema.json @@ -0,0 +1,25 @@ +{ + "$id": "https://spec.openapis.org/oas/3.1/dialect/base", + "$schema": "https://json-schema.org/draft/2020-12/schema", + + "title": "OpenAPI 3.1 Schema Object Dialect", + "description": "A JSON Schema dialect describing schemas found in OpenAPI documents", + + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "https://spec.openapis.org/oas/3.1/vocab/base": false + }, + + "$dynamicAnchor": "meta", + + "allOf": [ + { "$ref": "https://json-schema.org/draft/2020-12/schema" }, + { "$ref": "https://spec.openapis.org/oas/3.1/meta/base" } + ] +} diff --git a/openapi_schema_validator/schemas/oas3.1/vocabularies/base b/openapi_schema_validator/schemas/oas3.1/vocabularies/base new file mode 100644 index 0000000..a7a59f1 --- /dev/null +++ b/openapi_schema_validator/schemas/oas3.1/vocabularies/base @@ -0,0 +1,87 @@ +{ + "$id": "https://spec.openapis.org/oas/3.1/meta/base", + "$schema": "https://json-schema.org/draft/2020-12/schema", + + "title": "OAS Base vocabulary", + "description": "A JSON Schema Vocabulary used in the OpenAPI Schema Dialect", + + "$vocabulary": { + "https://spec.openapis.org/oas/3.1/vocab/base": true + }, + + "$dynamicAnchor": "meta", + + "type": ["object", "boolean"], + "properties": { + "example": true, + "discriminator": { "$ref": "#/$defs/discriminator" }, + "externalDocs": { "$ref": "#/$defs/external-docs" }, + "xml": { "$ref": "#/$defs/xml" } + }, + + "$defs": { + "extensible": { + "patternProperties": { + "^x-": true + } + }, + + "discriminator": { + "$ref": "#/$defs/extensible", + "type": "object", + "properties": { + "propertyName": { + "type": "string" + }, + "mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["propertyName"], + "unevaluatedProperties": false + }, + + "external-docs": { + "$ref": "#/$defs/extensible", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri-reference" + }, + "description": { + "type": "string" + } + }, + "required": ["url"], + "unevaluatedProperties": false + }, + + "xml": { + "$ref": "#/$defs/extensible", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string", + "format": "uri" + }, + "prefix": { + "type": "string" + }, + "attribute": { + "type": "boolean" + }, + "wrapped": { + "type": "boolean" + } + }, + "unevaluatedProperties": false + } + } +} diff --git a/openapi_schema_validator/shortcuts.py b/openapi_schema_validator/shortcuts.py index b9d60aa..ad86277 100644 --- a/openapi_schema_validator/shortcuts.py +++ b/openapi_schema_validator/shortcuts.py @@ -5,7 +5,9 @@ from jsonschema.exceptions import best_match from jsonschema.protocols import Validator +from openapi_schema_validator._dialects import OAS31_BASE_DIALECT_ID from openapi_schema_validator.validators import OAS31Validator +from openapi_schema_validator.validators import check_openapi_schema def validate( @@ -19,7 +21,19 @@ def validate( Validate an instance against a given schema using the specified validator class. """ schema_dict = cast(dict[str, Any], schema) - cls.check_schema(schema_dict) + + meta_schema = getattr(cls, "META_SCHEMA", None) + # jsonschema's default check_schema path does not accept a custom + # registry, so for the OAS 3.1 dialect we use the package registry + # explicitly to keep metaschema resolution local and deterministic. + if ( + isinstance(meta_schema, dict) + and meta_schema.get("$id") == OAS31_BASE_DIALECT_ID + ): + check_openapi_schema(cls, schema_dict) + else: + cls.check_schema(schema_dict) + validator = cls(schema_dict, *args, **kwargs) error = best_match( validator.evolve(schema=schema_dict).iter_errors(instance) diff --git a/openapi_schema_validator/validators.py b/openapi_schema_validator/validators.py index a46f375..d90d229 100644 --- a/openapi_schema_validator/validators.py +++ b/openapi_schema_validator/validators.py @@ -3,17 +3,46 @@ from jsonschema import _keywords from jsonschema import _legacy_keywords +from jsonschema.exceptions import SchemaError from jsonschema.exceptions import ValidationError from jsonschema.validators import Draft202012Validator from jsonschema.validators import create from jsonschema.validators import extend -from jsonschema_specifications import REGISTRY as SPECIFICATIONS +from jsonschema.validators import validator_for from openapi_schema_validator import _format as oas_format from openapi_schema_validator import _keywords as oas_keywords from openapi_schema_validator import _types as oas_types +from openapi_schema_validator._dialects import OAS31_BASE_DIALECT_ID +from openapi_schema_validator._dialects import OAS31_BASE_DIALECT_METASCHEMA +from openapi_schema_validator._dialects import register_openapi_dialect +from openapi_schema_validator._specifications import ( + REGISTRY as OPENAPI_SPECIFICATIONS, +) from openapi_schema_validator._types import oas31_type_checker +_CHECK_SCHEMA_UNSET = object() + + +def check_openapi_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_class = validator_for(cls.META_SCHEMA, default=cls) + + validator_for_metaschema = validator_class( + cls.META_SCHEMA, + format_checker=format_checker, + registry=OPENAPI_SPECIFICATIONS, + ) + + for error in validator_for_metaschema.iter_errors(schema): + raise SchemaError.create_from(error) + def _oas30_id_of(schema: Any) -> str: if isinstance(schema, dict): @@ -63,19 +92,49 @@ def _oas30_id_of(schema: Any) -> str: }, ) -OAS30Validator = create( - meta_schema=SPECIFICATIONS.contents( - "http://json-schema.org/draft-04/schema#", - ), - validators=OAS30_VALIDATORS, - type_checker=oas_types.oas30_type_checker, - format_checker=oas_format.oas30_format_checker, - # NOTE: version causes conflict with global jsonschema validator - # See https://github.com/python-openapi/openapi-schema-validator/pull/12 - # version="oas30", - id_of=_oas30_id_of, -) +def _build_oas30_validator() -> Any: + return create( + meta_schema=OPENAPI_SPECIFICATIONS.contents( + "http://json-schema.org/draft-04/schema#", + ), + validators=OAS30_VALIDATORS, + type_checker=oas_types.oas30_type_checker, + format_checker=oas_format.oas30_format_checker, + # NOTE: version causes conflict with global jsonschema validator + # See https://github.com/python-openapi/openapi-schema-validator/pull/12 + # version="oas30", + id_of=_oas30_id_of, + ) + + +def _build_oas31_validator() -> Any: + validator = extend( + Draft202012Validator, + { + # adjusted to OAS + "allOf": oas_keywords.allOf, + "oneOf": oas_keywords.oneOf, + "anyOf": oas_keywords.anyOf, + "description": oas_keywords.not_implemented, + # fixed OAS fields + "discriminator": oas_keywords.not_implemented, + "xml": oas_keywords.not_implemented, + "externalDocs": oas_keywords.not_implemented, + "example": oas_keywords.not_implemented, + }, + type_checker=oas31_type_checker, + format_checker=oas_format.oas31_format_checker, + ) + return register_openapi_dialect( + validator=validator, + dialect_id=OAS31_BASE_DIALECT_ID, + version_name="oas31", + metaschema=OAS31_BASE_DIALECT_METASCHEMA, + ) + + +OAS30Validator = _build_oas30_validator() OAS30StrictValidator = extend( OAS30Validator, validators={ @@ -87,7 +146,6 @@ def _oas30_id_of(schema: Any) -> str: # See https://github.com/python-openapi/openapi-schema-validator/pull/12 # version="oas30-strict", ) - OAS30ReadValidator = extend( OAS30Validator, validators={ @@ -95,7 +153,6 @@ def _oas30_id_of(schema: Any) -> str: "writeOnly": oas_keywords.read_writeOnly, }, ) - OAS30WriteValidator = extend( OAS30Validator, validators={ @@ -104,23 +161,7 @@ def _oas30_id_of(schema: Any) -> str: }, ) -OAS31Validator = extend( - Draft202012Validator, - { - # adjusted to OAS - "allOf": oas_keywords.allOf, - "oneOf": oas_keywords.oneOf, - "anyOf": oas_keywords.anyOf, - "description": oas_keywords.not_implemented, - # fixed OAS fields - "discriminator": oas_keywords.not_implemented, - "xml": oas_keywords.not_implemented, - "externalDocs": oas_keywords.not_implemented, - "example": oas_keywords.not_implemented, - }, - type_checker=oas31_type_checker, - format_checker=oas_format.oas31_format_checker, -) +OAS31Validator = _build_oas31_validator() # OAS 3.2 uses JSON Schema Draft 2020-12 as its base dialect, same as # OAS 3.1. The OAS-specific vocabulary differs slightly (e.g. xml keyword diff --git a/poetry.lock b/poetry.lock index f831642..c811dfd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "alabaster" @@ -2072,4 +2072,4 @@ docs = [] [metadata] lock-version = "2.1" python-versions = "^3.10.0" -content-hash = "f2147bc3dd2893e3d4cacbe807117cb963544490dc1ea5c2995b109421da694f" +content-hash = "09c27eb4b3e29b9e0ec20db1cab6922cb73e91eeac1049b29e1b65a6a5ca8359" diff --git a/pyproject.toml b/pyproject.toml index 9667879..497e32e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,7 @@ python = "^3.10.0" jsonschema = "^4.19.1" rfc3339-validator = "*" # requred by jsonschema for date-time checker jsonschema-specifications = ">=2024.10.1" +referencing = "^0.37.0" [tool.poetry.extras] docs = ["sphinx", "sphinx-immaterial"] diff --git a/tests/integration/test_validators.py b/tests/integration/test_validators.py index 90c6fa4..8b39f9c 100644 --- a/tests/integration/test_validators.py +++ b/tests/integration/test_validators.py @@ -1,3 +1,4 @@ +import warnings from base64 import b64encode from typing import Any from typing import cast @@ -7,6 +8,9 @@ from jsonschema.exceptions import ( _WrappedReferencingError as WrappedReferencingError, ) +from jsonschema.validators import Draft202012Validator +from jsonschema.validators import extend +from jsonschema.validators import validator_for from referencing import Registry from referencing import Resource from referencing.exceptions import InvalidAnchor @@ -24,6 +28,9 @@ from openapi_schema_validator import oas30_strict_format_checker from openapi_schema_validator import oas31_format_checker from openapi_schema_validator import oas32_format_checker +from openapi_schema_validator._dialects import OAS31_BASE_DIALECT_METASCHEMA +from openapi_schema_validator._dialects import register_openapi_dialect +from openapi_schema_validator.validators import OAS31_BASE_DIALECT_ID class TestOAS30ValidatorFormatChecker: @@ -1113,3 +1120,84 @@ def test_strict_binary_format_rejects_str(self): # Note: "test" is actually valid base64, so use "not base64" which is not with pytest.raises(ValidationError, match="is not a 'binary'"): validator.validate("not base64") + + +class TestValidatorForDiscovery: + def test_oas31_base_dialect_resolves_to_oas31_validator(self): + schema = {"$schema": OAS31_BASE_DIALECT_ID} + + validator_class = validator_for(schema) + + assert validator_class is OAS31Validator + + def test_oas31_base_dialect_discovery_has_no_deprecation_warning(self): + schema = {"$schema": OAS31_BASE_DIALECT_ID} + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + validator_for(schema) + + assert not any( + issubclass(warning.category, DeprecationWarning) + for warning in caught + ) + + def test_oas31_base_dialect_keeps_oas_keyword_behavior(self): + schema = { + "$schema": OAS31_BASE_DIALECT_ID, + "type": "object", + "required": ["kind"], + "properties": {"kind": {"type": "string"}}, + "discriminator": {"propertyName": "kind"}, + "xml": {"name": "Pet"}, + "example": {"kind": "cat"}, + } + + validator_class = validator_for(schema) + validator = validator_class( + schema, format_checker=oas31_format_checker + ) + + result = validator.validate({"kind": "cat"}) + + assert result is None + + def test_draft_2020_12_discovery_is_unchanged(self): + schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"} + + validator_class = validator_for(schema) + + assert validator_class is Draft202012Validator + + def test_openapi_dialect_registration_is_idempotent(self): + register_openapi_dialect( + validator=OAS31Validator, + dialect_id=OAS31_BASE_DIALECT_ID, + version_name="oas31", + metaschema=OAS31_BASE_DIALECT_METASCHEMA, + ) + register_openapi_dialect( + validator=OAS31Validator, + dialect_id=OAS31_BASE_DIALECT_ID, + version_name="oas31", + metaschema=OAS31_BASE_DIALECT_METASCHEMA, + ) + + validator_class = validator_for({"$schema": OAS31_BASE_DIALECT_ID}) + + assert validator_class is OAS31Validator + + def test_openapi_dialect_registration_does_not_replace_validator(self): + another_oas31_validator = extend(OAS31Validator, {}) + + registered_validator = register_openapi_dialect( + validator=another_oas31_validator, + dialect_id=OAS31_BASE_DIALECT_ID, + version_name="oas31", + metaschema=OAS31_BASE_DIALECT_METASCHEMA, + ) + + assert registered_validator is OAS31Validator + assert ( + validator_for({"$schema": OAS31_BASE_DIALECT_ID}) is OAS31Validator + ) diff --git a/tests/unit/test_shortcut.py b/tests/unit/test_shortcut.py index 26eca30..95cafe4 100644 --- a/tests/unit/test_shortcut.py +++ b/tests/unit/test_shortcut.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import pytest from openapi_schema_validator import validate @@ -32,3 +34,10 @@ def test_validate_does_not_mutate_schema(schema): original_schema = schema.copy() validate({"email": "foo@bar.com"}, schema) assert schema == original_schema + + +def test_validate_does_not_fetch_remote_metaschemas(schema): + with patch("urllib.request.urlopen") as urlopen: + validate({"email": "foo@bar.com"}, schema) + + urlopen.assert_not_called()