From d71ebade9a88b4e78a9208cb460e6b687614efd8 Mon Sep 17 00:00:00 2001 From: Majd Sehwail <56893463+MajdSehwail@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:29:01 +0300 Subject: [PATCH] Fix Protocol.UndefinedError when composite casts merge struct results Cast.AllOf merges member results with Utils.merge_maps. A oneOf or discriminator member rebuilds the matched branch as a struct, and reducing over a struct raises Protocol.UndefinedError, so any allOf wrapper around a struct-building union crashes the whole cast. merge_maps now demotes struct arguments to plain maps first; this also keeps the anyOf accumulator safe should its struct short-circuit change. Co-Authored-By: Claude Fable 5 --- lib/open_api_spex/cast/utils.ex | 8 +++++++- test/cast/all_of_test.exs | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/open_api_spex/cast/utils.ex b/lib/open_api_spex/cast/utils.ex index 6c3e3923..084a9ca9 100644 --- a/lib/open_api_spex/cast/utils.ex +++ b/lib/open_api_spex/cast/utils.ex @@ -5,8 +5,11 @@ defmodule OpenApiSpex.Cast.Utils do # Merge 2 maps considering as equal keys that are atom or string representation # of that atom. Atom keys takes precedence over string ones. + # Struct arguments are demoted to plain maps first: composite casts + # (`allOf`/`anyOf`) can receive struct results from `oneOf`/discriminator + # members, and reducing over a struct raises `Protocol.UndefinedError`. def merge_maps(map1, map2) do - result = Map.merge(map1, map2) + result = Map.merge(plain_map(map1), plain_map(map2)) Enum.reduce(result, result, fn {k, _v}, result when is_atom(k) -> Map.delete(result, to_string(k)) @@ -14,6 +17,9 @@ defmodule OpenApiSpex.Cast.Utils do end) end + defp plain_map(%_{} = struct), do: Map.from_struct(struct) + defp plain_map(map), do: map + def check_required_fields(%{value: input_map} = ctx), do: check_required_fields(ctx, input_map) def check_required_fields(ctx, %{} = input_map) do diff --git a/test/cast/all_of_test.exs b/test/cast/all_of_test.exs index 9f2fbabe..ce6cb7aa 100644 --- a/test/cast/all_of_test.exs +++ b/test/cast/all_of_test.exs @@ -34,6 +34,14 @@ defmodule OpenApiSpex.CastAllOfTest do "Failed to cast value as Age. Value must be castable using `allOf` schemas listed." end + test "allOf wrapper around a oneOf member whose branch casts to a struct" do + union = %Schema{oneOf: [OpenApiSpexTest.Schemas.User.schema()]} + schema = %Schema{allOf: [union]} + value = %{"name" => "Joe", "email" => "joe@example.com", "password" => "12345678"} + + assert {:ok, %{name: "Joe"}} = cast(value: value, schema: schema) + end + test "a more sophisticated example" do dog = %{"bark" => "woof", "pet_type" => "Dog"} TestAssertions.assert_schema(dog, "Dog", OpenApiSpexTest.ApiSpec.spec())