From 355ce5b431d7effa96d65e8b7bbe69d3960cd20b Mon Sep 17 00:00:00 2001 From: Andrii Nechaiev Date: Thu, 14 May 2026 13:32:45 +0000 Subject: [PATCH] fix: OAS 3.1 multi type (cast/deserialize) --- openapi_core/casting/schemas/casters.py | 2 +- openapi_core/casting/schemas/exceptions.py | 2 +- .../deserializing/styles/datatypes.py | 4 +- .../deserializing/styles/deserializers.py | 2 +- .../deserializing/styles/factories.py | 2 +- openapi_core/deserializing/styles/util.py | 37 +++++++++++++++---- tests/unit/casting/test_schema_casters.py | 20 ++++++++++ .../test_styles_deserializers.py | 28 ++++++++++++++ 8 files changed, 84 insertions(+), 13 deletions(-) diff --git a/openapi_core/casting/schemas/casters.py b/openapi_core/casting/schemas/casters.py index 2a0fd8e8..f270ed02 100644 --- a/openapi_core/casting/schemas/casters.py +++ b/openapi_core/casting/schemas/casters.py @@ -235,7 +235,7 @@ def cast(self, value: Any) -> Any: ): return value - schema_type = (self.schema / "type").read_str(None) + schema_type = (self.schema / "type").read_str_or_list(None) type_caster = self.get_type_caster(schema_type) if value is None: diff --git a/openapi_core/casting/schemas/exceptions.py b/openapi_core/casting/schemas/exceptions.py index de0f800c..356221c1 100644 --- a/openapi_core/casting/schemas/exceptions.py +++ b/openapi_core/casting/schemas/exceptions.py @@ -9,7 +9,7 @@ class CastError(DeserializeError): """Schema cast operation error""" value: Any - type: str | None + type: str | list[str] | None def __str__(self) -> str: return f"Failed to cast value to {self.type} type: {self.value}" diff --git a/openapi_core/deserializing/styles/datatypes.py b/openapi_core/deserializing/styles/datatypes.py index 27fc7f6c..a0026f4f 100644 --- a/openapi_core/deserializing/styles/datatypes.py +++ b/openapi_core/deserializing/styles/datatypes.py @@ -3,5 +3,7 @@ from typing import Dict from typing import Mapping -DeserializerCallable = Callable[[bool, str, str, Mapping[str, Any]], Any] +DeserializerCallable = Callable[ + [bool, str, str | list[str], Mapping[str, Any]], Any +] StyleDeserializersDict = Dict[str, DeserializerCallable] diff --git a/openapi_core/deserializing/styles/deserializers.py b/openapi_core/deserializing/styles/deserializers.py index 59565603..9cfbb5c3 100644 --- a/openapi_core/deserializing/styles/deserializers.py +++ b/openapi_core/deserializing/styles/deserializers.py @@ -15,7 +15,7 @@ def __init__( style: str, explode: bool, name: str, - schema_type: str, + schema_type: str | list[str], caster: SchemaCaster, deserializer_callable: Optional[DeserializerCallable] = None, ): diff --git a/openapi_core/deserializing/styles/factories.py b/openapi_core/deserializing/styles/factories.py index 2d4504c5..168d826d 100644 --- a/openapi_core/deserializing/styles/factories.py +++ b/openapi_core/deserializing/styles/factories.py @@ -28,7 +28,7 @@ def create( ) -> StyleDeserializer: deserialize_callable = self.style_deserializers.get(style) caster = self.schema_casters_factory.create(spec, schema) - schema_type = (schema / "type").read_str("") + schema_type = (schema / "type").read_str_or_list("") return StyleDeserializer( style, explode, name, schema_type, caster, deserialize_callable ) diff --git a/openapi_core/deserializing/styles/util.py b/openapi_core/deserializing/styles/util.py index 8290b7b4..bdc55c21 100644 --- a/openapi_core/deserializing/styles/util.py +++ b/openapi_core/deserializing/styles/util.py @@ -25,7 +25,7 @@ def split(value: str, separator: str = ",", step: int = 1) -> List[str]: def delimited_loads( explode: bool, name: str, - schema_type: str, + schema_type: str | list[str], location: Mapping[str, Any], delimiter: str, ) -> Any: @@ -46,7 +46,10 @@ def delimited_loads( def matrix_loads( - explode: bool, name: str, schema_type: str, location: Mapping[str, Any] + explode: bool, + name: str, + schema_type: str | list[str], + location: Mapping[str, Any], ) -> Any: if explode == False: m = re.match(rf"^;{name}=(.*)$", location[f";{name}"]) @@ -83,7 +86,10 @@ def matrix_loads( def label_loads( - explode: bool, name: str, schema_type: str, location: Mapping[str, Any] + explode: bool, + name: str, + schema_type: str | list[str], + location: Mapping[str, Any], ) -> Any: if explode == False: value = location[f".{name}"] @@ -113,7 +119,10 @@ def label_loads( def form_loads( - explode: bool, name: str, schema_type: str, location: Mapping[str, Any] + explode: bool, + name: str, + schema_type: str | list[str], + location: Mapping[str, Any], ) -> Any: explode_type = (explode, schema_type) # color=blue,black,brown @@ -144,7 +153,10 @@ def form_loads( def simple_loads( - explode: bool, name: str, schema_type: str, location: Mapping[str, Any] + explode: bool, + name: str, + schema_type: str | list[str], + location: Mapping[str, Any], ) -> Any: value = location[name] @@ -167,7 +179,10 @@ def simple_loads( def space_delimited_loads( - explode: bool, name: str, schema_type: str, location: Mapping[str, Any] + explode: bool, + name: str, + schema_type: str | list[str], + location: Mapping[str, Any], ) -> Any: return delimited_loads( explode, name, schema_type, location, delimiter="%20" @@ -175,13 +190,19 @@ def space_delimited_loads( def pipe_delimited_loads( - explode: bool, name: str, schema_type: str, location: Mapping[str, Any] + explode: bool, + name: str, + schema_type: str | list[str], + location: Mapping[str, Any], ) -> Any: return delimited_loads(explode, name, schema_type, location, delimiter="|") def deep_object_loads( - explode: bool, name: str, schema_type: str, location: Mapping[str, Any] + explode: bool, + name: str, + schema_type: str | list[str], + location: Mapping[str, Any], ) -> Any: explode_type = (explode, schema_type) diff --git a/tests/unit/casting/test_schema_casters.py b/tests/unit/casting/test_schema_casters.py index bad8098e..ae825b26 100644 --- a/tests/unit/casting/test_schema_casters.py +++ b/tests/unit/casting/test_schema_casters.py @@ -67,6 +67,26 @@ def test_array_invalid_value(self, value, caster_factory): ): caster_factory(schema).cast(value) + @pytest.mark.parametrize( + "schema_types,value", + [ + (["string", "number", "boolean"], "12567"), + (["integer", "string"], "42"), + (["number", "string"], "3.14"), + (["boolean", "string"], "true"), + ], + ) + def test_oas31_multi_type(self, caster_factory, schema_types, value): + """Test OAS 3.1 list-style `type`.""" + spec = { + "type": schema_types, + } + schema = SchemaPath.from_dict(spec) + + result = caster_factory(schema).cast(value) + + assert result == value + @pytest.mark.parametrize( "composite_type,schema_type,value,expected", [ diff --git a/tests/unit/deserializing/test_styles_deserializers.py b/tests/unit/deserializing/test_styles_deserializers.py index 2262fcd5..324af92d 100644 --- a/tests/unit/deserializing/test_styles_deserializers.py +++ b/tests/unit/deserializing/test_styles_deserializers.py @@ -444,6 +444,34 @@ def test_pipe_delimited_valid( assert result == expected + @pytest.mark.parametrize( + "schema_types,value,expected", + [ + (["string", "number", "boolean"], "12567", "12567"), + (["integer", "string"], "42", "42"), + ], + ) + def test_oas31_multi_type_form( + self, deserializer_factory, schema_types, value, expected + ): + """Test OAS 3.1 multi-type support for form style parameters.""" + name = "param" + spec = { + "name": name, + "in": "query", + "explode": True, + "schema": { + "type": schema_types, + }, + } + param = SchemaPath.from_dict(spec) + deserializer = deserializer_factory(param) + location = {name: value} + + result = deserializer.deserialize(location) + + assert result == expected + def test_deep_object_valid(self, deserializer_factory): name = "param" spec = {