Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Usage
cls=OAS32Validator,
allow_remote_references=False,
check_schema=True,
enforce_properties_required=False,
**kwargs,
)

Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/validation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Validate
cls=OAS32Validator,
allow_remote_references=False,
check_schema=True,
enforce_properties_required=False,
**kwargs,
)

Expand All @@ -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``).
Expand Down
12 changes: 12 additions & 0 deletions openapi_schema_validator/shortcuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
"""
Expand All @@ -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
Expand All @@ -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()
Expand Down
48 changes: 48 additions & 0 deletions openapi_schema_validator/validators.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
188 changes: 188 additions & 0 deletions tests/unit/test_shortcut.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
)
Loading