defmodule Solicit.ResponseError do @moduledoc """ An error in the JSON body of an HTTP response. """ alias Ecto.Changeset alias __MODULE__ defstruct code: "", description: "", field: nil @type t :: %__MODULE__{} @spec generic_error(binary()) :: ResponseError.t() def generic_error(description \\ "An unknown error occurred."), do: %ResponseError{code: :error, description: description} @spec bad_request(binary()) :: ResponseError.t() def bad_request(description \\ "Bad request."), do: %ResponseError{code: :bad_request, description: description} @spec forbidden(binary()) :: ResponseError.t() def forbidden(description \\ "This action is forbidden."), do: %ResponseError{code: :forbidden, description: description} @spec not_found(binary()) :: ResponseError.t() def not_found(description \\ "The resource was not found."), do: %ResponseError{code: :not_found, description: description} @spec timeout(binary()) :: ResponseError.t() def timeout(description \\ "Request timed out."), do: %ResponseError{code: :timeout, description: description} @spec unprocessable_entity(binary()) :: ResponseError.t() def unprocessable_entity(description \\ "Unable to process change.") when is_binary(description), do: %ResponseError{code: :unprocessable_entity, description: description} @spec conflict(binary()) :: ResponseError.t() def conflict(description \\ "A conflict has occurred."), do: %ResponseError{code: :conflict, description: description} @spec unauthorized(binary()) :: ResponseError.t() def unauthorized(description \\ "Must include valid Authorization credentials"), do: %ResponseError{ code: :unauthorized, description: description } @spec method_not_allowed(binary()) :: ResponseError.t() def method_not_allowed(description \\ "Method is not allowed."), do: %ResponseError{ code: :method_not_allowed, description: description } @spec internal_server_error(binary()) :: ResponseError.t() def internal_server_error(description \\ "Internal Server Error"), do: %ResponseError{code: :internal_server_error, description: description} @spec bad_gateway(binary()) :: ResponseError.t() def bad_gateway(description \\ "Bad Gateway"), do: %ResponseError{code: :bad_gateway, description: description} @spec service_unavailable(binary()) :: ResponseError.t() def service_unavailable(description \\ "Service Unavailable"), do: %ResponseError{code: :service_unavailable, description: description} @spec too_many_requests(binary()) :: ResponseError.t() def too_many_requests(description \\ "Exceeded request threshold."), do: %ResponseError{ code: :too_many_requests, description: description } @spec gone(binary()) :: ResponseError.t() def gone(description \\ "Access to resource is no longer available."), do: %ResponseError{code: :gone, description: description} @spec request_entity_too_large(binary()) :: ResponseError.t() def request_entity_too_large(description \\ "Request entity is too large."), do: %ResponseError{code: :request_entity_too_large, description: description} @spec unsupported_media_type(binary()) :: ResponseError.t() def unsupported_media_type(description \\ "Request contains an unsupported media type."), do: %ResponseError{code: :unsupported_media_type, description: description} @doc """ Given an Ecto Changeset with errors, convert the errors into a list of ResponseError objects. """ @spec from_changeset(Changeset.t(), String.t() | nil) :: list(ResponseError.t()) def from_changeset(%Changeset{errors: errors} = c, field_prefix \\ nil) do # Attempt to get an ResponseError from a changeset errors keyword list entry # Remove all nils (failed ResponseError conversions) from the list errors |> Enum.map(&from_changeset_error/1) |> Enum.filter(& &1) |> Enum.concat(nested_errors(c)) |> Enum.map(&prefix_field_name(&1, field_prefix)) end @spec nested_errors(Ecto.Changeset.t()) :: list(ResponseError.t()) defp nested_errors(%Changeset{changes: changes}) do Enum.flat_map(changes, fn {key, %Changeset{} = c} -> from_changeset(c, Atom.to_string(key)) {key, changes} when is_list(changes) -> nested_errors(Atom.to_string(key), changes) _ -> [] end) end @spec nested_errors(any(), list()) :: list(ResponseError.t()) defp nested_errors(_, []), do: [] defp nested_errors(key, changes) when is_binary(key) and is_list(changes) and length(changes) > 0 do length = length(changes) Enum.flat_map(0..(length - 1), fn index -> change = Enum.at(changes, index) case change do %Changeset{} = c -> from_changeset(c, "#{key}.#{index}") _ -> [] end end) end @spec prefix_field_name(ResponseError.t(), nil | String.t()) :: ResponseError.t() defp prefix_field_name(%ResponseError{} = a, nil), do: a defp prefix_field_name(%ResponseError{field: field} = a, prefix) when is_binary(field) and is_binary(prefix) do %{a | field: "#{prefix}.#{field}"} end @doc """ Given a keyword list entry, attempt to turn it into an ResponseError. Assumption: All Changeset errors are going to relate to a specific database field. """ @spec from_changeset_error(tuple()) :: ResponseError.t() | tuple() def from_changeset_error({field, error}) do {code, description} = Solicit.Changeset.code_and_description(error) if code && description do field = if is_atom(field), do: Atom.to_string(field), else: field %ResponseError{ field: field, code: code, description: description } end end end defimpl Jason.Encoder, for: Solicit.ResponseError do alias Solicit.ResponseError def encode(%ResponseError{} = error, opts) do take = case error do %{field: nil} -> ~w(code description)a _ -> ~w(field code description)a end error |> Map.take(take) |> Jason.Encode.map(opts) end end