Skip to content

Commit 7c98dc8

Browse files
authored
Merge pull request #1077 from python-openapi/feature/urlencoded-deserializer-schema-matching-type-coercion
Urlencoded deserializer schema matching type coercion
2 parents 28ad2f9 + e1ed412 commit 7c98dc8

File tree

5 files changed

+300
-9
lines changed

5 files changed

+300
-9
lines changed

openapi_core/deserializing/media_types/deserializers.py

Lines changed: 66 additions & 7 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,72 @@ 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__
96110

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)
118+
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(
129+
self, location: Mapping[str, Any], schema_only: bool = False
130+
) -> Mapping[str, Any]:
105131
# schema is required for multipart
106132
assert self.schema is not None
107-
properties = {}
133+
properties: dict[str, Any] = {}
134+
135+
# For urlencoded/multipart, use caster for oneOf/anyOf detection if validator available
136+
if self.schema_validator is not None:
137+
one_of_schema = self.schema_validator.get_one_of_schema(
138+
location, caster=self.schema_caster
139+
)
140+
if one_of_schema is not None:
141+
one_of_properties = self.evolve(one_of_schema).decode(
142+
location, schema_only=True
143+
)
144+
properties.update(one_of_properties)
145+
146+
any_of_schemas = self.schema_validator.iter_any_of_schemas(
147+
location, caster=self.schema_caster
148+
)
149+
for any_of_schema in any_of_schemas:
150+
any_of_properties = self.evolve(any_of_schema).decode(
151+
location, schema_only=True
152+
)
153+
properties.update(any_of_properties)
154+
155+
all_of_schemas = self.schema_validator.iter_all_of_schemas(
156+
location
157+
)
158+
for all_of_schema in all_of_schemas:
159+
all_of_properties = self.evolve(all_of_schema).decode(
160+
location, schema_only=True
161+
)
162+
properties.update(all_of_properties)
163+
108164
for prop_name, prop_schema in get_properties(self.schema).items():
109165
try:
110166
properties[prop_name] = self.decode_property(
@@ -115,6 +171,9 @@ def decode(self, location: Mapping[str, Any]) -> Mapping[str, Any]:
115171
continue
116172
properties[prop_name] = prop_schema["default"]
117173

174+
if schema_only:
175+
return properties
176+
118177
return properties
119178

120179
def decode_property(
@@ -175,8 +234,8 @@ def decode_property_content_type(
175234
) -> Any:
176235
prop_content_type = get_content_type(prop_schema, prop_encoding)
177236
prop_deserializer = self.evolve(
178-
prop_content_type,
179237
prop_schema,
238+
mimetype=prop_content_type,
180239
)
181240
prop_schema_type = prop_schema.getkey("type", "")
182241
if (

openapi_core/deserializing/media_types/factories.py

Lines changed: 21 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[
@@ -74,11 +76,30 @@ def create(
7476
extra_media_type_deserializers,
7577
)
7678

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 = (
91+
self.style_deserializers_factory.schema_casters_factory.create(
92+
schema
93+
)
94+
)
95+
7796
return MediaTypeDeserializer(
7897
self.style_deserializers_factory,
7998
media_types_deserializer,
8099
mimetype,
81100
schema=schema,
101+
schema_validator=schema_validator,
102+
schema_caster=schema_caster,
82103
encoding=encoding,
83104
**parameters,
84105
)

openapi_core/validation/schemas/validators.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
from functools import cached_property
33
from functools import partial
4+
from typing import TYPE_CHECKING
45
from typing import Any
56
from typing import Iterator
67
from typing import Optional
@@ -13,6 +14,9 @@
1314
from openapi_core.validation.schemas.exceptions import InvalidSchemaValue
1415
from openapi_core.validation.schemas.exceptions import ValidateError
1516

17+
if TYPE_CHECKING:
18+
from openapi_core.casting.schemas.casters import SchemaCaster
19+
1620
log = logging.getLogger(__name__)
1721

1822

@@ -113,15 +117,39 @@ def iter_valid_schemas(self, value: Any) -> Iterator[SchemaPath]:
113117
def get_one_of_schema(
114118
self,
115119
value: Any,
120+
caster: Optional["SchemaCaster"] = None,
116121
) -> Optional[SchemaPath]:
122+
"""Find the matching oneOf schema.
123+
124+
Args:
125+
value: The value to match against schemas
126+
caster: Optional caster for type coercion during matching.
127+
Useful for form-encoded data where types need casting.
128+
"""
117129
if "oneOf" not in self.schema:
118130
return None
119131

120132
one_of_schemas = self.schema / "oneOf"
121133
for subschema in one_of_schemas:
122134
validator = self.evolve(subschema)
123135
try:
124-
validator.validate(value)
136+
test_value = value
137+
# Only cast if caster provided (opt-in behavior)
138+
if caster is not None:
139+
try:
140+
# Convert to dict if it's not exactly a plain dict
141+
# (e.g., ImmutableMultiDict from werkzeug)
142+
if type(value) is not dict:
143+
test_value = dict(value)
144+
else:
145+
test_value = value
146+
test_value = caster.evolve(subschema).cast(test_value)
147+
except (ValueError, TypeError, Exception):
148+
# If casting fails, try validation with original value
149+
# We catch generic Exception to handle CastError without circular import
150+
test_value = value
151+
152+
validator.validate(test_value)
125153
except ValidateError:
126154
continue
127155
else:
@@ -133,15 +161,38 @@ def get_one_of_schema(
133161
def iter_any_of_schemas(
134162
self,
135163
value: Any,
164+
caster: Optional["SchemaCaster"] = None,
136165
) -> Iterator[SchemaPath]:
166+
"""Iterate matching anyOf schemas.
167+
168+
Args:
169+
value: The value to match against schemas
170+
caster: Optional caster for type coercion during matching.
171+
Useful for form-encoded data where types need casting.
172+
"""
137173
if "anyOf" not in self.schema:
138174
return
139175

140176
any_of_schemas = self.schema / "anyOf"
141177
for subschema in any_of_schemas:
142178
validator = self.evolve(subschema)
143179
try:
144-
validator.validate(value)
180+
test_value = value
181+
# Only cast if caster provided (opt-in behavior)
182+
if caster is not None:
183+
try:
184+
# Convert to dict if it's not exactly a plain dict
185+
if type(value) is not dict:
186+
test_value = dict(value)
187+
else:
188+
test_value = value
189+
test_value = caster.evolve(subschema).cast(test_value)
190+
except (ValueError, TypeError, Exception):
191+
# If casting fails, try validation with original value
192+
# We catch generic Exception to handle CastError without circular import
193+
test_value = value
194+
195+
validator.validate(test_value)
145196
except ValidateError:
146197
continue
147198
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)