How to render constraint error as JSON in create action in controller?

I tired the following but getting error:

(Poison.EncodeError) unable to encode value: {"is a duplicate", []}

Code:

  def create(conn, %{"engagement" => engagement_params}) do
    case Engagements.create_engagement(engagement_params) do
      {:ok, engagement} ->
        conn
        |> put_flash(:info, "Engagement created successfully.")
        |> redirect(to: engagement_path(conn, :show, engagement))
      {:error, %Ecto.Changeset{} = changeset} ->
        #render(conn, "new.html", changeset: changeset)
        pretty_json(conn, changeset)
    end
  end

  def pretty_json(conn, data) do
    conn
    |> Plug.Conn.put_resp_header("content-type", "application/json; charset=utf-8")
    |> Plug.Conn.send_resp(200, Poison.encode!(data, pretty: true))
  end

The error is because in the changeset there’s a tuple somewhere (probably the error field?) and Poison doesn’t know how to encode that. You can write a function to extract the relevant bits.

FYI perhaps there’s already something to deal with this, like the json views?

Poison cant encode lists. I am not sure maybe tuples too. I switched to elixir json for that.

1 Like

wouldn’t you do something like:

%{errors: MyApp.Web.ChangesetView.translate_errors(changeset)}

eg:

{:error, %Ecto.Changeset{} = changeset} ->
        #render(conn, "new.html", changeset: changeset)
        pretty_json(conn, %{errors: MyApp.Web.ChangesetView.translate_errors(changeset)})

additionally shouldn’t your api return 422 or similar on errors? (not 200)

1 Like

I have managed to render the errors as JSON like this:

a. added the following to error_view.ex:

  def translate_errors(changeset) do
    Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
  end

  def render("error.json", %{changeset: changeset}) do
    # When encoded, the changeset returns its errors
    # as a JSON object. So we just pass it forward.
    %{errors: translate_errors(changeset)}
  end

My create action in controller looks like this now:

  def create(conn, %{"engagement" => engagement_params}) do
    case Engagements.create_engagement(engagement_params) do
      {:ok, engagement} ->
        conn
        |> put_flash(:info, "Engagement created successfully.")
        |> redirect(to: engagement_path(conn, :show, engagement))
      {:error, %Ecto.Changeset{} = changeset} ->
        #render(conn, "new.html", changeset: changeset)
        conn
        |> put_status(:unprocessable_entity)
        |> render(HubWeb.ErrorView, "error.json", changeset: changeset)
    end
  end

Next I will be looking for a way to provide localized translations for those error messages.

12 Likes

Thank you so much for this!

2 Likes

Thank you :slight_smile:

1 Like

Thank you. This helped me derive the code I needed using a FallbackController approach:

defmodule HubWeb.FallbackController do
  use HubWeb, :controller

  def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
    conn
    |> put_status(:unprocessable_entity)
    |> put_view(HubWeb.ChangesetView)
    |> render("error.json", changeset: changeset)
  end
end
4 Likes

In Phoenix 1.7 latest:

def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
  conn
  |> put_status(:unprocessable_entity)
  |> put_view(json: MyAppWeb.ChangesetJSON)
  |> render("error.json", changeset: changeset)
end

The ChangesetJSON file automatically generated for me when using the generators, but just in case here’s what it looks like:

defmodule MyApp.ChangesetJSON do
  @doc """
  Renders changeset errors.
  """
  def error(%{changeset: changeset}) do
    # When encoded, the changeset returns its errors
    # as a JSON object. So we just pass it forward.
    %{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
  end

  defp translate_error({msg, opts}) do
    # You can make use of gettext to translate error messages by
    # uncommenting and adjusting the following code:

    # if count = opts[:count] do
    #   Gettext.dngettext(MyAppWeb.Gettext, "errors", msg, msg, count, opts)
    # else
    #   Gettext.dgettext(MyAppWeb.Gettext, "errors", msg, opts)
    # end

    Enum.reduce(opts, msg, fn {key, value}, acc ->
      String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
    end)
  end
end

This allows me to return changeset’s nicely from my APIs:

def register(conn, params) do
  with {:ok, user} <- Accounts.register_user(params) do
    conn
    |> put_status(:ok)
    |> put_view(MyAppWeb.AccountsJSON)
    |> render(:register, user: user)
  else
    error ->
      error
  end
end
1 Like