diff --git a/apps/blunt_absinthe/lib/blunt/absinthe/absinthe_errors.ex b/apps/blunt_absinthe/lib/blunt/absinthe/absinthe_errors.ex index 0578466..02f7a2c 100644 --- a/apps/blunt_absinthe/lib/blunt/absinthe/absinthe_errors.ex +++ b/apps/blunt_absinthe/lib/blunt/absinthe/absinthe_errors.ex @@ -27,11 +27,25 @@ defmodule Blunt.Absinthe.AbsintheErrors do Enum.map(messages, fn message -> [message: message] end) ++ acc {key, messages}, acc when is_list(messages) or is_map(messages) -> - Enum.map(messages, fn message -> [message: "#{key} #{message}"] end) ++ acc + Enum.flat_map(leaves_with_path(messages, [key]), fn + {path, messages} -> + path = Enum.map(path, &to_string/1) + label = Enum.join(path, ".") + message = messages |> List.wrap() |> Enum.join(", ") + + [[message: "#{label} #{message}", path: path]] + end) ++ acc {key, message}, acc when is_binary(message) -> - [[message: "#{key} #{message}"] | acc] + [[message: "#{key} #{message}", path: [key]] | acc] end) |> Enum.map(&Keyword.merge(&1, extra_properties)) end + + defp leaves_with_path(input, path \\ []) do + Enum.flat_map(input, fn {key, value} -> + full = [key | path] + if is_map(value), do: leaves_with_path(value, full), else: [{Enum.reverse(full), value}] + end) + end end diff --git a/apps/blunt_absinthe/lib/blunt/absinthe/field.ex b/apps/blunt_absinthe/lib/blunt/absinthe/field.ex index 25c180b..9fb2ec5 100644 --- a/apps/blunt_absinthe/lib/blunt/absinthe/field.ex +++ b/apps/blunt_absinthe/lib/blunt/absinthe/field.ex @@ -86,6 +86,8 @@ defmodule Blunt.Absinthe.Field do def dispatch_and_resolve(operation, message_module, query_opts, parent, args, resolution) do context_configuration = DispatchContextConfiguration.configure(message_module, resolution) + input_object = Keyword.get(query_opts, :input_object, false) || Keyword.get(query_opts, :input_object?, false) + args = Map.get(args, :input, args) opts = @@ -112,6 +114,8 @@ defmodule Blunt.Absinthe.Field do return_value {:error, errors} when is_map(errors) -> + errors = if input_object, do: %{input: errors}, else: errors + {:error, AbsintheErrors.format(errors)} {:ok, %Context{} = context} -> diff --git a/apps/blunt_absinthe/test/blunt/absinthe/mutation_test.exs b/apps/blunt_absinthe/test/blunt/absinthe/mutation_test.exs index 87ee3ac..222830c 100644 --- a/apps/blunt_absinthe/test/blunt/absinthe/mutation_test.exs +++ b/apps/blunt_absinthe/test/blunt/absinthe/mutation_test.exs @@ -9,11 +9,28 @@ defmodule Blunt.Absinthe.MutationTest do setup do %{ query: """ - mutation create($name: String!, $gender: Gender!){ - createPerson(name: $name, gender: $gender){ + mutation create($name: String!, $gender: Gender!, $address: AddressInput){ + createPerson(name: $name, gender: $gender, address: $address){ id name gender + address { + line1 + line2 + } + } + } + """, + update_query: """ + mutation update($input: UpdatePersonInput){ + updatePerson(input: $input){ + id + name + gender + address { + line1 + line2 + } } } """ @@ -34,12 +51,48 @@ defmodule Blunt.Absinthe.MutationTest do test "can create a person", %{query: query} do assert {:ok, %{data: %{"createPerson" => person}}} = - Absinthe.run(query, Schema, variables: %{"name" => "chris", "gender" => "MALE"}) + Absinthe.run(query, Schema, + variables: %{"name" => "chris", "gender" => "MALE", "address" => %{"line1" => "42 Infinity Ave"}} + ) - assert %{"id" => id, "name" => "chris", "gender" => "MALE"} = person + assert %{"id" => id, "name" => "chris", "gender" => "MALE", "address" => %{"line1" => "42 Infinity Ave"}} = person assert {:ok, _} = UUID.info(id) end + test "returns errors", %{query: query} do + assert {:ok, %{errors: [%{message: message}]}} = + Absinthe.run(query, Schema, variables: %{"name" => "chris", "gender" => ""}) + + assert message =~ "gender" + end + + test "returns errors for absinthe type mismatch", %{query: query} do + assert {:ok, %{errors: [%{message: message}]}} = + Absinthe.run(query, Schema, + variables: %{ + "name" => "chris", + "gender" => "MALE", + "address" => %{} + } + ) + + assert message =~ ~r/address.*\n.*line1.*found null/ + end + + test "returns errors from nested changeset validations", %{query: query} do + assert {:ok, %{errors: [%{message: message, path: path}]}} = + Absinthe.run(query, Schema, + variables: %{ + "name" => "chris", + "gender" => "MALE", + "address" => %{"line1" => "10"} + } + ) + + assert message =~ "address.line1 should be at least 3 character(s)" + assert path == ~w(createPerson address line1) + end + test "user is put in the context from absinthe resolution context", %{query: query} do context = %{user: %{name: "chris"}, reply_to: self()} @@ -60,6 +113,9 @@ defmodule Blunt.Absinthe.MutationTest do }, gender: %{ type: :gender + }, + address: %{ + type: :address_input } } = fields end @@ -68,4 +124,59 @@ defmodule Blunt.Absinthe.MutationTest do assert %Object{fields: fields} = Absinthe.Schema.lookup_type(Schema, :dog) assert %{name: %{type: :string}} = fields end + + test "returns errors from deeper nested changeset validations", %{update_query: update_query} do + assert {:ok, %{errors: [%{message: message, path: path}]}} = + Absinthe.run(update_query, Schema, + variables: %{ + "input" => %{ + "id" => UUID.uuid4(), + "name" => "chris", + "gender" => "MALE", + "address" => %{"line1" => "--"} + } + } + ) + + assert message =~ "address.line1 should start with a number, should be at least 3 character(s)" + assert path == ~w(updatePerson input address line1) + end + + test "paths" do + errors = %{ + a: %{ + b: %{ + c: %{ + d: "broken" + }, + cc: "fixed", + ccc: "unknown" + }, + tree: ["trunk", "branches"] + } + } + + {:ok, context} = DispatchContext.new(%Blunt.Absinthe.Test.CreatePerson{}, []) + context = DispatchContext.put_error(context, errors) + %{id: dispatch_id} = context + + assert [ + [message: "a.b.c.d broken", path: ~w(a b c d), dispatch_id: ^dispatch_id], + [message: "a.b.cc fixed", path: ~w(a b cc), dispatch_id: ^dispatch_id], + [message: "a.b.ccc unknown", path: ~w(a b ccc), dispatch_id: ^dispatch_id], + [message: "a.tree trunk, branches", path: ~w(a tree), dispatch_id: ^dispatch_id] + ] = Blunt.Absinthe.AbsintheErrors.from_dispatch_context(context) + end + + test "format errors with key in message" do + errors = %{input: %{person: %{address: %{stuff: %{thing: "everything is b0rked"}}}}} + + assert [ + [ + message: "input.person.address.stuff.thing everything is b0rked", + path: ~w( input person address stuff thing ), + dispatch_id: "23424234" + ] + ] = Blunt.Absinthe.AbsintheErrors.format(errors, dispatch_id: "23424234") + end end diff --git a/apps/blunt_absinthe/test/support/address.ex b/apps/blunt_absinthe/test/support/address.ex new file mode 100644 index 0000000..5550b7b --- /dev/null +++ b/apps/blunt_absinthe/test/support/address.ex @@ -0,0 +1,14 @@ +defmodule Blunt.Absinthe.Test.Address do + use Blunt.ValueObject + import Ecto.Changeset + + field :line1, :string, required: true + field :line2, :string + + @impl true + def handle_validate(changeset, _opts) do + changeset + |> validate_length(:line1, min: 3) + |> validate_format(:line1, ~r/(\d)+.*/, message: "should start with a number") + end +end diff --git a/apps/blunt_absinthe/test/support/create_person.ex b/apps/blunt_absinthe/test/support/create_person.ex index 33143a3..c5c82c0 100644 --- a/apps/blunt_absinthe/test/support/create_person.ex +++ b/apps/blunt_absinthe/test/support/create_person.ex @@ -8,6 +8,7 @@ defmodule Blunt.Absinthe.Test.CreatePerson do field :name, :string field :gender, :enum, values: Person.genders(), default: :not_sure + field :address, Blunt.Absinthe.Test.Address, required: false internal_field :id, :binary_id, desc: "Id is set internally. Setting it will have no effect" diff --git a/apps/blunt_absinthe/test/support/create_person_handler.ex b/apps/blunt_absinthe/test/support/create_person_handler.ex index 7568c83..c32f64e 100644 --- a/apps/blunt_absinthe/test/support/create_person_handler.ex +++ b/apps/blunt_absinthe/test/support/create_person_handler.ex @@ -8,7 +8,11 @@ defmodule Blunt.Absinthe.Test.CreatePersonHandler do def handle_dispatch(command, _context) do command |> Map.from_struct() + |> Map.update!(:address, &to_map/1) |> Person.changeset() |> Repo.insert() end + + defp to_map(nil), do: nil + defp to_map(value), do: Map.from_struct(value) end diff --git a/apps/blunt_absinthe/test/support/get_person.ex b/apps/blunt_absinthe/test/support/get_person.ex index 38915c4..1603491 100644 --- a/apps/blunt_absinthe/test/support/get_person.ex +++ b/apps/blunt_absinthe/test/support/get_person.ex @@ -9,5 +9,5 @@ defmodule Blunt.Absinthe.Test.GetPerson do field :error_out, :boolean, default: false - binding :person, BluntBoundedContext.QueryTest.ReadModel.Person + binding :person, Blunt.Absinthe.Test.ReadModel.Person end diff --git a/apps/blunt_absinthe/test/support/read_model.ex b/apps/blunt_absinthe/test/support/read_model.ex index 6f6a762..4b6807c 100644 --- a/apps/blunt_absinthe/test/support/read_model.ex +++ b/apps/blunt_absinthe/test/support/read_model.ex @@ -9,12 +9,23 @@ defmodule Blunt.Absinthe.Test.ReadModel do schema "people" do field :name, :string field :gender, Ecto.Enum, values: @genders, default: :not_sure + + embeds_one :address, Address, primary_key: false do + field :line1, :string + field :line2, :string + end end def changeset(person \\ %__MODULE__{}, attrs) do person |> Ecto.Changeset.cast(attrs, [:id, :name, :gender]) |> Ecto.Changeset.validate_required([:id, :gender]) + |> Ecto.Changeset.cast_embed(:address, with: &address_changeset/2) + end + + def address_changeset(address, attrs) do + address + |> Ecto.Changeset.cast(attrs, [:line1, :line2]) end end end diff --git a/apps/blunt_absinthe/test/support/schema_types.ex b/apps/blunt_absinthe/test/support/schema_types.ex index d3a3f5f..6c901ae 100644 --- a/apps/blunt_absinthe/test/support/schema_types.ex +++ b/apps/blunt_absinthe/test/support/schema_types.ex @@ -2,7 +2,7 @@ defmodule Blunt.Absinthe.Test.SchemaTypes do use Blunt.Absinthe use Absinthe.Schema.Notation - alias Blunt.Absinthe.Test.{CreatePerson, GetPerson, UpdatePerson, Dog} + alias Blunt.Absinthe.Test.{CreatePerson, GetPerson, UpdatePerson, Address, Dog} derive_enum :gender, {CreatePerson, :gender} @@ -10,9 +10,12 @@ defmodule Blunt.Absinthe.Test.SchemaTypes do field :id, :id field :name, :string field :gender, :gender + field :address, :address end derive_object(:dog, Dog) + derive_object(:address, Address) + derive_mutation_input Address object :person_queries do derive_query GetPerson, :person, @@ -21,10 +24,10 @@ defmodule Blunt.Absinthe.Test.SchemaTypes do ] end - derive_mutation_input(UpdatePerson, arg_types: [gender: :gender]) + derive_mutation_input(UpdatePerson, arg_types: [gender: :gender, address: :address_input]) object :person_mutations do - derive_mutation CreatePerson, :person, arg_types: [gender: :gender] + derive_mutation CreatePerson, :person, arg_types: [gender: :gender, address: :address_input] derive_mutation UpdatePerson, :person, input_object: true end end diff --git a/apps/blunt_absinthe/test/support/update_person.ex b/apps/blunt_absinthe/test/support/update_person.ex index 93c258c..e271f10 100644 --- a/apps/blunt_absinthe/test/support/update_person.ex +++ b/apps/blunt_absinthe/test/support/update_person.ex @@ -5,4 +5,5 @@ defmodule Blunt.Absinthe.Test.UpdatePerson do field :id, :binary_id field :name, :string field :gender, :enum, values: Person.genders(), required: false + field :address, Blunt.Absinthe.Test.Address, required: false end