Skip to content

Commit 7bd0813

Browse files
committed
fix multipart binary/urlencoded composed-schema matching
1 parent aef7c45 commit 7bd0813

5 files changed

Lines changed: 685 additions & 53 deletions

File tree

openapi_core/deserializing/media_types/deserializers.py

Lines changed: 103 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
from dataclasses import dataclass
12
from typing import TYPE_CHECKING
23
from typing import Any
4+
from typing import Iterator
35
from typing import Mapping
46
from typing import Optional
57
from xml.etree.ElementTree import ParseError
68

79
from jsonschema_path import SchemaPath
810

11+
from openapi_core.deserializing.exceptions import DeserializeError
912
from openapi_core.deserializing.media_types.datatypes import (
1013
DeserializerCallable,
1114
)
@@ -23,6 +26,7 @@
2326
from openapi_core.schema.protocols import SuportsGetAll
2427
from openapi_core.schema.protocols import SuportsGetList
2528
from openapi_core.schema.schemas import get_properties
29+
from openapi_core.validation.schemas.exceptions import ValidateError
2630
from openapi_core.validation.schemas.validators import SchemaValidator
2731

2832
if TYPE_CHECKING:
@@ -63,6 +67,12 @@ def get_deserializer_callable(
6367
return self.media_type_deserializers[mimetype]
6468

6569

70+
@dataclass(frozen=True)
71+
class FormMediaSchemaMatch:
72+
schema: SchemaPath
73+
decoded_candidate: Mapping[str, Any]
74+
75+
6676
class MediaTypeDeserializer:
6777
def __init__(
6878
self,
@@ -97,7 +107,7 @@ def deserialize(self, value: bytes) -> Any:
97107
):
98108
return deserialized
99109

100-
# decode multipart request bodies if schema provided
110+
# Decode form-media bodies only when a schema is available.
101111
if self.schema is not None:
102112
return self.decode(deserialized)
103113

@@ -126,51 +136,41 @@ def evolve(
126136
schema=schema,
127137
schema_validator=schema_validator,
128138
schema_caster=schema_caster,
139+
encoding=self.encoding,
140+
**self.parameters,
129141
)
130142

131143
def decode(
132-
self, location: Mapping[str, Any], schema_only: bool = False
144+
self,
145+
location: Mapping[str, Any],
146+
schema_only: bool = False,
147+
use_defaults: bool = True,
133148
) -> Mapping[str, Any]:
134-
# schema is required for multipart
149+
# Form-media decoding always needs a schema to resolve properties.
135150
assert self.schema is not None
136151
properties: dict[str, Any] = {}
137152

138-
# For urlencoded/multipart, use caster for oneOf/anyOf detection if validator available
153+
# For form media, select composed branches from decoded candidates.
139154
if self.schema_validator is not None:
140-
one_of_schema = self.schema_validator.get_one_of_schema(
141-
location, caster=self.schema_caster
142-
)
143-
if one_of_schema is not None:
144-
one_of_properties = self.evolve(one_of_schema).decode(
145-
location, schema_only=True
146-
)
147-
properties.update(one_of_properties)
155+
one_of_match = self.get_form_media_one_of_match(location)
156+
if one_of_match is not None:
157+
properties.update(one_of_match.decoded_candidate)
148158

149-
any_of_schemas = self.schema_validator.iter_any_of_schemas(
150-
location, caster=self.schema_caster
151-
)
152-
for any_of_schema in any_of_schemas:
153-
any_of_properties = self.evolve(any_of_schema).decode(
154-
location, schema_only=True
155-
)
156-
properties.update(any_of_properties)
159+
any_of_matches = self.iter_form_media_any_of_matches(location)
160+
for any_of_match in any_of_matches:
161+
properties.update(any_of_match.decoded_candidate)
157162

158-
all_of_schemas = self.schema_validator.iter_all_of_schemas(
159-
location
160-
)
161-
for all_of_schema in all_of_schemas:
162-
all_of_properties = self.evolve(all_of_schema).decode(
163-
location, schema_only=True
164-
)
165-
properties.update(all_of_properties)
163+
all_of_matches = self.iter_form_media_all_of_matches(location)
164+
for all_of_match in all_of_matches:
165+
properties.update(all_of_match.decoded_candidate)
166166

167167
for prop_name, prop_schema in get_properties(self.schema).items():
168168
try:
169169
properties[prop_name] = self.decode_property(
170170
prop_name, prop_schema, location
171171
)
172172
except KeyError:
173-
if "default" not in prop_schema:
173+
if not use_defaults or "default" not in prop_schema:
174174
continue
175175
properties[prop_name] = (prop_schema / "default").read_value()
176176

@@ -179,6 +179,80 @@ def decode(
179179

180180
return properties
181181

182+
def get_form_media_one_of_match(
183+
self,
184+
location: Mapping[str, Any],
185+
) -> Optional[FormMediaSchemaMatch]:
186+
if self.schema is None or "oneOf" not in self.schema:
187+
return None
188+
189+
for subschema in self.schema / "oneOf":
190+
match = self.get_form_media_schema_match(subschema, location)
191+
if match is not None:
192+
return match
193+
194+
return None
195+
196+
def iter_form_media_any_of_matches(
197+
self,
198+
location: Mapping[str, Any],
199+
) -> list[FormMediaSchemaMatch]:
200+
if self.schema is None or "anyOf" not in self.schema:
201+
return []
202+
203+
return list(self.iter_form_media_schema_matches("anyOf", location))
204+
205+
def iter_form_media_all_of_matches(
206+
self,
207+
location: Mapping[str, Any],
208+
) -> list[FormMediaSchemaMatch]:
209+
if self.schema is None or "allOf" not in self.schema:
210+
return []
211+
212+
return list(self.iter_form_media_schema_matches("allOf", location))
213+
214+
def iter_form_media_schema_matches(
215+
self,
216+
keyword: str,
217+
location: Mapping[str, Any],
218+
) -> Iterator[FormMediaSchemaMatch]:
219+
assert self.schema is not None
220+
221+
for subschema in self.schema / keyword:
222+
match = self.get_form_media_schema_match(subschema, location)
223+
if match is not None:
224+
yield match
225+
226+
def get_form_media_schema_match(
227+
self,
228+
subschema: SchemaPath,
229+
location: Mapping[str, Any],
230+
) -> Optional[FormMediaSchemaMatch]:
231+
assert self.schema_validator is not None
232+
233+
deserializer = self.evolve(subschema)
234+
try:
235+
validation_decoded_candidate = deserializer.decode(
236+
location,
237+
schema_only=True,
238+
use_defaults=False,
239+
)
240+
except DeserializeError:
241+
return None
242+
243+
validator = self.schema_validator.evolve(subschema)
244+
validation_candidate = dict(location)
245+
validation_candidate.update(validation_decoded_candidate)
246+
247+
try:
248+
validator.validate(validation_candidate)
249+
except ValidateError:
250+
return None
251+
252+
decoded_candidate = deserializer.decode(location, schema_only=True)
253+
254+
return FormMediaSchemaMatch(subschema, decoded_candidate)
255+
182256
def decode_property(
183257
self,
184258
prop_name: str,

openapi_core/unmarshalling/unmarshallers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ def _get_param_or_header_and_schema(
117117
def _get_content_and_schema(
118118
self, raw: Any, content: SchemaPath, mimetype: Optional[str] = None
119119
) -> Tuple[Any, Optional[SchemaPath]]:
120-
casted, schema = super()._get_content_and_schema(
120+
casted, schema = self._get_content_schema_value_and_schema(
121121
raw, content, mimetype
122122
)
123123
if schema is None:

0 commit comments

Comments
 (0)