From 8a31c48d5a7d59380a8e778b2253b8f8ee2d0a22 Mon Sep 17 00:00:00 2001 From: p1c2u Date: Wed, 4 Mar 2026 19:15:41 +0000 Subject: [PATCH 1/2] Add strict response properties validation mode --- docs/configuration.md | 18 +++ openapi_core/app.py | 4 + openapi_core/configurations.py | 3 + .../unmarshalling/response/protocols.py | 2 + .../unmarshalling/schemas/factories.py | 2 + openapi_core/unmarshalling/unmarshallers.py | 3 + openapi_core/validation/configurations.py | 5 + openapi_core/validation/response/protocols.py | 2 + openapi_core/validation/schemas/factories.py | 70 +++++++- openapi_core/validation/validators.py | 4 + ...unmarshaller_strict_response_properties.py | 102 ++++++++++++ .../test_strict_response_properties.py | 153 ++++++++++++++++++ .../unit/validation/test_schema_validators.py | 68 ++++++++ 13 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 tests/integration/unmarshalling/test_response_unmarshaller_strict_response_properties.py create mode 100644 tests/integration/validation/test_strict_response_properties.py diff --git a/docs/configuration.md b/docs/configuration.md index 4971cce4..aae51752 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -153,6 +153,24 @@ When strict mode is enabled: - object schema with omitted `additionalProperties` rejects unknown fields - object schema with `additionalProperties: true` still allows unknown fields +## Strict Response Properties + +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, enable `strict_response_properties`. In this mode, response object schemas are validated as if all documented properties were required, except properties marked as `writeOnly`. + +``` python hl_lines="4" +from openapi_core import Config +from openapi_core import OpenAPI + +config = Config( + strict_response_properties=True, +) +openapi = OpenAPI.from_file_path('openapi.json', config=config) +``` + +This mode is intentionally stricter than the OpenAPI default and is useful for contract completeness checks in tests. + ## 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..bb447eed 100644 --- a/openapi_core/app.py +++ b/openapi_core/app.py @@ -447,6 +447,7 @@ 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, + strict_response_properties=self.config.strict_response_properties, ) @cached_property @@ -484,6 +485,7 @@ 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, + strict_response_properties=self.config.strict_response_properties, ) @cached_property @@ -523,6 +525,7 @@ 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, + strict_response_properties=self.config.strict_response_properties, schema_unmarshallers_factory=self.config.schema_unmarshallers_factory, extra_format_unmarshallers=self.config.extra_format_unmarshallers, ) @@ -564,6 +567,7 @@ 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, + strict_response_properties=self.config.strict_response_properties, 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..04d2140a 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. + strict_response_properties: 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..b5c74375 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, + strict_response_properties: bool = False, schema_unmarshallers_factory: Optional[ SchemaUnmarshallersFactory ] = None, @@ -92,6 +93,7 @@ def __init__( MediaTypeDeserializersDict ] = None, strict_additional_properties: bool = False, + strict_response_properties: 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..bd0bfff2 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, + require_all_properties: 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, + require_all_properties=require_all_properties, ) schema_format = (schema / "format").read_str(None) diff --git a/openapi_core/unmarshalling/unmarshallers.py b/openapi_core/unmarshalling/unmarshallers.py index 9c977f21..3526dc06 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, + strict_response_properties: 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, + strict_response_properties=strict_response_properties, ) 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, + require_all_properties=self.strict_response_properties, 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..c2cc4469 100644 --- a/openapi_core/validation/configurations.py +++ b/openapi_core/validation/configurations.py @@ -46,6 +46,10 @@ class ValidatorConfig: strict_additional_properties If true, treat schemas that omit additionalProperties as if additionalProperties: false. + strict_response_properties + 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 +70,4 @@ class ValidatorConfig: security_provider_factory ) strict_additional_properties: bool = False + strict_response_properties: bool = False diff --git a/openapi_core/validation/response/protocols.py b/openapi_core/validation/response/protocols.py index dbc6ba95..022e0893 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, + strict_response_properties: bool = False, ): ... def iter_errors( @@ -85,6 +86,7 @@ def __init__( MediaTypeDeserializersDict ] = None, strict_additional_properties: bool = False, + strict_response_properties: bool = False, ): ... def iter_errors( diff --git a/openapi_core/validation/schemas/factories.py b/openapi_core/validation/schemas/factories.py index 9db4ee59..b3feec32 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, + require_all_properties: 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 require_all_properties: + 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..6f8faf1d 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, + strict_response_properties: 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.strict_response_properties = strict_response_properties @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, + require_all_properties=self.strict_response_properties, ) 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, + require_all_properties=self.strict_response_properties, ) validator.validate(value) diff --git a/tests/integration/unmarshalling/test_response_unmarshaller_strict_response_properties.py b/tests/integration/unmarshalling/test_response_unmarshaller_strict_response_properties.py new file mode 100644 index 00000000..430a2e84 --- /dev/null +++ b/tests/integration/unmarshalling/test_response_unmarshaller_strict_response_properties.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(strict_response_properties=True) + 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(strict_response_properties=True) + 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_strict_response_properties.py b/tests/integration/validation/test_strict_response_properties.py new file mode 100644 index 00000000..37dccbaf --- /dev/null +++ b/tests/integration/validation/test_strict_response_properties.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(strict_response_properties=True) + 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(strict_response_properties=True) + 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(strict_response_properties=True) + 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_strict_response_properties_flag(): + config = Config(strict_response_properties=True) + 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..fcbaff1c 100644 --- a/tests/unit/validation/test_schema_validators.py +++ b/tests/unit/validation/test_schema_validators.py @@ -275,3 +275,71 @@ def test_additional_properties_true_strict_allows_extra(self): ).validate(value) assert result is None + + def test_require_all_properties_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, + require_all_properties=True, + ).validate({"name": "openapi-core"}) + + def test_require_all_properties_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, + require_all_properties=True, + ).validate({"name": "openapi-core"}) + + assert result is None + + def test_require_all_properties_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, + require_all_properties=True, + ).validate({"name": "openapi-core", "meta": {}}) From 96d51c6f90001856433a7f482da74380d965b4a6 Mon Sep 17 00:00:00 2001 From: p1c2u Date: Wed, 4 Mar 2026 20:36:25 +0000 Subject: [PATCH 2/2] Response Properties Policy --- docs/configuration.md | 18 +++++++++++++----- openapi_core/app.py | 12 ++++++++---- openapi_core/configurations.py | 2 +- .../unmarshalling/response/protocols.py | 4 ++-- .../unmarshalling/schemas/factories.py | 4 ++-- openapi_core/unmarshalling/unmarshallers.py | 6 +++--- openapi_core/validation/configurations.py | 7 +++++-- openapi_core/validation/response/protocols.py | 4 ++-- openapi_core/validation/schemas/factories.py | 4 ++-- openapi_core/validation/validators.py | 8 ++++---- ...ller_response_properties_default_policy.py} | 4 ++-- ...test_response_properties_default_policy.py} | 10 +++++----- .../unit/validation/test_schema_validators.py | 14 ++++++++------ 13 files changed, 57 insertions(+), 40 deletions(-) rename tests/integration/unmarshalling/{test_response_unmarshaller_strict_response_properties.py => test_response_unmarshaller_response_properties_default_policy.py} (95%) rename tests/integration/validation/{test_strict_response_properties.py => test_response_properties_default_policy.py} (92%) diff --git a/docs/configuration.md b/docs/configuration.md index aae51752..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,24 +158,27 @@ When strict mode is enabled: - object schema with omitted `additionalProperties` rejects unknown fields - object schema with `additionalProperties: true` still allows unknown fields -## Strict Response Properties +## 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, enable `strict_response_properties`. In this mode, response object schemas are validated as if all documented properties were required, except properties marked as `writeOnly`. +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( - strict_response_properties=True, + response_properties_default_policy="required", ) openapi = OpenAPI.from_file_path('openapi.json', config=config) ``` -This mode is intentionally stricter than the OpenAPI default and is useful for contract completeness checks in tests. - ## 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 bb447eed..f58a5682 100644 --- a/openapi_core/app.py +++ b/openapi_core/app.py @@ -447,7 +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, - strict_response_properties=self.config.strict_response_properties, + enforce_properties_required=self.config.response_properties_default_policy + == "required", ) @cached_property @@ -485,7 +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, - strict_response_properties=self.config.strict_response_properties, + enforce_properties_required=self.config.response_properties_default_policy + == "required", ) @cached_property @@ -525,7 +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, - strict_response_properties=self.config.strict_response_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, ) @@ -567,7 +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, - strict_response_properties=self.config.strict_response_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 04d2140a..f3318f8f 100644 --- a/openapi_core/configurations.py +++ b/openapi_core/configurations.py @@ -38,7 +38,7 @@ 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. - strict_response_properties: If true, require documented response + response_properties_default_policy: If true, require documented response properties (except writeOnly properties) in response validation and unmarshalling. """ diff --git a/openapi_core/unmarshalling/response/protocols.py b/openapi_core/unmarshalling/response/protocols.py index b5c74375..5f38ca3a 100644 --- a/openapi_core/unmarshalling/response/protocols.py +++ b/openapi_core/unmarshalling/response/protocols.py @@ -56,7 +56,7 @@ def __init__( MediaTypeDeserializersDict ] = None, strict_additional_properties: bool = False, - strict_response_properties: bool = False, + enforce_properties_required: bool = False, schema_unmarshallers_factory: Optional[ SchemaUnmarshallersFactory ] = None, @@ -93,7 +93,7 @@ def __init__( MediaTypeDeserializersDict ] = None, strict_additional_properties: bool = False, - strict_response_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 bd0bfff2..9de5a927 100644 --- a/openapi_core/unmarshalling/schemas/factories.py +++ b/openapi_core/unmarshalling/schemas/factories.py @@ -39,7 +39,7 @@ def create( extra_format_validators: Optional[FormatValidatorsDict] = None, extra_format_unmarshallers: Optional[FormatUnmarshallersDict] = None, strict_additional_properties: bool = False, - require_all_properties: bool = False, + enforce_properties_required: bool = False, ) -> SchemaUnmarshaller: """Create unmarshaller from the schema.""" if schema is None: @@ -55,7 +55,7 @@ def create( format_validators=format_validators, extra_format_validators=extra_format_validators, strict_additional_properties=strict_additional_properties, - require_all_properties=require_all_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 3526dc06..d1c766fb 100644 --- a/openapi_core/unmarshalling/unmarshallers.py +++ b/openapi_core/unmarshalling/unmarshallers.py @@ -51,7 +51,7 @@ def __init__( MediaTypeDeserializersDict ] = None, strict_additional_properties: bool = False, - strict_response_properties: bool = False, + enforce_properties_required: bool = False, schema_unmarshallers_factory: Optional[ SchemaUnmarshallersFactory ] = None, @@ -76,7 +76,7 @@ def __init__( extra_format_validators=extra_format_validators, extra_media_type_deserializers=extra_media_type_deserializers, strict_additional_properties=strict_additional_properties, - strict_response_properties=strict_response_properties, + enforce_properties_required=enforce_properties_required, ) self.schema_unmarshallers_factory = ( schema_unmarshallers_factory or self.schema_unmarshallers_factory @@ -94,7 +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, - require_all_properties=self.strict_response_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 c2cc4469..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,7 +47,7 @@ class ValidatorConfig: strict_additional_properties If true, treat schemas that omit additionalProperties as if additionalProperties: false. - strict_response_properties + response_properties_default_policy If true, response schema properties are treated as required during response validation/unmarshalling, except properties marked as writeOnly. @@ -70,4 +71,6 @@ class ValidatorConfig: security_provider_factory ) strict_additional_properties: bool = False - strict_response_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 022e0893..1e324c2a 100644 --- a/openapi_core/validation/response/protocols.py +++ b/openapi_core/validation/response/protocols.py @@ -48,7 +48,7 @@ def __init__( MediaTypeDeserializersDict ] = None, strict_additional_properties: bool = False, - strict_response_properties: bool = False, + enforce_properties_required: bool = False, ): ... def iter_errors( @@ -86,7 +86,7 @@ def __init__( MediaTypeDeserializersDict ] = None, strict_additional_properties: bool = False, - strict_response_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 b3feec32..562663d9 100644 --- a/openapi_core/validation/schemas/factories.py +++ b/openapi_core/validation/schemas/factories.py @@ -59,7 +59,7 @@ def create( format_validators: Optional[FormatValidatorsDict] = None, extra_format_validators: Optional[FormatValidatorsDict] = None, strict_additional_properties: bool = False, - require_all_properties: bool = False, + enforce_properties_required: bool = False, ) -> SchemaValidator: validator_class: type[Validator] = self.schema_validator_class if strict_additional_properties: @@ -74,7 +74,7 @@ def create( ) with schema.resolve() as resolved: schema_value = resolved.contents - if require_all_properties: + if enforce_properties_required: schema_value = self._build_required_properties_schema( schema_value ) diff --git a/openapi_core/validation/validators.py b/openapi_core/validation/validators.py index 6f8faf1d..aeb5f5be 100644 --- a/openapi_core/validation/validators.py +++ b/openapi_core/validation/validators.py @@ -65,7 +65,7 @@ def __init__( MediaTypeDeserializersDict ] = None, strict_additional_properties: bool = False, - strict_response_properties: bool = False, + enforce_properties_required: bool = False, ): self.spec = spec self.base_url = base_url @@ -104,7 +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.strict_response_properties = strict_response_properties + self.enforce_properties_required = enforce_properties_required @cached_property def path_finder(self) -> BasePathFinder: @@ -145,7 +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, - require_all_properties=self.strict_response_properties, + enforce_properties_required=self.enforce_properties_required, ) deserializer = self.media_type_deserializers_factory.create( mimetype, @@ -177,7 +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, - require_all_properties=self.strict_response_properties, + enforce_properties_required=self.enforce_properties_required, ) validator.validate(value) diff --git a/tests/integration/unmarshalling/test_response_unmarshaller_strict_response_properties.py b/tests/integration/unmarshalling/test_response_unmarshaller_response_properties_default_policy.py similarity index 95% rename from tests/integration/unmarshalling/test_response_unmarshaller_strict_response_properties.py rename to tests/integration/unmarshalling/test_response_unmarshaller_response_properties_default_policy.py index 430a2e84..7d712775 100644 --- a/tests/integration/unmarshalling/test_response_unmarshaller_strict_response_properties.py +++ b/tests/integration/unmarshalling/test_response_unmarshaller_response_properties_default_policy.py @@ -67,7 +67,7 @@ def test_response_unmarshal_default_allows_missing_optional_properties(): def test_response_unmarshal_strict_rejects_missing_documented_properties(): - config = Config(strict_response_properties=True) + config = Config(response_properties_default_policy="required") openapi = OpenAPI.from_dict(_spec_dict(), config=config) request = MockRequest("http://example.com", "get", "/resources") response = MockResponse( @@ -83,7 +83,7 @@ def test_response_unmarshal_strict_rejects_missing_documented_properties(): def test_response_unmarshal_strict_excludes_write_only_properties(): - config = Config(strict_response_properties=True) + config = Config(response_properties_default_policy="required") openapi = OpenAPI.from_dict(_spec_dict(), config=config) request = MockRequest("http://example.com", "get", "/resources") response = MockResponse( diff --git a/tests/integration/validation/test_strict_response_properties.py b/tests/integration/validation/test_response_properties_default_policy.py similarity index 92% rename from tests/integration/validation/test_strict_response_properties.py rename to tests/integration/validation/test_response_properties_default_policy.py index 37dccbaf..595eddf0 100644 --- a/tests/integration/validation/test_strict_response_properties.py +++ b/tests/integration/validation/test_response_properties_default_policy.py @@ -88,7 +88,7 @@ def test_response_validation_default_allows_missing_optional_properties(): def test_response_validation_strict_rejects_missing_documented_properties(): - config = Config(strict_response_properties=True) + config = Config(response_properties_default_policy="required") openapi = OpenAPI.from_dict(_spec_dict(), config=config) request = MockRequest("http://example.com", "get", "/resources") response = MockResponse( @@ -102,7 +102,7 @@ def test_response_validation_strict_rejects_missing_documented_properties(): def test_response_validation_strict_allows_nullable_properties_when_present(): - config = Config(strict_response_properties=True) + config = Config(response_properties_default_policy="required") openapi = OpenAPI.from_dict(_spec_dict(), config=config) request = MockRequest("http://example.com", "get", "/resources") response = MockResponse( @@ -121,7 +121,7 @@ def test_response_validation_strict_allows_nullable_properties_when_present(): def test_response_validation_strict_excludes_write_only_properties(): - config = Config(strict_response_properties=True) + config = Config(response_properties_default_policy="required") openapi = OpenAPI.from_dict(_spec_dict(), config=config) request = MockRequest("http://example.com", "get", "/resources") response = MockResponse( @@ -139,8 +139,8 @@ def test_response_validation_strict_excludes_write_only_properties(): openapi.validate_response(request, response) -def test_request_validation_ignores_strict_response_properties_flag(): - config = Config(strict_response_properties=True) +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", diff --git a/tests/unit/validation/test_schema_validators.py b/tests/unit/validation/test_schema_validators.py index fcbaff1c..0c4df25f 100644 --- a/tests/unit/validation/test_schema_validators.py +++ b/tests/unit/validation/test_schema_validators.py @@ -276,7 +276,7 @@ def test_additional_properties_true_strict_allows_extra(self): assert result is None - def test_require_all_properties_rejects_missing_property(self): + def test_enforce_properties_required_rejects_missing_property(self): schema = { "type": "object", "properties": { @@ -290,10 +290,10 @@ def test_require_all_properties_rejects_missing_property(self): with pytest.raises(InvalidSchemaValue): oas30_write_schema_validators_factory.create( spec, - require_all_properties=True, + enforce_properties_required=True, ).validate({"name": "openapi-core"}) - def test_require_all_properties_ignores_write_only_fields(self): + def test_enforce_properties_required_ignores_write_only_fields(self): schema = { "type": "object", "properties": { @@ -309,12 +309,14 @@ def test_require_all_properties_ignores_write_only_fields(self): result = oas30_write_schema_validators_factory.create( spec, - require_all_properties=True, + enforce_properties_required=True, ).validate({"name": "openapi-core"}) assert result is None - def test_require_all_properties_applies_to_nested_composed_schemas(self): + def test_enforce_properties_required_applies_to_nested_composed_schemas( + self, + ): schema = { "allOf": [ { @@ -341,5 +343,5 @@ def test_require_all_properties_applies_to_nested_composed_schemas(self): with pytest.raises(InvalidSchemaValue): oas30_write_schema_validators_factory.create( spec, - require_all_properties=True, + enforce_properties_required=True, ).validate({"name": "openapi-core", "meta": {}})