Skip to content
Merged
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
26 changes: 26 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ By default, OpenAPI follows JSON Schema behavior: when an object schema omits `a

If you want stricter behavior, enable `strict_additional_properties`. In this mode, omitted `additionalProperties` is treated as `false`.

This mode is particularly useful for:
- **Preventing data leaks**: Ensuring your API doesn't accidentally expose internal or sensitive fields in responses that aren't explicitly documented.
- **Strict client validation**: Rejecting client requests that contain typos, extraneous data, or unsupported fields, forcing clients to adhere exactly to the defined schema.
- **Contract tightening**: Enforcing the exact shape of objects across your API boundaries.

``` python hl_lines="4"
from openapi_core import Config
from openapi_core import OpenAPI
Expand All @@ -153,6 +158,27 @@ When strict mode is enabled:
- object schema with omitted `additionalProperties` rejects unknown fields
- object schema with `additionalProperties: true` still allows unknown fields

## Response Properties Policy

By default, OpenAPI follows JSON Schema behavior for `required`: response object properties are optional unless explicitly listed in `required`.

If you want stricter response checks, change `response_properties_default_policy` to `required`. In this mode, response object schemas are validated as if all documented properties were required (except properties marked as `writeOnly` in OpenAPI 3.0).

This mode is intentionally stricter than the OpenAPI default. It is particularly useful for:
- **Contract completeness checks in tests**: Ensuring that the backend actually returns all the properties documented in the OpenAPI specification.
- **Detecting API drift**: Catching bugs where a database schema change or serializer update inadvertently drops fields from the response.
- **Preventing silent failures**: Making sure clients aren't broken by missing data that they expect to be present according to the API documentation.

``` python hl_lines="4"
from openapi_core import Config
from openapi_core import OpenAPI

config = Config(
response_properties_default_policy="required",
)
openapi = OpenAPI.from_file_path('openapi.json', config=config)
```

## Extra Format Validators

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.
Expand Down
8 changes: 8 additions & 0 deletions openapi_core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,8 @@ def response_validator(self) -> ResponseValidator:
extra_format_validators=self.config.extra_format_validators,
extra_media_type_deserializers=self.config.extra_media_type_deserializers,
strict_additional_properties=self.config.strict_additional_properties,
enforce_properties_required=self.config.response_properties_default_policy
== "required",
)

@cached_property
Expand Down Expand Up @@ -484,6 +486,8 @@ def webhook_response_validator(self) -> WebhookResponseValidator:
extra_format_validators=self.config.extra_format_validators,
extra_media_type_deserializers=self.config.extra_media_type_deserializers,
strict_additional_properties=self.config.strict_additional_properties,
enforce_properties_required=self.config.response_properties_default_policy
== "required",
)

@cached_property
Expand Down Expand Up @@ -523,6 +527,8 @@ def response_unmarshaller(self) -> ResponseUnmarshaller:
extra_format_validators=self.config.extra_format_validators,
extra_media_type_deserializers=self.config.extra_media_type_deserializers,
strict_additional_properties=self.config.strict_additional_properties,
enforce_properties_required=self.config.response_properties_default_policy
== "required",
schema_unmarshallers_factory=self.config.schema_unmarshallers_factory,
extra_format_unmarshallers=self.config.extra_format_unmarshallers,
)
Expand Down Expand Up @@ -564,6 +570,8 @@ def webhook_response_unmarshaller(self) -> WebhookResponseUnmarshaller:
extra_format_validators=self.config.extra_format_validators,
extra_media_type_deserializers=self.config.extra_media_type_deserializers,
strict_additional_properties=self.config.strict_additional_properties,
enforce_properties_required=self.config.response_properties_default_policy
== "required",
schema_unmarshallers_factory=self.config.schema_unmarshallers_factory,
extra_format_unmarshallers=self.config.extra_format_unmarshallers,
)
Expand Down
3 changes: 3 additions & 0 deletions openapi_core/configurations.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ class Config(UnmarshallerConfig):
response_unmarshaller_cls: Response unmarshaller class.
webhook_request_unmarshaller_cls: Webhook request unmarshaller class.
webhook_response_unmarshaller_cls: Webhook response unmarshaller class.
response_properties_default_policy: If true, require documented response
properties (except writeOnly properties) in response validation and
unmarshalling.
"""

spec_validator_cls: Union[SpecValidatorType, Unset] = _UNSET
Expand Down
2 changes: 2 additions & 0 deletions openapi_core/unmarshalling/response/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def __init__(
MediaTypeDeserializersDict
] = None,
strict_additional_properties: bool = False,
enforce_properties_required: bool = False,
schema_unmarshallers_factory: Optional[
SchemaUnmarshallersFactory
] = None,
Expand Down Expand Up @@ -92,6 +93,7 @@ def __init__(
MediaTypeDeserializersDict
] = None,
strict_additional_properties: bool = False,
enforce_properties_required: bool = False,
schema_unmarshallers_factory: Optional[
SchemaUnmarshallersFactory
] = None,
Expand Down
2 changes: 2 additions & 0 deletions openapi_core/unmarshalling/schemas/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def create(
extra_format_validators: Optional[FormatValidatorsDict] = None,
extra_format_unmarshallers: Optional[FormatUnmarshallersDict] = None,
strict_additional_properties: bool = False,
enforce_properties_required: bool = False,
) -> SchemaUnmarshaller:
"""Create unmarshaller from the schema."""
if schema is None:
Expand All @@ -54,6 +55,7 @@ def create(
format_validators=format_validators,
extra_format_validators=extra_format_validators,
strict_additional_properties=strict_additional_properties,
enforce_properties_required=enforce_properties_required,
)

schema_format = (schema / "format").read_str(None)
Expand Down
3 changes: 3 additions & 0 deletions openapi_core/unmarshalling/unmarshallers.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def __init__(
MediaTypeDeserializersDict
] = None,
strict_additional_properties: bool = False,
enforce_properties_required: bool = False,
schema_unmarshallers_factory: Optional[
SchemaUnmarshallersFactory
] = None,
Expand All @@ -75,6 +76,7 @@ def __init__(
extra_format_validators=extra_format_validators,
extra_media_type_deserializers=extra_media_type_deserializers,
strict_additional_properties=strict_additional_properties,
enforce_properties_required=enforce_properties_required,
)
self.schema_unmarshallers_factory = (
schema_unmarshallers_factory or self.schema_unmarshallers_factory
Expand All @@ -92,6 +94,7 @@ def _unmarshal_schema(self, schema: SchemaPath, value: Any) -> Any:
format_validators=self.format_validators,
extra_format_validators=self.extra_format_validators,
strict_additional_properties=self.strict_additional_properties,
enforce_properties_required=self.enforce_properties_required,
format_unmarshallers=self.format_unmarshallers,
extra_format_unmarshallers=self.extra_format_unmarshallers,
)
Expand Down
8 changes: 8 additions & 0 deletions openapi_core/validation/configurations.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from dataclasses import dataclass
from typing import Literal
from typing import Optional

from openapi_core.casting.schemas.factories import SchemaCastersFactory
Expand Down Expand Up @@ -46,6 +47,10 @@ class ValidatorConfig:
strict_additional_properties
If true, treat schemas that omit additionalProperties as if
additionalProperties: false.
response_properties_default_policy
If true, response schema properties are treated as required during
response validation/unmarshalling, except properties marked as
writeOnly.
"""

server_base_url: Optional[str] = None
Expand All @@ -66,3 +71,6 @@ class ValidatorConfig:
security_provider_factory
)
strict_additional_properties: bool = False
response_properties_default_policy: Literal["optional", "required"] = (
"optional"
)
2 changes: 2 additions & 0 deletions openapi_core/validation/response/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def __init__(
MediaTypeDeserializersDict
] = None,
strict_additional_properties: bool = False,
enforce_properties_required: bool = False,
): ...

def iter_errors(
Expand Down Expand Up @@ -85,6 +86,7 @@ def __init__(
MediaTypeDeserializersDict
] = None,
strict_additional_properties: bool = False,
enforce_properties_required: bool = False,
): ...

def iter_errors(
Expand Down
70 changes: 69 additions & 1 deletion openapi_core/validation/schemas/factories.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from copy import deepcopy
from typing import Any
from typing import Optional
from typing import cast

Expand Down Expand Up @@ -58,6 +59,7 @@ def create(
format_validators: Optional[FormatValidatorsDict] = None,
extra_format_validators: Optional[FormatValidatorsDict] = None,
strict_additional_properties: bool = False,
enforce_properties_required: bool = False,
) -> SchemaValidator:
validator_class: type[Validator] = self.schema_validator_class
if strict_additional_properties:
Expand All @@ -71,10 +73,76 @@ def create(
format_validators, extra_format_validators
)
with schema.resolve() as resolved:
schema_value = resolved.contents
if enforce_properties_required:
schema_value = self._build_required_properties_schema(
schema_value
)
jsonschema_validator = validator_class(
resolved.contents,
schema_value,
_resolver=resolved.resolver,
format_checker=format_checker,
)

return SchemaValidator(schema, jsonschema_validator)

def _build_required_properties_schema(self, schema_value: Any) -> Any:
updated = deepcopy(schema_value)
self._set_required_properties(updated)
return updated

def _set_required_properties(self, schema: Any) -> None:
if not isinstance(schema, dict):
return

properties = schema.get("properties")
if isinstance(properties, dict) and properties:
schema["required"] = [
property_name
for property_name, property_schema in properties.items()
if not self._is_write_only_property(property_schema)
]
for property_schema in properties.values():
self._set_required_properties(property_schema)

for keyword in (
"allOf",
"anyOf",
"oneOf",
"prefixItems",
):
subschemas = schema.get(keyword)
if isinstance(subschemas, list):
for subschema in subschemas:
self._set_required_properties(subschema)

for keyword in (
"items",
"contains",
"if",
"then",
"else",
"not",
"propertyNames",
"additionalProperties",
"unevaluatedProperties",
"unevaluatedItems",
"contentSchema",
):
self._set_required_properties(schema.get(keyword))

for keyword in ("$defs", "definitions", "patternProperties"):
subschemas_map = schema.get(keyword)
if isinstance(subschemas_map, dict):
for subschema in subschemas_map.values():
self._set_required_properties(subschema)

dependent_schemas = schema.get("dependentSchemas")
if isinstance(dependent_schemas, dict):
for subschema in dependent_schemas.values():
self._set_required_properties(subschema)

def _is_write_only_property(self, property_schema: Any) -> bool:
if not isinstance(property_schema, dict):
return False
return property_schema.get("writeOnly") is True
4 changes: 4 additions & 0 deletions openapi_core/validation/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def __init__(
MediaTypeDeserializersDict
] = None,
strict_additional_properties: bool = False,
enforce_properties_required: bool = False,
):
self.spec = spec
self.base_url = base_url
Expand Down Expand Up @@ -103,6 +104,7 @@ def __init__(
self.extra_format_validators = extra_format_validators
self.extra_media_type_deserializers = extra_media_type_deserializers
self.strict_additional_properties = strict_additional_properties
self.enforce_properties_required = enforce_properties_required

@cached_property
def path_finder(self) -> BasePathFinder:
Expand Down Expand Up @@ -143,6 +145,7 @@ def _deserialise_media_type(
format_validators=self.format_validators,
extra_format_validators=self.extra_format_validators,
strict_additional_properties=self.strict_additional_properties,
enforce_properties_required=self.enforce_properties_required,
)
deserializer = self.media_type_deserializers_factory.create(
mimetype,
Expand Down Expand Up @@ -174,6 +177,7 @@ def _validate_schema(self, schema: SchemaPath, value: Any) -> None:
format_validators=self.format_validators,
extra_format_validators=self.extra_format_validators,
strict_additional_properties=self.strict_additional_properties,
enforce_properties_required=self.enforce_properties_required,
)
validator.validate(value)

Expand Down
Loading
Loading