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