Skip to content

Commit 515c95d

Browse files
committed
Urlencoded deserializer schema matching type coercion
1 parent 28ad2f9 commit 515c95d

File tree

4 files changed

+142
-8
lines changed

4 files changed

+142
-8
lines changed

openapi_core/deserializing/media_types/deserializers.py

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from typing import TYPE_CHECKING
12
from typing import Any
23
from typing import Mapping
34
from typing import Optional
@@ -22,6 +23,10 @@
2223
from openapi_core.schema.protocols import SuportsGetAll
2324
from openapi_core.schema.protocols import SuportsGetList
2425
from openapi_core.schema.schemas import get_properties
26+
from openapi_core.validation.schemas.validators import SchemaValidator
27+
28+
if TYPE_CHECKING:
29+
from openapi_core.casting.schemas.casters import SchemaCaster
2530

2631

2732
class MediaTypesDeserializer:
@@ -65,13 +70,17 @@ def __init__(
6570
media_types_deserializer: MediaTypesDeserializer,
6671
mimetype: str,
6772
schema: Optional[SchemaPath] = None,
73+
schema_validator: Optional[SchemaValidator] = None,
74+
schema_caster: Optional["SchemaCaster"] = None,
6875
encoding: Optional[SchemaPath] = None,
6976
**parameters: str,
7077
):
7178
self.style_deserializers_factory = style_deserializers_factory
7279
self.media_types_deserializer = media_types_deserializer
7380
self.mimetype = mimetype
7481
self.schema = schema
82+
self.schema_validator = schema_validator
83+
self.schema_caster = schema_caster
7584
self.encoding = encoding
7685
self.parameters = parameters
7786

@@ -86,25 +95,68 @@ def deserialize(self, value: bytes) -> Any:
8695
):
8796
return deserialized
8897

89-
# decode multipart request bodies
90-
return self.decode(deserialized)
98+
# decode multipart request bodies if schema provided
99+
if self.schema is not None:
100+
return self.decode(deserialized)
101+
102+
return deserialized
91103

92104
def evolve(
93-
self, mimetype: str, schema: Optional[SchemaPath]
105+
self,
106+
schema: SchemaPath,
107+
mimetype: Optional[str] = None,
94108
) -> "MediaTypeDeserializer":
95109
cls = self.__class__
110+
111+
schema_validator = None
112+
if self.schema_validator is not None:
113+
schema_validator = self.schema_validator.evolve(schema)
114+
115+
schema_caster = None
116+
if self.schema_caster is not None:
117+
schema_caster = self.schema_caster.evolve(schema)
96118

97119
return cls(
98120
self.style_deserializers_factory,
99121
self.media_types_deserializer,
100-
mimetype,
122+
mimetype=mimetype or self.mimetype,
101123
schema=schema,
124+
schema_validator=schema_validator,
125+
schema_caster=schema_caster,
102126
)
103127

104-
def decode(self, location: Mapping[str, Any]) -> Mapping[str, Any]:
128+
def decode(self, location: Mapping[str, Any], schema_only: bool = False) -> Mapping[str, Any]:
105129
# schema is required for multipart
106130
assert self.schema is not None
107131
properties = {}
132+
133+
# For urlencoded/multipart, use caster for oneOf/anyOf detection if validator available
134+
if self.schema_validator is not None:
135+
one_of_schema = self.schema_validator.get_one_of_schema(
136+
location, caster=self.schema_caster
137+
)
138+
if one_of_schema is not None:
139+
one_of_properties = self.evolve(
140+
one_of_schema
141+
).decode(location, schema_only=True)
142+
properties.update(one_of_properties)
143+
144+
any_of_schemas = self.schema_validator.iter_any_of_schemas(
145+
location, caster=self.schema_caster
146+
)
147+
for any_of_schema in any_of_schemas:
148+
any_of_properties = self.evolve(
149+
any_of_schema
150+
).decode(location, schema_only=True)
151+
properties.update(any_of_properties)
152+
153+
all_of_schemas = self.schema_validator.iter_all_of_schemas(location)
154+
for all_of_schema in all_of_schemas:
155+
all_of_properties = self.evolve(
156+
all_of_schema
157+
).decode(location, schema_only=True)
158+
properties.update(all_of_properties)
159+
108160
for prop_name, prop_schema in get_properties(self.schema).items():
109161
try:
110162
properties[prop_name] = self.decode_property(
@@ -115,6 +167,9 @@ def decode(self, location: Mapping[str, Any]) -> Mapping[str, Any]:
115167
continue
116168
properties[prop_name] = prop_schema["default"]
117169

170+
if schema_only:
171+
return properties
172+
118173
return properties
119174

120175
def decode_property(
@@ -175,8 +230,8 @@ def decode_property_content_type(
175230
) -> Any:
176231
prop_content_type = get_content_type(prop_schema, prop_encoding)
177232
prop_deserializer = self.evolve(
178-
prop_content_type,
179233
prop_schema,
234+
mimetype=prop_content_type,
180235
)
181236
prop_schema_type = prop_schema.getkey("type", "")
182237
if (

openapi_core/deserializing/media_types/factories.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from openapi_core.deserializing.styles.factories import (
1818
StyleDeserializersFactory,
1919
)
20+
from openapi_core.validation.schemas.validators import SchemaValidator
2021

2122

2223
class MediaTypeDeserializersFactory:
@@ -59,6 +60,7 @@ def create(
5960
self,
6061
mimetype: str,
6162
schema: Optional[SchemaPath] = None,
63+
schema_validator: Optional[SchemaValidator] = None,
6264
parameters: Optional[Mapping[str, str]] = None,
6365
encoding: Optional[SchemaPath] = None,
6466
extra_media_type_deserializers: Optional[
@@ -73,12 +75,29 @@ def create(
7375
self.media_type_deserializers,
7476
extra_media_type_deserializers,
7577
)
78+
79+
# Create schema caster for urlencoded/multipart content types
80+
# Only create if both schema and schema_validator are provided
81+
schema_caster = None
82+
if (
83+
schema is not None
84+
and schema_validator is not None
85+
and (
86+
mimetype == "application/x-www-form-urlencoded"
87+
or mimetype.startswith("multipart")
88+
)
89+
):
90+
schema_caster = self.style_deserializers_factory.schema_casters_factory.create(
91+
schema
92+
)
7693

7794
return MediaTypeDeserializer(
7895
self.style_deserializers_factory,
7996
media_types_deserializer,
8097
mimetype,
8198
schema=schema,
99+
schema_validator=schema_validator,
100+
schema_caster=schema_caster,
82101
encoding=encoding,
83102
**parameters,
84103
)

openapi_core/validation/schemas/validators.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import logging
2+
from collections.abc import Mapping
23
from functools import cached_property
34
from functools import partial
5+
from typing import TYPE_CHECKING
46
from typing import Any
57
from typing import Iterator
68
from typing import Optional
@@ -13,6 +15,9 @@
1315
from openapi_core.validation.schemas.exceptions import InvalidSchemaValue
1416
from openapi_core.validation.schemas.exceptions import ValidateError
1517

18+
if TYPE_CHECKING:
19+
from openapi_core.casting.schemas.casters import SchemaCaster
20+
1621
log = logging.getLogger(__name__)
1722

1823

@@ -113,15 +118,39 @@ def iter_valid_schemas(self, value: Any) -> Iterator[SchemaPath]:
113118
def get_one_of_schema(
114119
self,
115120
value: Any,
121+
caster: Optional["SchemaCaster"] = None,
116122
) -> Optional[SchemaPath]:
123+
"""Find the matching oneOf schema.
124+
125+
Args:
126+
value: The value to match against schemas
127+
caster: Optional caster for type coercion during matching.
128+
Useful for form-encoded data where types need casting.
129+
"""
117130
if "oneOf" not in self.schema:
118131
return None
119132

120133
one_of_schemas = self.schema / "oneOf"
121134
for subschema in one_of_schemas:
122135
validator = self.evolve(subschema)
123136
try:
124-
validator.validate(value)
137+
test_value = value
138+
# Only cast if caster provided (opt-in behavior)
139+
if caster is not None:
140+
try:
141+
# Convert to dict if it's not exactly a plain dict
142+
# (e.g., ImmutableMultiDict from werkzeug)
143+
if type(value) is not dict:
144+
test_value = dict(value)
145+
else:
146+
test_value = value
147+
test_value = caster.evolve(subschema).cast(test_value)
148+
except (ValueError, TypeError, Exception):
149+
# If casting fails, try validation with original value
150+
# We catch generic Exception to handle CastError without circular import
151+
test_value = value
152+
153+
validator.validate(test_value)
125154
except ValidateError:
126155
continue
127156
else:
@@ -133,15 +162,38 @@ def get_one_of_schema(
133162
def iter_any_of_schemas(
134163
self,
135164
value: Any,
165+
caster: Optional["SchemaCaster"] = None,
136166
) -> Iterator[SchemaPath]:
167+
"""Iterate matching anyOf schemas.
168+
169+
Args:
170+
value: The value to match against schemas
171+
caster: Optional caster for type coercion during matching.
172+
Useful for form-encoded data where types need casting.
173+
"""
137174
if "anyOf" not in self.schema:
138175
return
139176

140177
any_of_schemas = self.schema / "anyOf"
141178
for subschema in any_of_schemas:
142179
validator = self.evolve(subschema)
143180
try:
144-
validator.validate(value)
181+
test_value = value
182+
# Only cast if caster provided (opt-in behavior)
183+
if caster is not None:
184+
try:
185+
# Convert to dict if it's not exactly a plain dict
186+
if type(value) is not dict:
187+
test_value = dict(value)
188+
else:
189+
test_value = value
190+
test_value = caster.evolve(subschema).cast(test_value)
191+
except (ValueError, TypeError, Exception):
192+
# If casting fails, try validation with original value
193+
# We catch generic Exception to handle CastError without circular import
194+
test_value = value
195+
196+
validator.validate(test_value)
145197
except ValidateError:
146198
continue
147199
else:

openapi_core/validation/validators.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,17 @@ def _deserialise_media_type(
134134
encoding = None
135135
if "encoding" in media_type:
136136
encoding = media_type.get("encoding")
137+
schema_validator = None
138+
if schema is not None:
139+
schema_validator = self.schema_validators_factory.create(
140+
schema,
141+
format_validators=self.format_validators,
142+
extra_format_validators=self.extra_format_validators,
143+
)
137144
deserializer = self.media_type_deserializers_factory.create(
138145
mimetype,
139146
schema=schema,
147+
schema_validator=schema_validator,
140148
parameters=parameters,
141149
encoding=encoding,
142150
extra_media_type_deserializers=self.extra_media_type_deserializers,

0 commit comments

Comments
 (0)