diff --git a/openapi_core/casting/schemas/__init__.py b/openapi_core/casting/schemas/__init__.py index 39c14a4e..1becd642 100644 --- a/openapi_core/casting/schemas/__init__.py +++ b/openapi_core/casting/schemas/__init__.py @@ -1,5 +1,6 @@ from collections import OrderedDict +from openapi_core.casting.schemas.casters import AnyCaster from openapi_core.casting.schemas.casters import ArrayCaster from openapi_core.casting.schemas.casters import BooleanCaster from openapi_core.casting.schemas.casters import IntegerCaster @@ -43,11 +44,11 @@ oas30_types_caster = TypesCaster( oas30_casters_dict, - PrimitiveCaster, + AnyCaster, ) oas31_types_caster = TypesCaster( oas31_casters_dict, - PrimitiveCaster, + AnyCaster, multi=PrimitiveCaster, ) oas32_types_caster = oas31_types_caster diff --git a/openapi_core/casting/schemas/casters.py b/openapi_core/casting/schemas/casters.py index 27e78e54..2a0fd8e8 100644 --- a/openapi_core/casting/schemas/casters.py +++ b/openapi_core/casting/schemas/casters.py @@ -40,6 +40,39 @@ def cast(self, value: Any) -> Any: return value +class AnyCaster(PrimitiveCaster): + def cast(self, value: Any) -> Any: + if "allOf" in self.schema: + for subschema in self.schema / "allOf": + try: + # Note: Mutates `value` iteratively. This sequentially + # resolves standard overlapping types but can cause edge cases + # if a string is casted to an int and passed to a string schema. + value = self.schema_caster.evolve(subschema).cast(value) + except (ValueError, TypeError, CastError): + pass + + if "oneOf" in self.schema: + for subschema in self.schema / "oneOf": + try: + # Note: Greedy resolution. Will return the first successful + # cast based on the order of the oneOf array. + return self.schema_caster.evolve(subschema).cast(value) + except (ValueError, TypeError, CastError): + pass + + if "anyOf" in self.schema: + for subschema in self.schema / "anyOf": + try: + # Note: Greedy resolution. Will return the first successful + # cast based on the order of the anyOf array. + return self.schema_caster.evolve(subschema).cast(value) + except (ValueError, TypeError, CastError): + pass + + return value + + PrimitiveType = TypeVar("PrimitiveType") diff --git a/tests/unit/casting/test_schema_casters.py b/tests/unit/casting/test_schema_casters.py index 4e765cc8..bad8098e 100644 --- a/tests/unit/casting/test_schema_casters.py +++ b/tests/unit/casting/test_schema_casters.py @@ -66,3 +66,69 @@ def test_array_invalid_value(self, value, caster_factory): CastError, match=f"Failed to cast value to array type: {value}" ): caster_factory(schema).cast(value) + + @pytest.mark.parametrize( + "composite_type,schema_type,value,expected", + [ + ("allOf", "integer", "2", 2), + ("anyOf", "number", "3.14", 3.14), + ("oneOf", "boolean", "false", False), + ("oneOf", "boolean", "true", True), + ], + ) + def test_composite_primitive( + self, caster_factory, composite_type, schema_type, value, expected + ): + spec = { + composite_type: [{"type": schema_type}], + } + schema = SchemaPath.from_dict(spec) + + result = caster_factory(schema).cast(value) + + assert result == expected + + @pytest.mark.parametrize( + "schemas,value,expected", + [ + # If string is evaluated first, it succeeds and returns string + ([{"type": "string"}, {"type": "integer"}], "123", "123"), + # If integer is evaluated first, it succeeds and returns int + ([{"type": "integer"}, {"type": "string"}], "123", 123), + ], + ) + def test_oneof_greedy_casting_edge_case( + self, caster_factory, schemas, value, expected + ): + """ + Documents the edge case that AnyCaster's oneOf/anyOf logic is greedy. + It returns the first successfully casted value based on the order in the list. + """ + spec = { + "oneOf": schemas, + } + schema = SchemaPath.from_dict(spec) + + result = caster_factory(schema).cast(value) + + assert result == expected + # Ensure exact type matches to prevent 123 == "123" test bypass issues + assert type(result) is type(expected) + + def test_allof_sequential_mutation_edge_case(self, caster_factory): + """ + Documents the edge case that AnyCaster's allOf logic sequentially mutates the value. + The first schema casts "2" to an int (2). The second schema (number) + receives the int 2, casts it to float (2.0), and returns the float. + """ + spec = { + "allOf": [{"type": "integer"}, {"type": "number"}], + } + schema = SchemaPath.from_dict(spec) + value = "2" + + result = caster_factory(schema).cast(value) + + # "2" -> int(2) -> float(2.0) + assert result == 2.0 + assert type(result) is float