Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions lib/ash_json_api/controllers/helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
61 changes: 61 additions & 0 deletions test/acceptance/patch_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Loading