diff --git a/docs/configuration.md b/docs/configuration.md index 4971cce4..37d0d4f9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 @@ -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. diff --git a/openapi_core/app.py b/openapi_core/app.py index d9336960..f58a5682 100644 --- a/openapi_core/app.py +++ b/openapi_core/app.py @@ -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 @@ -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 @@ -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, ) @@ -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, ) diff --git a/openapi_core/configurations.py b/openapi_core/configurations.py index 9b23eb03..f3318f8f 100644 --- a/openapi_core/configurations.py +++ b/openapi_core/configurations.py @@ -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 diff --git a/openapi_core/unmarshalling/response/protocols.py b/openapi_core/unmarshalling/response/protocols.py index ede95124..5f38ca3a 100644 --- a/openapi_core/unmarshalling/response/protocols.py +++ b/openapi_core/unmarshalling/response/protocols.py @@ -56,6 +56,7 @@ def __init__( MediaTypeDeserializersDict ] = None, strict_additional_properties: bool = False, + enforce_properties_required: bool = False, schema_unmarshallers_factory: Optional[ SchemaUnmarshallersFactory ] = None, @@ -92,6 +93,7 @@ def __init__( MediaTypeDeserializersDict ] = None, strict_additional_properties: bool = False, + enforce_properties_required: bool = False, schema_unmarshallers_factory: Optional[ SchemaUnmarshallersFactory ] = None, diff --git a/openapi_core/unmarshalling/schemas/factories.py b/openapi_core/unmarshalling/schemas/factories.py index 27dc7247..9de5a927 100644 --- a/openapi_core/unmarshalling/schemas/factories.py +++ b/openapi_core/unmarshalling/schemas/factories.py @@ -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: @@ -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) diff --git a/openapi_core/unmarshalling/unmarshallers.py b/openapi_core/unmarshalling/unmarshallers.py index 9c977f21..d1c766fb 100644 --- a/openapi_core/unmarshalling/unmarshallers.py +++ b/openapi_core/unmarshalling/unmarshallers.py @@ -51,6 +51,7 @@ def __init__( MediaTypeDeserializersDict ] = None, strict_additional_properties: bool = False, + enforce_properties_required: bool = False, schema_unmarshallers_factory: Optional[ SchemaUnmarshallersFactory ] = None, @@ -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 @@ -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, ) diff --git a/openapi_core/validation/configurations.py b/openapi_core/validation/configurations.py index 645a971b..fdcb1f83 100644 --- a/openapi_core/validation/configurations.py +++ b/openapi_core/validation/configurations.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Literal from typing import Optional from openapi_core.casting.schemas.factories import SchemaCastersFactory @@ -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 @@ -66,3 +71,6 @@ class ValidatorConfig: security_provider_factory ) strict_additional_properties: bool = False + response_properties_default_policy: Literal["optional", "required"] = ( + "optional" + ) diff --git a/openapi_core/validation/response/protocols.py b/openapi_core/validation/response/protocols.py index dbc6ba95..1e324c2a 100644 --- a/openapi_core/validation/response/protocols.py +++ b/openapi_core/validation/response/protocols.py @@ -48,6 +48,7 @@ def __init__( MediaTypeDeserializersDict ] = None, strict_additional_properties: bool = False, + enforce_properties_required: bool = False, ): ... def iter_errors( @@ -85,6 +86,7 @@ def __init__( MediaTypeDeserializersDict ] = None, strict_additional_properties: bool = False, + enforce_properties_required: bool = False, ): ... def iter_errors( diff --git a/openapi_core/validation/schemas/factories.py b/openapi_core/validation/schemas/factories.py index 9db4ee59..562663d9 100644 --- a/openapi_core/validation/schemas/factories.py +++ b/openapi_core/validation/schemas/factories.py @@ -1,4 +1,5 @@ from copy import deepcopy +from typing import Any from typing import Optional from typing import cast @@ -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: @@ -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 diff --git a/openapi_core/validation/validators.py b/openapi_core/validation/validators.py index 34ef6b31..aeb5f5be 100644 --- a/openapi_core/validation/validators.py +++ b/openapi_core/validation/validators.py @@ -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 @@ -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: @@ -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, @@ -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) diff --git a/tests/integration/unmarshalling/test_response_unmarshaller_response_properties_default_policy.py b/tests/integration/unmarshalling/test_response_unmarshaller_response_properties_default_policy.py new file mode 100644 index 00000000..7d712775 --- /dev/null +++ b/tests/integration/unmarshalling/test_response_unmarshaller_response_properties_default_policy.py @@ -0,0 +1,102 @@ +import json + +from openapi_core import Config +from openapi_core import OpenAPI +from openapi_core.testing import MockRequest +from openapi_core.testing import MockResponse +from openapi_core.validation.response.exceptions import InvalidData + + +def _spec_dict(): + return { + "openapi": "3.0.3", + "info": { + "title": "Strict response properties", + "version": "1.0.0", + }, + "servers": [{"url": "http://example.com"}], + "paths": { + "/resources": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Resource" + } + } + }, + } + } + } + } + }, + "components": { + "schemas": { + "Resource": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "secret": { + "type": "string", + "writeOnly": True, + }, + }, + "required": ["id"], + } + } + }, + } + + +def test_response_unmarshal_default_allows_missing_optional_properties(): + openapi = OpenAPI.from_dict(_spec_dict()) + request = MockRequest("http://example.com", "get", "/resources") + response = MockResponse( + data=json.dumps({"id": 1}).encode("utf-8"), + status_code=200, + content_type="application/json", + ) + + result = openapi.unmarshal_response(request, response) + + assert result.errors == [] + + +def test_response_unmarshal_strict_rejects_missing_documented_properties(): + config = Config(response_properties_default_policy="required") + openapi = OpenAPI.from_dict(_spec_dict(), config=config) + request = MockRequest("http://example.com", "get", "/resources") + response = MockResponse( + data=json.dumps({"id": 1}).encode("utf-8"), + status_code=200, + content_type="application/json", + ) + + result = openapi.unmarshal_response(request, response) + + assert result.errors == [InvalidData()] + assert result.data is None + + +def test_response_unmarshal_strict_excludes_write_only_properties(): + config = Config(response_properties_default_policy="required") + openapi = OpenAPI.from_dict(_spec_dict(), config=config) + request = MockRequest("http://example.com", "get", "/resources") + response = MockResponse( + data=json.dumps( + { + "id": 1, + "name": "resource", + } + ).encode("utf-8"), + status_code=200, + content_type="application/json", + ) + + result = openapi.unmarshal_response(request, response) + + assert result.errors == [] diff --git a/tests/integration/validation/test_response_properties_default_policy.py b/tests/integration/validation/test_response_properties_default_policy.py new file mode 100644 index 00000000..595eddf0 --- /dev/null +++ b/tests/integration/validation/test_response_properties_default_policy.py @@ -0,0 +1,153 @@ +import json + +import pytest + +from openapi_core import Config +from openapi_core import OpenAPI +from openapi_core.testing import MockRequest +from openapi_core.testing import MockResponse +from openapi_core.validation.response.exceptions import InvalidData + + +def _spec_dict(): + return { + "openapi": "3.0.3", + "info": { + "title": "Strict response properties", + "version": "1.0.0", + }, + "servers": [{"url": "http://example.com"}], + "paths": { + "/resources": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Resource" + } + } + }, + } + } + }, + "post": { + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Resource" + } + } + }, + }, + "responses": { + "201": { + "description": "Created", + } + }, + }, + } + }, + "components": { + "schemas": { + "Resource": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "description": { + "type": "string", + "nullable": True, + }, + "secret": { + "type": "string", + "writeOnly": True, + }, + }, + "required": ["id"], + } + } + }, + } + + +def test_response_validation_default_allows_missing_optional_properties(): + openapi = OpenAPI.from_dict(_spec_dict()) + request = MockRequest("http://example.com", "get", "/resources") + response = MockResponse( + data=json.dumps({"id": 1}).encode("utf-8"), + status_code=200, + content_type="application/json", + ) + + openapi.validate_response(request, response) + + +def test_response_validation_strict_rejects_missing_documented_properties(): + config = Config(response_properties_default_policy="required") + openapi = OpenAPI.from_dict(_spec_dict(), config=config) + request = MockRequest("http://example.com", "get", "/resources") + response = MockResponse( + data=json.dumps({"id": 1}).encode("utf-8"), + status_code=200, + content_type="application/json", + ) + + with pytest.raises(InvalidData): + openapi.validate_response(request, response) + + +def test_response_validation_strict_allows_nullable_properties_when_present(): + config = Config(response_properties_default_policy="required") + openapi = OpenAPI.from_dict(_spec_dict(), config=config) + request = MockRequest("http://example.com", "get", "/resources") + response = MockResponse( + data=json.dumps( + { + "id": 1, + "name": "resource", + "description": None, + } + ).encode("utf-8"), + status_code=200, + content_type="application/json", + ) + + openapi.validate_response(request, response) + + +def test_response_validation_strict_excludes_write_only_properties(): + config = Config(response_properties_default_policy="required") + openapi = OpenAPI.from_dict(_spec_dict(), config=config) + request = MockRequest("http://example.com", "get", "/resources") + response = MockResponse( + data=json.dumps( + { + "id": 1, + "name": "resource", + "description": "description", + } + ).encode("utf-8"), + status_code=200, + content_type="application/json", + ) + + openapi.validate_response(request, response) + + +def test_request_validation_ignores_response_properties_default_policy_flag(): + config = Config(response_properties_default_policy="required") + openapi = OpenAPI.from_dict(_spec_dict(), config=config) + request = MockRequest( + "http://example.com", + "post", + "/resources", + content_type="application/json", + data=json.dumps({"id": 1}).encode("utf-8"), + ) + + openapi.validate_request(request) diff --git a/tests/unit/validation/test_schema_validators.py b/tests/unit/validation/test_schema_validators.py index eb2367b5..0c4df25f 100644 --- a/tests/unit/validation/test_schema_validators.py +++ b/tests/unit/validation/test_schema_validators.py @@ -275,3 +275,73 @@ def test_additional_properties_true_strict_allows_extra(self): ).validate(value) assert result is None + + def test_enforce_properties_required_rejects_missing_property(self): + schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + }, + "required": ["name"], + } + spec = SchemaPath.from_dict(schema) + + with pytest.raises(InvalidSchemaValue): + oas30_write_schema_validators_factory.create( + spec, + enforce_properties_required=True, + ).validate({"name": "openapi-core"}) + + def test_enforce_properties_required_ignores_write_only_fields(self): + schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "secret": { + "type": "string", + "writeOnly": True, + }, + }, + "required": ["name"], + } + spec = SchemaPath.from_dict(schema) + + result = oas30_write_schema_validators_factory.create( + spec, + enforce_properties_required=True, + ).validate({"name": "openapi-core"}) + + assert result is None + + def test_enforce_properties_required_applies_to_nested_composed_schemas( + self, + ): + schema = { + "allOf": [ + { + "type": "object", + "properties": { + "name": {"type": "string"}, + }, + }, + { + "type": "object", + "properties": { + "meta": { + "type": "object", + "properties": { + "version": {"type": "integer"}, + }, + } + }, + }, + ] + } + spec = SchemaPath.from_dict(schema) + + with pytest.raises(InvalidSchemaValue): + oas30_write_schema_validators_factory.create( + spec, + enforce_properties_required=True, + ).validate({"name": "openapi-core", "meta": {}})