Skip to content

Commit 0547bcb

Browse files
committed
Fix form-media oneOf greedy behavior
1 parent 6548458 commit 0547bcb

4 files changed

Lines changed: 100 additions & 33 deletions

File tree

openapi_core/deserializing/media_types/deserializers.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,68 @@ def get_form_media_one_of_match(
223223
if self.schema is None or "oneOf" not in self.schema:
224224
return None
225225

226+
matches: list[FormMediaSchemaMatch] = []
226227
for subschema in self.schema / "oneOf":
227228
match = self.get_form_media_schema_match(subschema, location)
228229
if match is not None:
229-
return match
230+
matches.append(match)
231+
232+
if len(matches) == 1:
233+
return matches[0]
234+
235+
return self.get_preferred_form_media_one_of_match(matches)
236+
237+
def get_preferred_form_media_one_of_match(
238+
self,
239+
matches: list[FormMediaSchemaMatch],
240+
) -> Optional[FormMediaSchemaMatch]:
241+
if len(matches) < 2:
242+
return None
243+
244+
preferred_match = matches[0]
245+
for match in matches[1:]:
246+
preferred_match = self.prefer_form_media_one_of_match(
247+
preferred_match,
248+
match,
249+
)
250+
if preferred_match is None:
251+
return None
252+
253+
return preferred_match
254+
255+
def prefer_form_media_one_of_match(
256+
self,
257+
current: FormMediaSchemaMatch,
258+
new: FormMediaSchemaMatch,
259+
) -> Optional[FormMediaSchemaMatch]:
260+
current_keys = set(current.decoded_candidate)
261+
new_keys = set(new.decoded_candidate)
262+
if current_keys != new_keys:
263+
return None
264+
265+
preferred = current
266+
preferred_has_binary = False
267+
268+
for prop_name in current_keys:
269+
current_value = current.decoded_candidate[prop_name]
270+
new_value = new.decoded_candidate[prop_name]
271+
272+
if current_value == new_value:
273+
continue
274+
275+
if isinstance(current_value, bytes) and isinstance(new_value, str):
276+
preferred_has_binary = True
277+
continue
278+
279+
if isinstance(current_value, str) and isinstance(new_value, bytes):
280+
preferred = new
281+
preferred_has_binary = True
282+
continue
283+
284+
return None
285+
286+
if preferred_has_binary:
287+
return preferred
230288

231289
return None
232290

openapi_core/validation/schemas/validators.py

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -294,31 +294,15 @@ def get_one_of_schema(
294294
if "oneOf" not in self.schema:
295295
return None
296296

297-
one_of_schemas = self.schema / "oneOf"
298-
for subschema in one_of_schemas:
299-
validator = self.evolve(subschema)
300-
try:
301-
test_value = value
302-
# Only cast if caster provided (opt-in behavior)
303-
if caster is not None:
304-
try:
305-
# Convert to dict if it's not exactly a plain dict
306-
# (e.g., ImmutableMultiDict from werkzeug)
307-
if type(value) is not dict:
308-
test_value = dict(value)
309-
else:
310-
test_value = value
311-
test_value = caster.evolve(subschema).cast(test_value)
312-
except (ValueError, TypeError, Exception):
313-
# If casting fails, try validation with original value
314-
# We catch generic Exception to handle CastError without circular import
315-
test_value = value
316-
317-
validator.validate(test_value)
318-
except ValidateError:
319-
continue
320-
else:
321-
return subschema
297+
matched_schemas = list(
298+
self.iter_matching_composed_schemas(
299+
"oneOf",
300+
value,
301+
caster=caster,
302+
)
303+
)
304+
if len(matched_schemas) == 1:
305+
return matched_schemas[0]
322306

323307
log.warning("valid oneOf schema not found")
324308
return None

tests/integration/unmarshalling/test_request_unmarshaller.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -582,13 +582,6 @@ def test_request_body_urlencoded_allof_typeless_fragments(self):
582582
assert result.errors == []
583583
assert result.body == {"a": 1, "b": True}
584584

585-
@pytest.mark.xfail(
586-
reason=(
587-
"Form-media oneOf behavior is greedy and selects the first "
588-
"matching branch instead of enforcing oneOf exclusivity"
589-
),
590-
strict=True,
591-
)
592585
def test_request_body_urlencoded_oneof_rejects_ambiguous_matches(self):
593586
from openapi_core import OpenAPI
594587

tests/unit/deserializing/test_media_types_deserializers.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,38 @@ def test_urlencoded_allof_typeless_fragments(
721721
"b": True,
722722
}
723723

724+
def test_urlencoded_oneof_does_not_match_ambiguous_fields(
725+
self, spec, deserializer_factory
726+
):
727+
mimetype = "application/x-www-form-urlencoded"
728+
schema_dict = {
729+
"oneOf": [
730+
{
731+
"type": "object",
732+
"required": ["a"],
733+
"properties": {
734+
"a": {"type": "string"},
735+
},
736+
},
737+
{
738+
"type": "object",
739+
"required": ["b"],
740+
"properties": {
741+
"b": {"type": "string"},
742+
},
743+
},
744+
]
745+
}
746+
schema = SchemaPath.from_dict(schema_dict)
747+
schema_validator = oas31_schema_validators_factory.create(spec, schema)
748+
deserializer = deserializer_factory(
749+
mimetype, schema=schema, schema_validator=schema_validator
750+
)
751+
752+
result = deserializer.deserialize(b"a=x&b=y")
753+
754+
assert result == {}
755+
724756
def test_multipart_oneof_binary_field(self, spec, deserializer_factory):
725757
mimetype = "multipart/form-data"
726758
schema_dict = {

0 commit comments

Comments
 (0)