Skip to content

Commit 32ad803

Browse files
committed
Enforce properties required flag
1 parent 0508964 commit 32ad803

5 files changed

Lines changed: 262 additions & 0 deletions

File tree

README.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Usage
6060
cls=OAS32Validator,
6161
allow_remote_references=False,
6262
check_schema=True,
63+
enforce_properties_required=False,
6364
**kwargs,
6465
)
6566
@@ -94,6 +95,12 @@ accept jsonschema's default remote retrieval behavior.
9495
validating an instance. For trusted pre-validated schemas in hot paths, set
9596
``check_schema=False`` to skip schema checking.
9697

98+
When ``enforce_properties_required=True`` is passed, all properties declared
99+
in the schema's ``properties`` object are strictly required to be present in
100+
the instance (except those marked as ``writeOnly`` or ``readOnly`` where
101+
appropriate), regardless of the schema's ``required`` array. This is useful for
102+
response or contract testing to ensure no documented fields are missing.
103+
97104
The ``validate`` helper keeps an internal compiled-validator cache. You can
98105
control cache size using the
99106
``OPENAPI_SCHEMA_VALIDATOR_COMPILED_VALIDATOR_CACHE_MAX_SIZE`` environment variable

docs/validation.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Validate
1616
cls=OAS32Validator,
1717
allow_remote_references=False,
1818
check_schema=True,
19+
enforce_properties_required=False,
1920
**kwargs,
2021
)
2122
@@ -39,6 +40,12 @@ jsonschema's default remote retrieval behavior.
3940
For trusted pre-validated schemas in hot paths, set ``check_schema=False`` to
4041
skip schema checking.
4142

43+
When ``enforce_properties_required=True`` is passed, all properties declared
44+
in the schema's ``properties`` object are strictly required to be present in
45+
the instance (except those marked as ``writeOnly`` or ``readOnly`` where
46+
appropriate), regardless of the schema's ``required`` array. This is useful for
47+
response or contract testing to ensure no documented fields are missing.
48+
4249
The shortcut keeps an internal compiled-validator cache.
4350
Use ``OPENAPI_SCHEMA_VALIDATOR_COMPILED_VALIDATOR_CACHE_MAX_SIZE`` to control cache
4451
capacity (default: ``128``).

openapi_schema_validator/shortcuts.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
from openapi_schema_validator._dialects import OAS31_BASE_DIALECT_ID
1313
from openapi_schema_validator._dialects import OAS32_BASE_DIALECT_ID
1414
from openapi_schema_validator.validators import OAS32Validator
15+
from openapi_schema_validator.validators import (
16+
build_enforce_properties_required_validator,
17+
)
1518
from openapi_schema_validator.validators import check_openapi_schema
1619

1720
_LOCAL_ONLY_REGISTRY = Registry()
@@ -42,6 +45,7 @@ def validate(
4245
*args: Any,
4346
allow_remote_references: bool = False,
4447
check_schema: bool = True,
48+
enforce_properties_required: bool = False,
4549
**kwargs: Any,
4650
) -> None:
4751
"""
@@ -65,6 +69,11 @@ def validate(
6569
check_schema: If ``True`` (default), validate the provided schema
6670
before validating ``instance``. If ``False``, skip schema
6771
validation and run instance validation directly.
72+
enforce_properties_required: If ``True``, all properties declared in
73+
the schema's ``properties`` object are strictly required to be
74+
present in the instance (except those marked as ``writeOnly`` or
75+
``readOnly`` where appropriate), regardless of the schema's
76+
``required`` array. Defaults to ``False``.
6877
**kwargs: Keyword arguments forwarded to ``cls`` constructor
6978
(for example ``registry`` and ``format_checker``). If omitted,
7079
a local-only empty ``Registry`` is used to avoid implicit remote
@@ -74,6 +83,9 @@ def validate(
7483
jsonschema.exceptions.SchemaError: If ``schema`` is invalid.
7584
jsonschema.exceptions.ValidationError: If ``instance`` is invalid.
7685
"""
86+
if enforce_properties_required:
87+
cls = build_enforce_properties_required_validator(cls) # type: ignore[arg-type]
88+
7789
schema_dict = cast(dict[str, Any], schema)
7890

7991
validator_kwargs = kwargs.copy()

openapi_schema_validator/validators.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
from functools import lru_cache
12
from typing import Any
3+
from typing import Iterator
4+
from typing import Mapping
25
from typing import cast
36

47
from jsonschema import _keywords
58
from jsonschema import _legacy_keywords
69
from jsonschema.exceptions import SchemaError
710
from jsonschema.exceptions import ValidationError
11+
from jsonschema.protocols import Validator
812
from jsonschema.validators import Draft202012Validator
913
from jsonschema.validators import create
1014
from jsonschema.validators import extend
@@ -187,3 +191,47 @@ def _build_oas32_validator() -> Any:
187191
OAS30Validator.check_schema = classmethod(check_openapi_schema)
188192
OAS31Validator.check_schema = classmethod(check_openapi_schema)
189193
OAS32Validator.check_schema = classmethod(check_openapi_schema)
194+
195+
196+
@lru_cache(maxsize=None)
197+
def build_enforce_properties_required_validator(
198+
validator_class: Any,
199+
) -> type[Validator]:
200+
properties_validator = validator_class.VALIDATORS.get("properties")
201+
required_validator = validator_class.VALIDATORS.get("required")
202+
203+
def enforce_properties(
204+
validator: Any,
205+
properties: Any,
206+
instance: Any,
207+
schema: Mapping[str, Any],
208+
) -> Iterator[Any]:
209+
if properties_validator is not None:
210+
yield from properties_validator(
211+
validator, properties, instance, schema
212+
)
213+
214+
if not validator.is_type(instance, "object"):
215+
return
216+
217+
if required_validator is not None:
218+
schema_required = (
219+
schema.get("required", []) if isinstance(schema, dict) else []
220+
)
221+
missing_props = [
222+
p for p in properties.keys() if p not in schema_required
223+
]
224+
if missing_props:
225+
yield from required_validator(
226+
validator, missing_props, instance, schema
227+
)
228+
229+
extended_validator = extend(
230+
validator_class,
231+
validators={"properties": enforce_properties},
232+
)
233+
if hasattr(validator_class, "check_schema"):
234+
extended_validator.check_schema = classmethod(
235+
validator_class.check_schema.__func__
236+
)
237+
return cast(type[Validator], extended_validator)

tests/unit/test_shortcut.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
from openapi_schema_validator._regex import has_ecma_regex
1414
from openapi_schema_validator.settings import reset_settings_cache
1515
from openapi_schema_validator.shortcuts import clear_validate_cache
16+
from openapi_schema_validator.validators import OAS30ReadValidator
17+
from openapi_schema_validator.validators import OAS30Validator
18+
from openapi_schema_validator.validators import OAS30WriteValidator
19+
from openapi_schema_validator.validators import OAS31Validator
20+
from openapi_schema_validator.validators import OAS32Validator
1621

1722

1823
@pytest.fixture(scope="function")
@@ -195,3 +200,186 @@ def test_validate_cache_max_size_from_env(monkeypatch):
195200
validate("foo", schema_a, cls=OAS32Validator)
196201

197202
assert check_schema_mock.call_count == 3
203+
204+
205+
@pytest.mark.parametrize(
206+
"schema, cls, instance, enforce, expected_error",
207+
[
208+
(
209+
{
210+
"type": "object",
211+
"properties": {
212+
"id": {"type": "string"},
213+
"nickname": {"type": "string"},
214+
},
215+
"required": ["id"],
216+
},
217+
OAS30Validator,
218+
{"id": "42"},
219+
False,
220+
None,
221+
),
222+
(
223+
{
224+
"type": "object",
225+
"properties": {
226+
"id": {"type": "string"},
227+
"nickname": {"type": "string"},
228+
},
229+
"required": ["id"],
230+
},
231+
OAS30Validator,
232+
{"id": "42"},
233+
True,
234+
"'nickname' is a required property",
235+
),
236+
(
237+
{
238+
"type": "object",
239+
"properties": {
240+
"id": {"type": "string", "readOnly": True},
241+
"password": {"type": "string", "writeOnly": True},
242+
"normal": {"type": "string"},
243+
},
244+
},
245+
OAS30ReadValidator,
246+
{"id": "123"},
247+
True,
248+
"'normal' is a required property",
249+
),
250+
(
251+
{
252+
"type": "object",
253+
"properties": {
254+
"id": {"type": "string", "readOnly": True},
255+
"password": {"type": "string", "writeOnly": True},
256+
"normal": {"type": "string"},
257+
},
258+
},
259+
OAS30ReadValidator,
260+
{"normal": "abc"},
261+
True,
262+
"'id' is a required property",
263+
),
264+
(
265+
{
266+
"type": "object",
267+
"properties": {
268+
"id": {"type": "string", "readOnly": True},
269+
"password": {"type": "string", "writeOnly": True},
270+
"normal": {"type": "string"},
271+
},
272+
},
273+
OAS30ReadValidator,
274+
{"id": "123", "normal": "abc"},
275+
True,
276+
None,
277+
),
278+
(
279+
{
280+
"type": "object",
281+
"properties": {
282+
"id": {"type": "string", "readOnly": True},
283+
"password": {"type": "string", "writeOnly": True},
284+
"normal": {"type": "string"},
285+
},
286+
},
287+
OAS30WriteValidator,
288+
{"normal": "abc"},
289+
True,
290+
"'password' is a required property",
291+
),
292+
(
293+
{
294+
"type": "object",
295+
"properties": {
296+
"id": {"type": "string", "readOnly": True},
297+
"password": {"type": "string", "writeOnly": True},
298+
"normal": {"type": "string"},
299+
},
300+
},
301+
OAS30WriteValidator,
302+
{"password": "secret"},
303+
True,
304+
"'normal' is a required property",
305+
),
306+
(
307+
{
308+
"type": "object",
309+
"properties": {
310+
"id": {"type": "string", "readOnly": True},
311+
"password": {"type": "string", "writeOnly": True},
312+
"normal": {"type": "string"},
313+
},
314+
},
315+
OAS30WriteValidator,
316+
{"password": "secret", "normal": "abc"},
317+
True,
318+
None,
319+
),
320+
(
321+
{
322+
"type": "object",
323+
"properties": {
324+
"foo": True,
325+
},
326+
},
327+
OAS31Validator,
328+
{},
329+
False,
330+
None,
331+
),
332+
(
333+
{
334+
"type": "object",
335+
"properties": {
336+
"foo": True,
337+
},
338+
},
339+
OAS31Validator,
340+
{},
341+
True,
342+
"'foo' is a required property",
343+
),
344+
(
345+
{
346+
"type": "object",
347+
"properties": {
348+
"foo": {"type": "string"},
349+
},
350+
},
351+
OAS32Validator,
352+
{},
353+
False,
354+
None,
355+
),
356+
(
357+
{
358+
"type": "object",
359+
"properties": {
360+
"foo": {"type": "string"},
361+
},
362+
},
363+
OAS32Validator,
364+
{},
365+
True,
366+
"'foo' is a required property",
367+
),
368+
],
369+
)
370+
def test_enforce_properties_required(
371+
schema, cls, instance, enforce, expected_error
372+
):
373+
if expected_error:
374+
with pytest.raises(ValidationError) as exc:
375+
validate(
376+
instance,
377+
schema,
378+
cls=cls,
379+
enforce_properties_required=enforce,
380+
)
381+
assert expected_error in str(exc.value)
382+
else:
383+
validate(
384+
instance, schema, cls=cls, enforce_properties_required=enforce
385+
)

0 commit comments

Comments
 (0)