Skip to content

Commit 8a31c48

Browse files
committed
Add strict response properties validation mode
1 parent 5fdc799 commit 8a31c48

File tree

13 files changed

+435
-1
lines changed

13 files changed

+435
-1
lines changed

docs/configuration.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,24 @@ When strict mode is enabled:
153153
- object schema with omitted `additionalProperties` rejects unknown fields
154154
- object schema with `additionalProperties: true` still allows unknown fields
155155

156+
## Strict Response Properties
157+
158+
By default, OpenAPI follows JSON Schema behavior for `required`: response object properties are optional unless explicitly listed in `required`.
159+
160+
If you want stricter response checks, enable `strict_response_properties`. In this mode, response object schemas are validated as if all documented properties were required, except properties marked as `writeOnly`.
161+
162+
``` python hl_lines="4"
163+
from openapi_core import Config
164+
from openapi_core import OpenAPI
165+
166+
config = Config(
167+
strict_response_properties=True,
168+
)
169+
openapi = OpenAPI.from_file_path('openapi.json', config=config)
170+
```
171+
172+
This mode is intentionally stricter than the OpenAPI default and is useful for contract completeness checks in tests.
173+
156174
## Extra Format Validators
157175

158176
OpenAPI defines a `format` keyword that hints at how a value should be interpreted. For example, a `string` with the format `date` should conform to the RFC 3339 date format.

openapi_core/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ def response_validator(self) -> ResponseValidator:
447447
extra_format_validators=self.config.extra_format_validators,
448448
extra_media_type_deserializers=self.config.extra_media_type_deserializers,
449449
strict_additional_properties=self.config.strict_additional_properties,
450+
strict_response_properties=self.config.strict_response_properties,
450451
)
451452

452453
@cached_property
@@ -484,6 +485,7 @@ def webhook_response_validator(self) -> WebhookResponseValidator:
484485
extra_format_validators=self.config.extra_format_validators,
485486
extra_media_type_deserializers=self.config.extra_media_type_deserializers,
486487
strict_additional_properties=self.config.strict_additional_properties,
488+
strict_response_properties=self.config.strict_response_properties,
487489
)
488490

489491
@cached_property
@@ -523,6 +525,7 @@ def response_unmarshaller(self) -> ResponseUnmarshaller:
523525
extra_format_validators=self.config.extra_format_validators,
524526
extra_media_type_deserializers=self.config.extra_media_type_deserializers,
525527
strict_additional_properties=self.config.strict_additional_properties,
528+
strict_response_properties=self.config.strict_response_properties,
526529
schema_unmarshallers_factory=self.config.schema_unmarshallers_factory,
527530
extra_format_unmarshallers=self.config.extra_format_unmarshallers,
528531
)
@@ -564,6 +567,7 @@ def webhook_response_unmarshaller(self) -> WebhookResponseUnmarshaller:
564567
extra_format_validators=self.config.extra_format_validators,
565568
extra_media_type_deserializers=self.config.extra_media_type_deserializers,
566569
strict_additional_properties=self.config.strict_additional_properties,
570+
strict_response_properties=self.config.strict_response_properties,
567571
schema_unmarshallers_factory=self.config.schema_unmarshallers_factory,
568572
extra_format_unmarshallers=self.config.extra_format_unmarshallers,
569573
)

openapi_core/configurations.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ class Config(UnmarshallerConfig):
3838
response_unmarshaller_cls: Response unmarshaller class.
3939
webhook_request_unmarshaller_cls: Webhook request unmarshaller class.
4040
webhook_response_unmarshaller_cls: Webhook response unmarshaller class.
41+
strict_response_properties: If true, require documented response
42+
properties (except writeOnly properties) in response validation and
43+
unmarshalling.
4144
"""
4245

4346
spec_validator_cls: Union[SpecValidatorType, Unset] = _UNSET

openapi_core/unmarshalling/response/protocols.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def __init__(
5656
MediaTypeDeserializersDict
5757
] = None,
5858
strict_additional_properties: bool = False,
59+
strict_response_properties: bool = False,
5960
schema_unmarshallers_factory: Optional[
6061
SchemaUnmarshallersFactory
6162
] = None,
@@ -92,6 +93,7 @@ def __init__(
9293
MediaTypeDeserializersDict
9394
] = None,
9495
strict_additional_properties: bool = False,
96+
strict_response_properties: bool = False,
9597
schema_unmarshallers_factory: Optional[
9698
SchemaUnmarshallersFactory
9799
] = None,

openapi_core/unmarshalling/schemas/factories.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def create(
3939
extra_format_validators: Optional[FormatValidatorsDict] = None,
4040
extra_format_unmarshallers: Optional[FormatUnmarshallersDict] = None,
4141
strict_additional_properties: bool = False,
42+
require_all_properties: bool = False,
4243
) -> SchemaUnmarshaller:
4344
"""Create unmarshaller from the schema."""
4445
if schema is None:
@@ -54,6 +55,7 @@ def create(
5455
format_validators=format_validators,
5556
extra_format_validators=extra_format_validators,
5657
strict_additional_properties=strict_additional_properties,
58+
require_all_properties=require_all_properties,
5759
)
5860

5961
schema_format = (schema / "format").read_str(None)

openapi_core/unmarshalling/unmarshallers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def __init__(
5151
MediaTypeDeserializersDict
5252
] = None,
5353
strict_additional_properties: bool = False,
54+
strict_response_properties: bool = False,
5455
schema_unmarshallers_factory: Optional[
5556
SchemaUnmarshallersFactory
5657
] = None,
@@ -75,6 +76,7 @@ def __init__(
7576
extra_format_validators=extra_format_validators,
7677
extra_media_type_deserializers=extra_media_type_deserializers,
7778
strict_additional_properties=strict_additional_properties,
79+
strict_response_properties=strict_response_properties,
7880
)
7981
self.schema_unmarshallers_factory = (
8082
schema_unmarshallers_factory or self.schema_unmarshallers_factory
@@ -92,6 +94,7 @@ def _unmarshal_schema(self, schema: SchemaPath, value: Any) -> Any:
9294
format_validators=self.format_validators,
9395
extra_format_validators=self.extra_format_validators,
9496
strict_additional_properties=self.strict_additional_properties,
97+
require_all_properties=self.strict_response_properties,
9598
format_unmarshallers=self.format_unmarshallers,
9699
extra_format_unmarshallers=self.extra_format_unmarshallers,
97100
)

openapi_core/validation/configurations.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ class ValidatorConfig:
4646
strict_additional_properties
4747
If true, treat schemas that omit additionalProperties as if
4848
additionalProperties: false.
49+
strict_response_properties
50+
If true, response schema properties are treated as required during
51+
response validation/unmarshalling, except properties marked as
52+
writeOnly.
4953
"""
5054

5155
server_base_url: Optional[str] = None
@@ -66,3 +70,4 @@ class ValidatorConfig:
6670
security_provider_factory
6771
)
6872
strict_additional_properties: bool = False
73+
strict_response_properties: bool = False

openapi_core/validation/response/protocols.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def __init__(
4848
MediaTypeDeserializersDict
4949
] = None,
5050
strict_additional_properties: bool = False,
51+
strict_response_properties: bool = False,
5152
): ...
5253

5354
def iter_errors(
@@ -85,6 +86,7 @@ def __init__(
8586
MediaTypeDeserializersDict
8687
] = None,
8788
strict_additional_properties: bool = False,
89+
strict_response_properties: bool = False,
8890
): ...
8991

9092
def iter_errors(

openapi_core/validation/schemas/factories.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from copy import deepcopy
2+
from typing import Any
23
from typing import Optional
34
from typing import cast
45

@@ -58,6 +59,7 @@ def create(
5859
format_validators: Optional[FormatValidatorsDict] = None,
5960
extra_format_validators: Optional[FormatValidatorsDict] = None,
6061
strict_additional_properties: bool = False,
62+
require_all_properties: bool = False,
6163
) -> SchemaValidator:
6264
validator_class: type[Validator] = self.schema_validator_class
6365
if strict_additional_properties:
@@ -71,10 +73,76 @@ def create(
7173
format_validators, extra_format_validators
7274
)
7375
with schema.resolve() as resolved:
76+
schema_value = resolved.contents
77+
if require_all_properties:
78+
schema_value = self._build_required_properties_schema(
79+
schema_value
80+
)
7481
jsonschema_validator = validator_class(
75-
resolved.contents,
82+
schema_value,
7683
_resolver=resolved.resolver,
7784
format_checker=format_checker,
7885
)
7986

8087
return SchemaValidator(schema, jsonschema_validator)
88+
89+
def _build_required_properties_schema(self, schema_value: Any) -> Any:
90+
updated = deepcopy(schema_value)
91+
self._set_required_properties(updated)
92+
return updated
93+
94+
def _set_required_properties(self, schema: Any) -> None:
95+
if not isinstance(schema, dict):
96+
return
97+
98+
properties = schema.get("properties")
99+
if isinstance(properties, dict) and properties:
100+
schema["required"] = [
101+
property_name
102+
for property_name, property_schema in properties.items()
103+
if not self._is_write_only_property(property_schema)
104+
]
105+
for property_schema in properties.values():
106+
self._set_required_properties(property_schema)
107+
108+
for keyword in (
109+
"allOf",
110+
"anyOf",
111+
"oneOf",
112+
"prefixItems",
113+
):
114+
subschemas = schema.get(keyword)
115+
if isinstance(subschemas, list):
116+
for subschema in subschemas:
117+
self._set_required_properties(subschema)
118+
119+
for keyword in (
120+
"items",
121+
"contains",
122+
"if",
123+
"then",
124+
"else",
125+
"not",
126+
"propertyNames",
127+
"additionalProperties",
128+
"unevaluatedProperties",
129+
"unevaluatedItems",
130+
"contentSchema",
131+
):
132+
self._set_required_properties(schema.get(keyword))
133+
134+
for keyword in ("$defs", "definitions", "patternProperties"):
135+
subschemas_map = schema.get(keyword)
136+
if isinstance(subschemas_map, dict):
137+
for subschema in subschemas_map.values():
138+
self._set_required_properties(subschema)
139+
140+
dependent_schemas = schema.get("dependentSchemas")
141+
if isinstance(dependent_schemas, dict):
142+
for subschema in dependent_schemas.values():
143+
self._set_required_properties(subschema)
144+
145+
def _is_write_only_property(self, property_schema: Any) -> bool:
146+
if not isinstance(property_schema, dict):
147+
return False
148+
return property_schema.get("writeOnly") is True

openapi_core/validation/validators.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def __init__(
6565
MediaTypeDeserializersDict
6666
] = None,
6767
strict_additional_properties: bool = False,
68+
strict_response_properties: bool = False,
6869
):
6970
self.spec = spec
7071
self.base_url = base_url
@@ -103,6 +104,7 @@ def __init__(
103104
self.extra_format_validators = extra_format_validators
104105
self.extra_media_type_deserializers = extra_media_type_deserializers
105106
self.strict_additional_properties = strict_additional_properties
107+
self.strict_response_properties = strict_response_properties
106108

107109
@cached_property
108110
def path_finder(self) -> BasePathFinder:
@@ -143,6 +145,7 @@ def _deserialise_media_type(
143145
format_validators=self.format_validators,
144146
extra_format_validators=self.extra_format_validators,
145147
strict_additional_properties=self.strict_additional_properties,
148+
require_all_properties=self.strict_response_properties,
146149
)
147150
deserializer = self.media_type_deserializers_factory.create(
148151
mimetype,
@@ -174,6 +177,7 @@ def _validate_schema(self, schema: SchemaPath, value: Any) -> None:
174177
format_validators=self.format_validators,
175178
extra_format_validators=self.extra_format_validators,
176179
strict_additional_properties=self.strict_additional_properties,
180+
require_all_properties=self.strict_response_properties,
177181
)
178182
validator.validate(value)
179183

0 commit comments

Comments
 (0)