Skip to content

Commit

Permalink
Add support for nested changeset errors (#29)
Browse files Browse the repository at this point in the history
* Use traverse_errors to get errors from changeset

* Support nested errors in changesets

* iUse dev version of Jabbax

* Add comment to traverse_errors and the typespec

* Disable credo check

* Use Jabbax 0.2.0

* Bump version and add changelog entry
  • Loading branch information
katafrakt authored Jun 24, 2021
1 parent e594664 commit 628bfe1
Show file tree
Hide file tree
Showing 5 changed files with 118 additions and 29 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 4.2.0

- Added support for translating errors in nested changeset to JSON API responses

## 4.0.0

- Removed support for AppSignal
Expand Down
36 changes: 28 additions & 8 deletions lib/surgex/changeset/changeset.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,45 @@ case Code.ensure_loaded(Jabbax) do
Builds Jabbax document that describes changeset errors.
"""
def build_errors_document(changeset) do
%Document{errors: build_errors(changeset)}
# According to the typespec a function passed to traverse_errors, but we choose to ignore
# as it does not seem to be justified.
# credo:disable-for-next-line Credo.Check.Design.AliasUsage
errors_map = Ecto.Changeset.traverse_errors(changeset, & &1)
%Document{errors: build_errors(errors_map)}
end

defp build_errors(_changeset = %{errors: errors}) do
Enum.map(errors, &build_error/1)
defp build_errors(map, prefixes \\ []) do
map
|> Enum.map(&build_error(&1, prefixes))
|> List.flatten()
end

defp build_error({field, {text, info}}) do
%Error{
code: get_error_code(text, info[:validation]),
source: ErrorSource.from_attribute(field)
}
defp build_error({field, map}, prefixes) when is_map(map) do
build_errors(map, [field | prefixes])
end

defp build_error({field, list}, prefixes) do
Enum.map(list, fn {text, info} ->
%Error{
code: get_error_code(text, info[:validation]),
source: build_error_source(field, prefixes)
}
end)
end

defp get_error_code("has already been taken", nil), do: "taken"
defp get_error_code(_, :required), do: "required"
defp get_error_code(_, nil), do: "invalid"
defp get_error_code(_, :cast), do: "invalid"
defp get_error_code(_, suffix), do: "invalid_#{suffix}"

defp build_error_source(field, []), do: ErrorSource.from_attribute(field)

defp build_error_source(field, prefixes) do
[field | prefixes]
|> Enum.reverse()
|> ErrorSource.from_attribute()
end
end

_ ->
Expand Down
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Surgex.Mixfile do
def project do
[
app: :surgex,
version: "4.1.1",
version: "4.2.0",
elixir: "~> 1.4",
elixirc_paths: elixirc_paths(Mix.env()),
build_embedded: Mix.env() == :prod,
Expand Down Expand Up @@ -67,7 +67,7 @@ defmodule Surgex.Mixfile do
[
{:confix, "~> 0.4"},
{:ecto_sql, "~> 3.0"},
{:jabbax, "~> 0.1"},
{:jabbax, "~> 0.2"},
{:plug, "~> 1.7"}
]
|> Enum.map(&merge_dep_flags(&1, optional: true))
Expand Down
10 changes: 5 additions & 5 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,22 @@
"hackney": {:hex, :hackney, "1.17.0", "717ea195fd2f898d9fe9f1ce0afcc2621a41ecfe137fae57e7fe6e9484b9aa99", [:rebar3], [{:certifi, "~>2.5", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "64c22225f1ea8855f584720c0e5b3cd14095703af1c9fbc845ba042811dc671c"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"inch_ex": {:hex, :inch_ex, "0.5.6", "418357418a553baa6d04eccd1b44171936817db61f4c0840112b420b8e378e67", [:mix], [{:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "7123ca0450686a61416a06cd38e26af18fd0f8c1cff5214770a957c6e0724338"},
"jabbax": {:hex, :jabbax, "0.1.0", "010dc5f3581e6167fcbd6bb08cb1717e5ff0df5d2e8eb50abcb345c3afbdfb19", [:mix], [{:plug, "~> 1.3.2 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "4db4f8ce29a08b5ee67060c8e4d57944ce13c997ea73d7f132f57692b6b70d1d"},
"jabbax": {:hex, :jabbax, "0.2.0", "e532a24d7d364b61832b1182b7683f2d71807690ec1ab6208c08503bd1667d7a", [:mix], [{:plug, "~> 1.3.2 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "bf86a5430bbea2ead98f7d4c15ea7aa9ceb2c764d350e8beb61a4dc91e18464c"},
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
"makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"},
"makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"},
"meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"},
"mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"mock": {:hex, :mock, "0.2.1", "bfdba786903e77f9c18772dee472d020ceb8ef000783e737725a4c8f54ad28ec", [:mix], [{:meck, "~> 0.8.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "d440b817e4e3505fe2d17963b7e783ae72875011dc6ac757c21904a8cc3578ac"},
"nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"},
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"plug": {:hex, :plug, "1.11.0", "f17217525597628298998bc3baed9f8ea1fa3f1160aa9871aee6df47a6e4d38e", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2d9c633f0499f9dc5c2fd069161af4e2e7756890b81adcbb2ceaa074e8308876"},
"plug_crypto": {:hex, :plug_crypto, "1.2.0", "1cb20793aa63a6c619dd18bb33d7a3aa94818e5fd39ad357051a67f26dfa2df6", [:mix], [], "hexpm", "a48b538ae8bf381ffac344520755f3007cc10bd8e90b240af98ea29b69683fc2"},
"plug": {:hex, :plug, "1.11.1", "f2992bac66fdae679453c9e86134a4201f6f43a687d8ff1cd1b2862d53c80259", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "23524e4fefbb587c11f0833b3910bfb414bf2e2534d61928e920f54e3a1b881f"},
"plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
"poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"},
"postgrex": {:hex, :postgrex, "0.15.8", "f5e782bbe5e8fa178d5e3cd1999c857dc48eda95f0a4d7f7bd92a50e84a0d491", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "698fbfacea34c4cf22c8281abeb5cf68d99628d541874f085520ab3b53d356fe"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},
"telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
}
93 changes: 79 additions & 14 deletions test/surgex/changeset/changeset_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,97 @@ defmodule Surgex.ChangesetTest do
use ExUnit.Case
alias Surgex.Changeset

import Ecto.Changeset, only: [change: 2, add_error: 3, add_error: 4]

defmodule TestSchema do
use Ecto.Schema
import Ecto.Changeset

embedded_schema do
field(:name, :string)

embeds_one :address, TestAddress do
field(:city, :string)
field(:street, :string)

embeds_one :country, TestCountry do
field(:code, :string)
end
end
end

def changeset(schema, params) do
schema
|> cast(params, [:name])
|> cast_embed(:address, with: &address_changeset/2)
end

defp address_changeset(schema, params) do
schema
|> cast(params, [:city, :street])
|> validate_required([:city])
|> cast_embed(:country, with: &country_changeset/2)
end

defp country_changeset(schema, params) do
schema
|> cast(params, [:code])
|> validate_inclusion(:code, ["us", "ca", "pl"])
end
end

test "changeset with all possible errors" do
assert Changeset.build_errors_document(%{
errors: [
taken_field: {"has already been taken", nil},
required_field: {nil, [validation: :required]},
invalid_field: {nil, nil},
invalid_type_field: {nil, [validation: :type]}
]
}) == %Jabbax.Document{
changeset =
%TestSchema{}
|> change(%{})
|> add_error(:taken_field, "has already been taken")
|> add_error(:required_field, "", validation: :required)
|> add_error(:invalid_type_field, "", validation: :type)
|> add_error(:invalid_field, "")

assert Changeset.build_errors_document(changeset) == %Jabbax.Document{
errors: [
%Jabbax.Document.Error{
code: "taken",
source: %Jabbax.Document.ErrorSource{pointer: "/data/attributes/taken_field"}
code: "invalid",
source: %Jabbax.Document.ErrorSource{pointer: "/data/attributes/invalid_field"}
},
%Jabbax.Document.Error{
code: "invalid_type",
source: %Jabbax.Document.ErrorSource{
pointer: "/data/attributes/invalid_type_field"
}
},
%Jabbax.Document.Error{
code: "required",
source: %Jabbax.Document.ErrorSource{pointer: "/data/attributes/required_field"}
},
%Jabbax.Document.Error{
code: "invalid",
source: %Jabbax.Document.ErrorSource{pointer: "/data/attributes/invalid_field"}
code: "taken",
source: %Jabbax.Document.ErrorSource{pointer: "/data/attributes/taken_field"}
}
]
}
end

test "changeset with nested errors" do
changeset =
TestSchema.changeset(%TestSchema{}, %{
name: "test",
address: %{street: "Main", country: %{code: "es"}}
})

assert Changeset.build_errors_document(changeset) == %Jabbax.Document{
errors: [
%Jabbax.Document.Error{
code: "required",
source: %Jabbax.Document.ErrorSource{
pointer: "/data/attributes/address/city"
}
},
%Jabbax.Document.Error{
code: "invalid_type",
code: "invalid_inclusion",
source: %Jabbax.Document.ErrorSource{
pointer: "/data/attributes/invalid_type_field"
pointer: "/data/attributes/address/country/code"
}
}
]
Expand Down

0 comments on commit 628bfe1

Please sign in to comment.