From 32ad803cc1da9be752216a52c88a740bbeebdb2c Mon Sep 17 00:00:00 2001 From: p1c2u Date: Fri, 24 Apr 2026 23:27:20 +0100 Subject: [PATCH] Enforce properties required flag --- README.rst | 7 + docs/validation.rst | 7 + openapi_schema_validator/shortcuts.py | 12 ++ openapi_schema_validator/validators.py | 48 +++++++ tests/unit/test_shortcut.py | 188 +++++++++++++++++++++++++ 5 files changed, 262 insertions(+) diff --git a/README.rst b/README.rst index 1a45d9b..9e3b351 100644 --- a/README.rst +++ b/README.rst @@ -60,6 +60,7 @@ Usage cls=OAS32Validator, allow_remote_references=False, check_schema=True, + enforce_properties_required=False, **kwargs, ) @@ -94,6 +95,12 @@ accept jsonschema's default remote retrieval behavior. validating an instance. For trusted pre-validated schemas in hot paths, set ``check_schema=False`` to skip schema checking. +When ``enforce_properties_required=True`` is passed, all properties declared +in the schema's ``properties`` object are strictly required to be present in +the instance (except those marked as ``writeOnly`` or ``readOnly`` where +appropriate), regardless of the schema's ``required`` array. This is useful for +response or contract testing to ensure no documented fields are missing. + The ``validate`` helper keeps an internal compiled-validator cache. You can control cache size using the ``OPENAPI_SCHEMA_VALIDATOR_COMPILED_VALIDATOR_CACHE_MAX_SIZE`` environment variable diff --git a/docs/validation.rst b/docs/validation.rst index ea48705..c95164e 100644 --- a/docs/validation.rst +++ b/docs/validation.rst @@ -16,6 +16,7 @@ Validate cls=OAS32Validator, allow_remote_references=False, check_schema=True, + enforce_properties_required=False, **kwargs, ) @@ -39,6 +40,12 @@ jsonschema's default remote retrieval behavior. For trusted pre-validated schemas in hot paths, set ``check_schema=False`` to skip schema checking. +When ``enforce_properties_required=True`` is passed, all properties declared +in the schema's ``properties`` object are strictly required to be present in +the instance (except those marked as ``writeOnly`` or ``readOnly`` where +appropriate), regardless of the schema's ``required`` array. This is useful for +response or contract testing to ensure no documented fields are missing. + The shortcut keeps an internal compiled-validator cache. Use ``OPENAPI_SCHEMA_VALIDATOR_COMPILED_VALIDATOR_CACHE_MAX_SIZE`` to control cache capacity (default: ``128``). diff --git a/openapi_schema_validator/shortcuts.py b/openapi_schema_validator/shortcuts.py index 496f65b..103b16b 100644 --- a/openapi_schema_validator/shortcuts.py +++ b/openapi_schema_validator/shortcuts.py @@ -12,6 +12,9 @@ from openapi_schema_validator._dialects import OAS31_BASE_DIALECT_ID from openapi_schema_validator._dialects import OAS32_BASE_DIALECT_ID 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 _LOCAL_ONLY_REGISTRY = Registry() @@ -42,6 +45,7 @@ def validate( *args: Any, allow_remote_references: bool = False, check_schema: bool = True, + enforce_properties_required: bool = False, **kwargs: Any, ) -> None: """ @@ -65,6 +69,11 @@ def validate( check_schema: If ``True`` (default), validate the provided schema before validating ``instance``. If ``False``, skip schema validation and run instance validation directly. + enforce_properties_required: If ``True``, all properties declared in + the schema's ``properties`` object are strictly required to be + present in the instance (except those marked as ``writeOnly`` or + ``readOnly`` where appropriate), regardless of the schema's + ``required`` array. Defaults to ``False``. **kwargs: Keyword arguments forwarded to ``cls`` constructor (for example ``registry`` and ``format_checker``). If omitted, a local-only empty ``Registry`` is used to avoid implicit remote @@ -74,6 +83,9 @@ def validate( jsonschema.exceptions.SchemaError: If ``schema`` is invalid. jsonschema.exceptions.ValidationError: If ``instance`` is invalid. """ + if enforce_properties_required: + cls = build_enforce_properties_required_validator(cls) # type: ignore[arg-type] + schema_dict = cast(dict[str, Any], schema) validator_kwargs = kwargs.copy() diff --git a/openapi_schema_validator/validators.py b/openapi_schema_validator/validators.py index 2dad167..907b803 100644 --- a/openapi_schema_validator/validators.py +++ b/openapi_schema_validator/validators.py @@ -1,10 +1,14 @@ +from functools import lru_cache from typing import Any +from typing import Iterator +from typing import Mapping from typing import cast from jsonschema import _keywords from jsonschema import _legacy_keywords from jsonschema.exceptions import SchemaError from jsonschema.exceptions import ValidationError +from jsonschema.protocols import Validator from jsonschema.validators import Draft202012Validator from jsonschema.validators import create from jsonschema.validators import extend @@ -187,3 +191,47 @@ def _build_oas32_validator() -> Any: OAS30Validator.check_schema = classmethod(check_openapi_schema) OAS31Validator.check_schema = classmethod(check_openapi_schema) OAS32Validator.check_schema = classmethod(check_openapi_schema) + + +@lru_cache(maxsize=None) +def build_enforce_properties_required_validator( + validator_class: Any, +) -> type[Validator]: + properties_validator = validator_class.VALIDATORS.get("properties") + required_validator = validator_class.VALIDATORS.get("required") + + def enforce_properties( + validator: Any, + properties: Any, + instance: Any, + schema: Mapping[str, Any], + ) -> Iterator[Any]: + if properties_validator is not None: + yield from properties_validator( + validator, properties, instance, schema + ) + + if not validator.is_type(instance, "object"): + return + + if required_validator is not None: + schema_required = ( + schema.get("required", []) if isinstance(schema, dict) else [] + ) + missing_props = [ + p for p in properties.keys() if p not in schema_required + ] + if missing_props: + yield from required_validator( + validator, missing_props, instance, schema + ) + + extended_validator = extend( + validator_class, + validators={"properties": enforce_properties}, + ) + if hasattr(validator_class, "check_schema"): + extended_validator.check_schema = classmethod( + validator_class.check_schema.__func__ + ) + return cast(type[Validator], extended_validator) diff --git a/tests/unit/test_shortcut.py b/tests/unit/test_shortcut.py index c37acc2..6d0bf57 100644 --- a/tests/unit/test_shortcut.py +++ b/tests/unit/test_shortcut.py @@ -13,6 +13,11 @@ from openapi_schema_validator._regex import has_ecma_regex 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 OAS30Validator +from openapi_schema_validator.validators import OAS30WriteValidator +from openapi_schema_validator.validators import OAS31Validator +from openapi_schema_validator.validators import OAS32Validator @pytest.fixture(scope="function") @@ -195,3 +200,186 @@ def test_validate_cache_max_size_from_env(monkeypatch): validate("foo", schema_a, cls=OAS32Validator) assert check_schema_mock.call_count == 3 + + +@pytest.mark.parametrize( + "schema, cls, instance, enforce, expected_error", + [ + ( + { + "type": "object", + "properties": { + "id": {"type": "string"}, + "nickname": {"type": "string"}, + }, + "required": ["id"], + }, + OAS30Validator, + {"id": "42"}, + False, + None, + ), + ( + { + "type": "object", + "properties": { + "id": {"type": "string"}, + "nickname": {"type": "string"}, + }, + "required": ["id"], + }, + OAS30Validator, + {"id": "42"}, + True, + "'nickname' is a required property", + ), + ( + { + "type": "object", + "properties": { + "id": {"type": "string", "readOnly": True}, + "password": {"type": "string", "writeOnly": True}, + "normal": {"type": "string"}, + }, + }, + OAS30ReadValidator, + {"id": "123"}, + True, + "'normal' is a required property", + ), + ( + { + "type": "object", + "properties": { + "id": {"type": "string", "readOnly": True}, + "password": {"type": "string", "writeOnly": True}, + "normal": {"type": "string"}, + }, + }, + OAS30ReadValidator, + {"normal": "abc"}, + True, + "'id' is a required property", + ), + ( + { + "type": "object", + "properties": { + "id": {"type": "string", "readOnly": True}, + "password": {"type": "string", "writeOnly": True}, + "normal": {"type": "string"}, + }, + }, + OAS30ReadValidator, + {"id": "123", "normal": "abc"}, + True, + None, + ), + ( + { + "type": "object", + "properties": { + "id": {"type": "string", "readOnly": True}, + "password": {"type": "string", "writeOnly": True}, + "normal": {"type": "string"}, + }, + }, + OAS30WriteValidator, + {"normal": "abc"}, + True, + "'password' is a required property", + ), + ( + { + "type": "object", + "properties": { + "id": {"type": "string", "readOnly": True}, + "password": {"type": "string", "writeOnly": True}, + "normal": {"type": "string"}, + }, + }, + OAS30WriteValidator, + {"password": "secret"}, + True, + "'normal' is a required property", + ), + ( + { + "type": "object", + "properties": { + "id": {"type": "string", "readOnly": True}, + "password": {"type": "string", "writeOnly": True}, + "normal": {"type": "string"}, + }, + }, + OAS30WriteValidator, + {"password": "secret", "normal": "abc"}, + True, + None, + ), + ( + { + "type": "object", + "properties": { + "foo": True, + }, + }, + OAS31Validator, + {}, + False, + None, + ), + ( + { + "type": "object", + "properties": { + "foo": True, + }, + }, + OAS31Validator, + {}, + True, + "'foo' is a required property", + ), + ( + { + "type": "object", + "properties": { + "foo": {"type": "string"}, + }, + }, + OAS32Validator, + {}, + False, + None, + ), + ( + { + "type": "object", + "properties": { + "foo": {"type": "string"}, + }, + }, + OAS32Validator, + {}, + True, + "'foo' is a required property", + ), + ], +) +def test_enforce_properties_required( + schema, cls, instance, enforce, expected_error +): + if expected_error: + with pytest.raises(ValidationError) as exc: + validate( + instance, + schema, + cls=cls, + enforce_properties_required=enforce, + ) + assert expected_error in str(exc.value) + else: + validate( + instance, schema, cls=cls, enforce_properties_required=enforce + )