Skip to content

Commit 92186a7

Browse files
committed
OAS 3.1 tmulti-type cast/deserialize support
1 parent 9342ce6 commit 92186a7

8 files changed

Lines changed: 289 additions & 79 deletions

File tree

openapi_core/casting/schemas/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from openapi_core.casting.schemas.casters import ArrayCaster
55
from openapi_core.casting.schemas.casters import BooleanCaster
66
from openapi_core.casting.schemas.casters import IntegerCaster
7+
from openapi_core.casting.schemas.casters import MultiTypeCaster
78
from openapi_core.casting.schemas.casters import NumberCaster
89
from openapi_core.casting.schemas.casters import ObjectCaster
910
from openapi_core.casting.schemas.casters import PrimitiveCaster
@@ -46,10 +47,14 @@
4647
oas30_casters_dict,
4748
AnyCaster,
4849
)
50+
# OAS 3.1/3.2: ``type`` may be a list. ``multi=MultiTypeCaster`` enables the
51+
# real coercion path. ``multi`` is intentionally left ``None`` for OAS 3.0 so
52+
# any ``type: [..]`` in a 3.0 spec still raises
53+
# ``TypeError("caster does not accept multiple types")`` at dispatch time.
4954
oas31_types_caster = TypesCaster(
5055
oas31_casters_dict,
5156
AnyCaster,
52-
multi=PrimitiveCaster,
57+
multi=MultiTypeCaster,
5358
)
5459
oas32_types_caster = oas31_types_caster
5560

openapi_core/casting/schemas/casters.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,35 @@ def _cast_proparties(
186186
return value
187187

188188

189+
class MultiTypeCaster(PrimitiveCaster):
190+
"""Cast a value against a multi-type schema (OAS 3.1/3.2 ``type: [..]``).
191+
192+
Tries each declared type in order and returns the first cast that
193+
succeeds. ``"null"`` entries are skipped — null values are short-circuited
194+
upstream by ``SchemaCaster.cast`` before this caster is dispatched.
195+
196+
Raises ``CastError`` with the full type list when no candidate succeeds,
197+
so callers see one failure with the complete declared set rather than a
198+
storm of per-candidate errors.
199+
"""
200+
201+
def cast(self, value: Any) -> Any:
202+
schema_types = (self.schema / "type").read_str_or_list([])
203+
if isinstance(schema_types, str):
204+
schema_types = [schema_types]
205+
for candidate in schema_types:
206+
if candidate == "null":
207+
continue
208+
try:
209+
candidate_caster = self.schema_caster.get_type_caster(
210+
candidate
211+
)
212+
return candidate_caster(value)
213+
except (CastError, ValueError, TypeError):
214+
continue
215+
raise CastError(value, list(schema_types))
216+
217+
189218
class TypesCaster:
190219
casters: Mapping[str, Type[PrimitiveCaster]] = {}
191220
multi: Optional[Type[PrimitiveCaster]] = None

openapi_core/deserializing/media_types/deserializers.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from openapi_core.schema.protocols import SuportsGetAll
2424
from openapi_core.schema.protocols import SuportsGetList
2525
from openapi_core.schema.schemas import get_properties
26+
from openapi_core.schema.types import pick_style_type
2627
from openapi_core.validation.schemas.validators import SchemaValidator
2728

2829
if TYPE_CHECKING:
@@ -240,7 +241,12 @@ def decode_property_content_type(
240241
prop_schema,
241242
mimetype=prop_content_type,
242243
)
243-
prop_schema_type = (prop_schema / "type").read_str("")
244+
# Use ``read_str_or_list`` so OAS 3.1/3.2 multi-type properties
245+
# (e.g. ``type: ["array", "null"]``) still trigger the multipart
246+
# fan-out branch.
247+
prop_schema_type = pick_style_type(
248+
(prop_schema / "type").read_str_or_list("")
249+
)
244250
if (
245251
self.mimetype.startswith("multipart")
246252
and prop_schema_type == "array"

openapi_core/deserializing/styles/casters.py

Lines changed: 0 additions & 55 deletions
This file was deleted.

openapi_core/deserializing/styles/util.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from openapi_core.schema.protocols import SuportsGetAll
88
from openapi_core.schema.protocols import SuportsGetList
9+
from openapi_core.schema.types import pick_style_type
910

1011

1112
def split(value: str, separator: str = ",", step: int = 1) -> List[str]:
@@ -31,7 +32,7 @@ def delimited_loads(
3132
) -> Any:
3233
value = location[name]
3334

34-
explode_type = (explode, schema_type)
35+
explode_type = (explode, pick_style_type(schema_type))
3536
if explode_type == (False, "array"):
3637
return split(value, separator=delimiter)
3738
if explode_type == (False, "object"):
@@ -51,25 +52,26 @@ def matrix_loads(
5152
schema_type: str | list[str],
5253
location: Mapping[str, Any],
5354
) -> Any:
55+
structural_type = pick_style_type(schema_type)
5456
if explode == False:
5557
m = re.match(rf"^;{name}=(.*)$", location[f";{name}"])
5658
if m is None:
5759
raise KeyError(name)
5860
value = m.group(1)
5961
# ;color=blue,black,brown
60-
if schema_type == "array":
62+
if structural_type == "array":
6163
return split(value)
6264
# ;color=R,100,G,200,B,150
63-
if schema_type == "object":
65+
if structural_type == "object":
6466
return dict(map(split, split(value, step=2)))
6567
# .;color=blue
6668
return value
6769
else:
6870
# ;color=blue;color=black;color=brown
69-
if schema_type == "array":
71+
if structural_type == "array":
7072
return re.findall(rf";{name}=([^;]*)", location[f";{name}*"])
7173
# ;R=100;G=200;B=150
72-
if schema_type == "object":
74+
if structural_type == "object":
7375
value = location[f";{name}*"]
7476
return dict(
7577
map(
@@ -91,23 +93,24 @@ def label_loads(
9193
schema_type: str | list[str],
9294
location: Mapping[str, Any],
9395
) -> Any:
96+
structural_type = pick_style_type(schema_type)
9497
if explode == False:
9598
value = location[f".{name}"]
9699
# .blue,black,brown
97-
if schema_type == "array":
100+
if structural_type == "array":
98101
return split(value[1:])
99102
# .R,100,G,200,B,150
100-
if schema_type == "object":
103+
if structural_type == "object":
101104
return dict(map(split, split(value[1:], separator=",", step=2)))
102105
# .blue
103106
return value[1:]
104107
else:
105108
value = location[f".{name}*"]
106109
# .blue.black.brown
107-
if schema_type == "array":
110+
if structural_type == "array":
108111
return split(value[1:], separator=".")
109112
# .R=100.G=200.B=150
110-
if schema_type == "object":
113+
if structural_type == "object":
111114
return dict(
112115
map(
113116
partial(split, separator="="),
@@ -124,7 +127,7 @@ def form_loads(
124127
schema_type: str | list[str],
125128
location: Mapping[str, Any],
126129
) -> Any:
127-
explode_type = (explode, schema_type)
130+
explode_type = (explode, pick_style_type(schema_type))
128131
# color=blue,black,brown
129132
if explode_type == (False, "array"):
130133
return split(location[name], separator=",")
@@ -159,12 +162,13 @@ def simple_loads(
159162
location: Mapping[str, Any],
160163
) -> Any:
161164
value = location[name]
165+
structural_type = pick_style_type(schema_type)
162166

163167
# blue,black,brown
164-
if schema_type == "array":
168+
if structural_type == "array":
165169
return split(value, separator=",")
166170

167-
explode_type = (explode, schema_type)
171+
explode_type = (explode, structural_type)
168172
# R,100,G,200,B,150
169173
if explode_type == (False, "object"):
170174
return dict(map(split, split(value, separator=",", step=2)))
@@ -204,7 +208,7 @@ def deep_object_loads(
204208
schema_type: str | list[str],
205209
location: Mapping[str, Any],
206210
) -> Any:
207-
explode_type = (explode, schema_type)
211+
explode_type = (explode, pick_style_type(schema_type))
208212

209213
if explode_type != (True, "object"):
210214
raise ValueError("not available")

openapi_core/schema/types.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Helpers for OpenAPI ``type`` (string or list of strings).
2+
3+
OAS 3.1 and 3.2 allow the ``type`` keyword to be either a single string or
4+
a list of strings (e.g. ``type: ["integer", "null"]``). OAS 3.0 only allows
5+
a single string. Several places in the code need to make decisions based on
6+
the *structural* type implied by the schema (array vs. object vs. primitive)
7+
without caring which side of the version split they are on; this module
8+
centralises that mapping.
9+
"""
10+
11+
from typing import Iterable
12+
from typing import Optional
13+
from typing import Union
14+
15+
16+
def pick_style_type(
17+
schema_type: Optional[Union[str, Iterable[str]]],
18+
) -> str:
19+
"""Pick the structural type used by style/multipart deserializers.
20+
21+
Style loaders need to know whether the wire form should be parsed as an
22+
array, an object, or a single scalar. They do not need to know which
23+
primitive type the leaf will eventually become — that is the schema
24+
caster's job.
25+
26+
For multi-type schemas the priority is ``array`` > ``object`` >
27+
primitive. Primitive (or unknown) is represented by an empty string to
28+
match the historical default returned by ``read_str_or_list("")``.
29+
"""
30+
if schema_type is None:
31+
return ""
32+
if isinstance(schema_type, str):
33+
return schema_type
34+
types = list(schema_type)
35+
if "array" in types:
36+
return "array"
37+
if "object" in types:
38+
return "object"
39+
return ""

0 commit comments

Comments
 (0)