From 92186a718b64a76c797cc3bce9cba75d98262dc5 Mon Sep 17 00:00:00 2001 From: p1c2u Date: Thu, 14 May 2026 19:33:40 +0100 Subject: [PATCH] OAS 3.1 tmulti-type cast/deserialize support --- openapi_core/casting/schemas/__init__.py | 7 +- openapi_core/casting/schemas/casters.py | 29 +++++ .../media_types/deserializers.py | 8 +- openapi_core/deserializing/styles/casters.py | 55 --------- openapi_core/deserializing/styles/util.py | 30 +++-- openapi_core/schema/types.py | 39 ++++++ tests/unit/casting/test_schema_casters.py | 112 ++++++++++++++++-- .../test_styles_deserializers.py | 88 +++++++++++++- 8 files changed, 289 insertions(+), 79 deletions(-) delete mode 100644 openapi_core/deserializing/styles/casters.py create mode 100644 openapi_core/schema/types.py diff --git a/openapi_core/casting/schemas/__init__.py b/openapi_core/casting/schemas/__init__.py index 1becd642..81853757 100644 --- a/openapi_core/casting/schemas/__init__.py +++ b/openapi_core/casting/schemas/__init__.py @@ -4,6 +4,7 @@ from openapi_core.casting.schemas.casters import ArrayCaster from openapi_core.casting.schemas.casters import BooleanCaster from openapi_core.casting.schemas.casters import IntegerCaster +from openapi_core.casting.schemas.casters import MultiTypeCaster from openapi_core.casting.schemas.casters import NumberCaster from openapi_core.casting.schemas.casters import ObjectCaster from openapi_core.casting.schemas.casters import PrimitiveCaster @@ -46,10 +47,14 @@ oas30_casters_dict, AnyCaster, ) +# OAS 3.1/3.2: ``type`` may be a list. ``multi=MultiTypeCaster`` enables the +# real coercion path. ``multi`` is intentionally left ``None`` for OAS 3.0 so +# any ``type: [..]`` in a 3.0 spec still raises +# ``TypeError("caster does not accept multiple types")`` at dispatch time. oas31_types_caster = TypesCaster( oas31_casters_dict, AnyCaster, - multi=PrimitiveCaster, + multi=MultiTypeCaster, ) oas32_types_caster = oas31_types_caster diff --git a/openapi_core/casting/schemas/casters.py b/openapi_core/casting/schemas/casters.py index f270ed02..a1ae6056 100644 --- a/openapi_core/casting/schemas/casters.py +++ b/openapi_core/casting/schemas/casters.py @@ -186,6 +186,35 @@ def _cast_proparties( return value +class MultiTypeCaster(PrimitiveCaster): + """Cast a value against a multi-type schema (OAS 3.1/3.2 ``type: [..]``). + + Tries each declared type in order and returns the first cast that + succeeds. ``"null"`` entries are skipped — null values are short-circuited + upstream by ``SchemaCaster.cast`` before this caster is dispatched. + + Raises ``CastError`` with the full type list when no candidate succeeds, + so callers see one failure with the complete declared set rather than a + storm of per-candidate errors. + """ + + def cast(self, value: Any) -> Any: + schema_types = (self.schema / "type").read_str_or_list([]) + if isinstance(schema_types, str): + schema_types = [schema_types] + for candidate in schema_types: + if candidate == "null": + continue + try: + candidate_caster = self.schema_caster.get_type_caster( + candidate + ) + return candidate_caster(value) + except (CastError, ValueError, TypeError): + continue + raise CastError(value, list(schema_types)) + + class TypesCaster: casters: Mapping[str, Type[PrimitiveCaster]] = {} multi: Optional[Type[PrimitiveCaster]] = None diff --git a/openapi_core/deserializing/media_types/deserializers.py b/openapi_core/deserializing/media_types/deserializers.py index 027058d6..850996ce 100644 --- a/openapi_core/deserializing/media_types/deserializers.py +++ b/openapi_core/deserializing/media_types/deserializers.py @@ -23,6 +23,7 @@ from openapi_core.schema.protocols import SuportsGetAll from openapi_core.schema.protocols import SuportsGetList from openapi_core.schema.schemas import get_properties +from openapi_core.schema.types import pick_style_type from openapi_core.validation.schemas.validators import SchemaValidator if TYPE_CHECKING: @@ -240,7 +241,12 @@ def decode_property_content_type( prop_schema, mimetype=prop_content_type, ) - prop_schema_type = (prop_schema / "type").read_str("") + # Use ``read_str_or_list`` so OAS 3.1/3.2 multi-type properties + # (e.g. ``type: ["array", "null"]``) still trigger the multipart + # fan-out branch. + prop_schema_type = pick_style_type( + (prop_schema / "type").read_str_or_list("") + ) if ( self.mimetype.startswith("multipart") and prop_schema_type == "array" diff --git a/openapi_core/deserializing/styles/casters.py b/openapi_core/deserializing/styles/casters.py deleted file mode 100644 index 698101df..00000000 --- a/openapi_core/deserializing/styles/casters.py +++ /dev/null @@ -1,55 +0,0 @@ -from typing import Any - -from jsonschema_path import SchemaPath - -from openapi_core.util import forcebool - - -def cast_primitive(value: Any, schema: SchemaPath) -> Any: - """Cast a primitive value based on schema type.""" - schema_type = (schema / "type").read_str("") - - if schema_type == "integer": - return int(value) - elif schema_type == "number": - return float(value) - elif schema_type == "boolean": - return forcebool(value) - - return value - - -def cast_value(value: Any, schema: SchemaPath, cast: bool) -> Any: - """Recursively cast a value based on schema.""" - if not cast: - return value - - schema_type = (schema / "type").read_str("") - - # Handle arrays - if schema_type == "array": - if not isinstance(value, list): - raise ValueError( - f"Expected list for array type, got {type(value)}" - ) - items_schema = schema.get("items", SchemaPath.from_dict({})) - return [cast_value(item, items_schema, cast) for item in value] - - # Handle objects - if schema_type == "object": - if not isinstance(value, dict): - raise ValueError( - f"Expected dict for object type, got {type(value)}" - ) - properties = schema.get("properties", SchemaPath.from_dict({})) - result = {} - for key, val in value.items(): - if key in properties: - prop_schema = schema / "properties" / key - result[key] = cast_value(val, prop_schema, cast) - else: - result[key] = val - return result - - # Handle primitives - return cast_primitive(value, schema) diff --git a/openapi_core/deserializing/styles/util.py b/openapi_core/deserializing/styles/util.py index bdc55c21..c725fd5f 100644 --- a/openapi_core/deserializing/styles/util.py +++ b/openapi_core/deserializing/styles/util.py @@ -6,6 +6,7 @@ from openapi_core.schema.protocols import SuportsGetAll from openapi_core.schema.protocols import SuportsGetList +from openapi_core.schema.types import pick_style_type def split(value: str, separator: str = ",", step: int = 1) -> List[str]: @@ -31,7 +32,7 @@ def delimited_loads( ) -> Any: value = location[name] - explode_type = (explode, schema_type) + explode_type = (explode, pick_style_type(schema_type)) if explode_type == (False, "array"): return split(value, separator=delimiter) if explode_type == (False, "object"): @@ -51,25 +52,26 @@ def matrix_loads( schema_type: str | list[str], location: Mapping[str, Any], ) -> Any: + structural_type = pick_style_type(schema_type) if explode == False: m = re.match(rf"^;{name}=(.*)$", location[f";{name}"]) if m is None: raise KeyError(name) value = m.group(1) # ;color=blue,black,brown - if schema_type == "array": + if structural_type == "array": return split(value) # ;color=R,100,G,200,B,150 - if schema_type == "object": + if structural_type == "object": return dict(map(split, split(value, step=2))) # .;color=blue return value else: # ;color=blue;color=black;color=brown - if schema_type == "array": + if structural_type == "array": return re.findall(rf";{name}=([^;]*)", location[f";{name}*"]) # ;R=100;G=200;B=150 - if schema_type == "object": + if structural_type == "object": value = location[f";{name}*"] return dict( map( @@ -91,23 +93,24 @@ def label_loads( schema_type: str | list[str], location: Mapping[str, Any], ) -> Any: + structural_type = pick_style_type(schema_type) if explode == False: value = location[f".{name}"] # .blue,black,brown - if schema_type == "array": + if structural_type == "array": return split(value[1:]) # .R,100,G,200,B,150 - if schema_type == "object": + if structural_type == "object": return dict(map(split, split(value[1:], separator=",", step=2))) # .blue return value[1:] else: value = location[f".{name}*"] # .blue.black.brown - if schema_type == "array": + if structural_type == "array": return split(value[1:], separator=".") # .R=100.G=200.B=150 - if schema_type == "object": + if structural_type == "object": return dict( map( partial(split, separator="="), @@ -124,7 +127,7 @@ def form_loads( schema_type: str | list[str], location: Mapping[str, Any], ) -> Any: - explode_type = (explode, schema_type) + explode_type = (explode, pick_style_type(schema_type)) # color=blue,black,brown if explode_type == (False, "array"): return split(location[name], separator=",") @@ -159,12 +162,13 @@ def simple_loads( location: Mapping[str, Any], ) -> Any: value = location[name] + structural_type = pick_style_type(schema_type) # blue,black,brown - if schema_type == "array": + if structural_type == "array": return split(value, separator=",") - explode_type = (explode, schema_type) + explode_type = (explode, structural_type) # R,100,G,200,B,150 if explode_type == (False, "object"): return dict(map(split, split(value, separator=",", step=2))) @@ -204,7 +208,7 @@ def deep_object_loads( schema_type: str | list[str], location: Mapping[str, Any], ) -> Any: - explode_type = (explode, schema_type) + explode_type = (explode, pick_style_type(schema_type)) if explode_type != (True, "object"): raise ValueError("not available") diff --git a/openapi_core/schema/types.py b/openapi_core/schema/types.py new file mode 100644 index 00000000..5996895f --- /dev/null +++ b/openapi_core/schema/types.py @@ -0,0 +1,39 @@ +"""Helpers for OpenAPI ``type`` (string or list of strings). + +OAS 3.1 and 3.2 allow the ``type`` keyword to be either a single string or +a list of strings (e.g. ``type: ["integer", "null"]``). OAS 3.0 only allows +a single string. Several places in the code need to make decisions based on +the *structural* type implied by the schema (array vs. object vs. primitive) +without caring which side of the version split they are on; this module +centralises that mapping. +""" + +from typing import Iterable +from typing import Optional +from typing import Union + + +def pick_style_type( + schema_type: Optional[Union[str, Iterable[str]]], +) -> str: + """Pick the structural type used by style/multipart deserializers. + + Style loaders need to know whether the wire form should be parsed as an + array, an object, or a single scalar. They do not need to know which + primitive type the leaf will eventually become — that is the schema + caster's job. + + For multi-type schemas the priority is ``array`` > ``object`` > + primitive. Primitive (or unknown) is represented by an empty string to + match the historical default returned by ``read_str_or_list("")``. + """ + if schema_type is None: + return "" + if isinstance(schema_type, str): + return schema_type + types = list(schema_type) + if "array" in types: + return "array" + if "object" in types: + return "object" + return "" diff --git a/tests/unit/casting/test_schema_casters.py b/tests/unit/casting/test_schema_casters.py index ae825b26..ffcf1dc7 100644 --- a/tests/unit/casting/test_schema_casters.py +++ b/tests/unit/casting/test_schema_casters.py @@ -1,7 +1,9 @@ import pytest from jsonschema_path import SchemaPath +from openapi_core.casting.schemas import oas30_write_schema_casters_factory from openapi_core.casting.schemas import oas31_schema_casters_factory +from openapi_core.casting.schemas import oas32_schema_casters_factory from openapi_core.casting.schemas.exceptions import CastError @@ -68,16 +70,26 @@ def test_array_invalid_value(self, value, caster_factory): caster_factory(schema).cast(value) @pytest.mark.parametrize( - "schema_types,value", + "schema_types,value,expected", [ - (["string", "number", "boolean"], "12567"), - (["integer", "string"], "42"), - (["number", "string"], "3.14"), - (["boolean", "string"], "true"), + # First candidate wins when it succeeds. + (["string", "number", "boolean"], "12567", "12567"), + (["integer", "string"], "42", 42), + (["number", "string"], "3.14", 3.14), + (["boolean", "string"], "true", True), + # Second candidate wins when the first one cannot coerce. + (["integer", "string"], "abc", "abc"), + (["boolean", "string"], "maybe", "maybe"), + # ``null`` entries are skipped — they are short-circuited + # upstream by ``SchemaCaster.cast`` before MultiTypeCaster runs. + (["integer", "null"], "42", 42), + (["null", "integer"], "42", 42), ], ) - def test_oas31_multi_type(self, caster_factory, schema_types, value): - """Test OAS 3.1 list-style `type`.""" + def test_oas31_multi_type( + self, caster_factory, schema_types, value, expected + ): + """OAS 3.1 list-style ``type`` coerces to the first matching candidate.""" spec = { "type": schema_types, } @@ -85,7 +97,91 @@ def test_oas31_multi_type(self, caster_factory, schema_types, value): result = caster_factory(schema).cast(value) - assert result == value + assert result == expected + assert type(result) is type(expected) + + def test_oas31_multi_type_no_candidate_raises(self, caster_factory): + """When no candidate succeeds, raise once with the full type list.""" + spec = {"type": ["integer", "boolean"]} + schema = SchemaPath.from_dict(spec) + + with pytest.raises(CastError) as excinfo: + caster_factory(schema).cast("not-a-number") + + # ``CastError.type`` carries the full declared list, not just the + # last attempted candidate. + assert excinfo.value.type == ["integer", "boolean"] + + def test_oas31_multi_type_null_value(self, caster_factory): + """``None`` is short-circuited by SchemaCaster.cast, regardless of + whether MultiTypeCaster is dispatched.""" + spec = {"type": ["integer", "null"]} + schema = SchemaPath.from_dict(spec) + + assert caster_factory(schema).cast(None) is None + + def test_oas31_multi_type_nested_object(self, caster_factory): + """A property declared multi-type is recursively coerced inside an + object.""" + spec = { + "type": "object", + "properties": { + "count": {"type": ["integer", "null"]}, + "name": {"type": ["string", "null"]}, + }, + } + schema = SchemaPath.from_dict(spec) + + result = caster_factory(schema).cast({"count": "5", "name": "foo"}) + + assert result == {"count": 5, "name": "foo"} + assert type(result["count"]) is int + + def test_oas31_multi_type_nested_array_items(self, caster_factory): + """Array items declared multi-type are coerced per element.""" + spec = { + "type": "array", + "items": {"type": ["integer", "string"]}, + } + schema = SchemaPath.from_dict(spec) + + result = caster_factory(schema).cast(["1", "2", "abc"]) + + assert result == [1, 2, "abc"] + + def test_oas31_multi_type_object_or_null(self, caster_factory): + """An ``object``-or-null schema still walks properties when the value + is an object.""" + spec = { + "type": ["object", "null"], + "properties": {"count": {"type": "integer"}}, + } + schema = SchemaPath.from_dict(spec) + + result = caster_factory(schema).cast({"count": "7"}) + + assert result == {"count": 7} + + def test_oas32_multi_type(self): + """OAS 3.2 inherits the OAS 3.1 multi-type behavior.""" + spec_dict = {} + spec = SchemaPath.from_dict(spec_dict) + schema = SchemaPath.from_dict({"type": ["integer", "string"]}) + + result = oas32_schema_casters_factory.create(spec, schema).cast("42") + + assert result == 42 + + def test_oas30_rejects_multi_type(self): + """OAS 3.0 has no notion of multi-type — dispatch must raise.""" + spec_dict = {} + spec = SchemaPath.from_dict(spec_dict) + schema = SchemaPath.from_dict({"type": ["string", "null"]}) + + with pytest.raises(TypeError, match="multiple types"): + oas30_write_schema_casters_factory.create(spec, schema).cast( + "anything" + ) @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 324af92d..3a1f16e0 100644 --- a/tests/unit/deserializing/test_styles_deserializers.py +++ b/tests/unit/deserializing/test_styles_deserializers.py @@ -447,8 +447,10 @@ def test_pipe_delimited_valid( @pytest.mark.parametrize( "schema_types,value,expected", [ + # ``string`` is first → identity wins. (["string", "number", "boolean"], "12567", "12567"), - (["integer", "string"], "42", "42"), + # ``integer`` is first → coerced to int. + (["integer", "string"], "42", 42), ], ) def test_oas31_multi_type_form( @@ -499,3 +501,87 @@ def test_deep_object_valid(self, deserializer_factory): "G": "200", "B": "150", } + + def test_simple_array_or_null(self, deserializer_factory): + """OAS 3.1 ``type: ["array", "null"]`` is parsed as an array.""" + name = "param" + spec = { + "name": name, + "in": "path", + "style": "simple", + "schema": { + "type": ["array", "null"], + "items": {"type": "integer"}, + }, + } + param = SchemaPath.from_dict(spec) + deserializer = deserializer_factory(param) + location = {name: "1,2,3"} + + result = deserializer.deserialize(location) + + assert result == [1, 2, 3] + + def test_form_array_or_null_explode(self, deserializer_factory): + """OAS 3.1 ``type: ["array", "null"]`` with form/explode fans out.""" + name = "param" + spec = { + "name": name, + "in": "query", + "style": "form", + "explode": True, + "schema": { + "type": ["array", "null"], + "items": {"type": "integer"}, + }, + } + param = SchemaPath.from_dict(spec) + deserializer = deserializer_factory(param) + location = ImmutableMultiDict([(name, "1"), (name, "2"), (name, "3")]) + + result = deserializer.deserialize(location) + + assert result == [1, 2, 3] + + def test_form_object_or_null(self, deserializer_factory): + """OAS 3.1 ``type: ["object", "null"]`` is parsed as an object.""" + name = "param" + spec = { + "name": name, + "in": "query", + "style": "form", + "explode": False, + "schema": { + "type": ["object", "null"], + "properties": { + "R": {"type": "integer"}, + "G": {"type": "integer"}, + "B": {"type": "integer"}, + }, + }, + } + param = SchemaPath.from_dict(spec) + deserializer = deserializer_factory(param) + location = {name: "R,100,G,200,B,150"} + + result = deserializer.deserialize(location) + + assert result == {"R": 100, "G": 200, "B": 150} + + def test_simple_primitive_or_null(self, deserializer_factory): + """OAS 3.1 ``type: ["integer", "null"]`` simple style coerces.""" + name = "param" + spec = { + "name": name, + "in": "path", + "style": "simple", + "schema": {"type": ["integer", "null"]}, + } + param = SchemaPath.from_dict(spec) + deserializer = deserializer_factory(param) + location = {name: "42"} + + result = deserializer.deserialize(location) + + assert result == 42 + assert type(result) is int