Skip to content

Commit ca92806

Browse files
authored
Merge pull request #1136 from python-openapi/fix/composite-schema-casting
Support parameter casting in composite schemas
2 parents 99ebe28 + 79dc69e commit ca92806

File tree

3 files changed

+102
-2
lines changed

3 files changed

+102
-2
lines changed

openapi_core/casting/schemas/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections import OrderedDict
22

3+
from openapi_core.casting.schemas.casters import AnyCaster
34
from openapi_core.casting.schemas.casters import ArrayCaster
45
from openapi_core.casting.schemas.casters import BooleanCaster
56
from openapi_core.casting.schemas.casters import IntegerCaster
@@ -43,11 +44,11 @@
4344

4445
oas30_types_caster = TypesCaster(
4546
oas30_casters_dict,
46-
PrimitiveCaster,
47+
AnyCaster,
4748
)
4849
oas31_types_caster = TypesCaster(
4950
oas31_casters_dict,
50-
PrimitiveCaster,
51+
AnyCaster,
5152
multi=PrimitiveCaster,
5253
)
5354
oas32_types_caster = oas31_types_caster

openapi_core/casting/schemas/casters.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,39 @@ def cast(self, value: Any) -> Any:
4040
return value
4141

4242

43+
class AnyCaster(PrimitiveCaster):
44+
def cast(self, value: Any) -> Any:
45+
if "allOf" in self.schema:
46+
for subschema in self.schema / "allOf":
47+
try:
48+
# Note: Mutates `value` iteratively. This sequentially
49+
# resolves standard overlapping types but can cause edge cases
50+
# if a string is casted to an int and passed to a string schema.
51+
value = self.schema_caster.evolve(subschema).cast(value)
52+
except (ValueError, TypeError, CastError):
53+
pass
54+
55+
if "oneOf" in self.schema:
56+
for subschema in self.schema / "oneOf":
57+
try:
58+
# Note: Greedy resolution. Will return the first successful
59+
# cast based on the order of the oneOf array.
60+
return self.schema_caster.evolve(subschema).cast(value)
61+
except (ValueError, TypeError, CastError):
62+
pass
63+
64+
if "anyOf" in self.schema:
65+
for subschema in self.schema / "anyOf":
66+
try:
67+
# Note: Greedy resolution. Will return the first successful
68+
# cast based on the order of the anyOf array.
69+
return self.schema_caster.evolve(subschema).cast(value)
70+
except (ValueError, TypeError, CastError):
71+
pass
72+
73+
return value
74+
75+
4376
PrimitiveType = TypeVar("PrimitiveType")
4477

4578

tests/unit/casting/test_schema_casters.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,69 @@ def test_array_invalid_value(self, value, caster_factory):
6666
CastError, match=f"Failed to cast value to array type: {value}"
6767
):
6868
caster_factory(schema).cast(value)
69+
70+
@pytest.mark.parametrize(
71+
"composite_type,schema_type,value,expected",
72+
[
73+
("allOf", "integer", "2", 2),
74+
("anyOf", "number", "3.14", 3.14),
75+
("oneOf", "boolean", "false", False),
76+
("oneOf", "boolean", "true", True),
77+
],
78+
)
79+
def test_composite_primitive(
80+
self, caster_factory, composite_type, schema_type, value, expected
81+
):
82+
spec = {
83+
composite_type: [{"type": schema_type}],
84+
}
85+
schema = SchemaPath.from_dict(spec)
86+
87+
result = caster_factory(schema).cast(value)
88+
89+
assert result == expected
90+
91+
@pytest.mark.parametrize(
92+
"schemas,value,expected",
93+
[
94+
# If string is evaluated first, it succeeds and returns string
95+
([{"type": "string"}, {"type": "integer"}], "123", "123"),
96+
# If integer is evaluated first, it succeeds and returns int
97+
([{"type": "integer"}, {"type": "string"}], "123", 123),
98+
],
99+
)
100+
def test_oneof_greedy_casting_edge_case(
101+
self, caster_factory, schemas, value, expected
102+
):
103+
"""
104+
Documents the edge case that AnyCaster's oneOf/anyOf logic is greedy.
105+
It returns the first successfully casted value based on the order in the list.
106+
"""
107+
spec = {
108+
"oneOf": schemas,
109+
}
110+
schema = SchemaPath.from_dict(spec)
111+
112+
result = caster_factory(schema).cast(value)
113+
114+
assert result == expected
115+
# Ensure exact type matches to prevent 123 == "123" test bypass issues
116+
assert type(result) is type(expected)
117+
118+
def test_allof_sequential_mutation_edge_case(self, caster_factory):
119+
"""
120+
Documents the edge case that AnyCaster's allOf logic sequentially mutates the value.
121+
The first schema casts "2" to an int (2). The second schema (number)
122+
receives the int 2, casts it to float (2.0), and returns the float.
123+
"""
124+
spec = {
125+
"allOf": [{"type": "integer"}, {"type": "number"}],
126+
}
127+
schema = SchemaPath.from_dict(spec)
128+
value = "2"
129+
130+
result = caster_factory(schema).cast(value)
131+
132+
# "2" -> int(2) -> float(2.0)
133+
assert result == 2.0
134+
assert type(result) is float

0 commit comments

Comments
 (0)