diff --git a/lib/ash_json_api/controllers/helpers.ex b/lib/ash_json_api/controllers/helpers.ex index d7e6dea..0832f8f 100644 --- a/lib/ash_json_api/controllers/helpers.ex +++ b/lib/ash_json_api/controllers/helpers.ex @@ -448,7 +448,7 @@ defmodule AshJsonApi.Controllers.Helpers do Request.add_error(request, error, :fetch_from_path) %Ash.BulkResult{status: :error, errors: errors} -> - Request.add_error(request, errors, :update) + Request.add_error(request, strip_bulk_index_from_errors(errors), :update) end end end @@ -635,7 +635,7 @@ defmodule AshJsonApi.Controllers.Helpers do Request.add_error(request, error, :fetch_from_path) %Ash.BulkResult{status: :error, errors: errors} -> - Request.add_error(request, errors, :update) + Request.add_error(request, strip_bulk_index_from_errors(errors), :destroy) end end end @@ -1165,4 +1165,34 @@ defmodule AshJsonApi.Controllers.Helpers do {:ok, updated} end end + + # Strips the bulk operation index (0) from error paths. + # When using Ash.bulk_update/bulk_destroy for single-record operations, + # errors include a leading 0 index that should not appear in JSON:API responses. + defp strip_bulk_index_from_errors(errors) do + Enum.map(errors, &strip_bulk_index_from_single_error/1) + end + + defp strip_bulk_index_from_single_error(error) do + error + |> strip_path_from_error() + |> strip_path_from_inner_errors() + end + + defp strip_path_from_error(error) do + case Map.get(error, :path) do + [0 | rest] -> %{error | path: rest} + _ -> error + end + end + + defp strip_path_from_inner_errors(error) do + case Map.get(error, :errors) do + errors when is_list(errors) and errors != [] -> + %{error | errors: Enum.map(errors, &strip_bulk_index_from_single_error/1)} + + _ -> + error + end + end end diff --git a/test/acceptance/patch_test.exs b/test/acceptance/patch_test.exs index 76749b8..b9205b5 100644 --- a/test/acceptance/patch_test.exs +++ b/test/acceptance/patch_test.exs @@ -208,6 +208,10 @@ defmodule Test.Acceptance.PatchTest do route "/private_arg_update/:id" end + patch :validated_update do + route "/validated_update/:id" + end + related :author, :read patch_relationship :author end @@ -253,6 +257,23 @@ defmodule Test.Acceptance.PatchTest do end end + update :validated_update do + accept([:name]) + require_atomic?(false) + + validate(fn changeset, _context -> + if Ash.Changeset.changing_attribute?(changeset, :name) do + {:error, + Ash.Error.Changes.InvalidAttribute.exception( + field: :name, + message: "cannot be changed" + )} + else + :ok + end + end) + end + action :forbidden_update, :struct do constraints(instance_of: __MODULE__) argument(:id, :uuid, allow_nil?: false) @@ -885,4 +906,44 @@ defmodule Test.Acceptance.PatchTest do assert bio_content == bio.bio end end + + describe "single-record error source pointers" do + setup do + post = + Post + |> Ash.Changeset.for_create(:create, %{id: Ecto.UUID.generate(), name: "Test Post"}) + |> Ash.create!() + + %{post: post} + end + + test "validation errors do not include bulk index in source pointer", %{post: post} do + # This test verifies the fix for the bug where single-record operations + # would include a `/0/` bulk index in error source pointers. + # Before the fix: source pointer was "/data/attributes/0/name" + # After the fix: source pointer is "/data/attributes/name" + response = + Domain + |> patch( + "/posts/validated_update/#{post.id}", + %{ + data: %{ + type: "post", + attributes: %{ + name: "new_name" + } + } + }, + status: 400 + ) + + assert %{"errors" => [error]} = response.resp_body + assert error["code"] == "invalid_attribute" + + # The source pointer should NOT contain "/0/" - that's the bulk index + # which should be filtered out for single-record operations + assert error["source"]["pointer"] == "/data/attributes/name" + refute error["source"]["pointer"] =~ ~r"/\d+/" + end + end end