Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion openapi_core/casting/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions openapi_core/casting/schemas/casters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion openapi_core/deserializing/media_types/deserializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
55 changes: 0 additions & 55 deletions openapi_core/deserializing/styles/casters.py

This file was deleted.

30 changes: 17 additions & 13 deletions openapi_core/deserializing/styles/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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"):
Expand All @@ -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(
Expand All @@ -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="="),
Expand All @@ -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=",")
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -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")
Expand Down
39 changes: 39 additions & 0 deletions openapi_core/schema/types.py
Original file line number Diff line number Diff line change
@@ -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 ""
Loading
Loading