lib/error_message.ex

defmodule ErrorMessage do
  @moduledoc "#{File.read!("./README.md")}"

  if Enum.any?(Application.loaded_applications(), fn {dep_name, _, _} -> dep_name === :jason end) do
    @derive Jason.Encoder
  end

  @enforce_keys [:code, :message]
  defstruct [:code, :message, :details]

  @type code :: :multiple_choices
              | :moved_permanently
              | :found
              | :see_other
              | :not_modified
              | :use_proxy
              | :switch_proxy
              | :temporary_redirect
              | :permanent_redirect
              | :bad_request
              | :unauthorized
              | :payment_required
              | :forbidden
              | :not_found
              | :method_not_allowed
              | :not_acceptable
              | :proxy_authentication_required
              | :request_timeout
              | :conflict
              | :gone
              | :length_required
              | :precondition_failed
              | :request_entity_too_large
              | :request_uri_too_long
              | :unsupported_media_type
              | :requested_range_not_satisfiable
              | :expectation_failed
              | :im_a_teapot
              | :misdirected_request
              | :unprocessable_entity
              | :locked
              | :failed_dependency
              | :too_early
              | :upgrade_required
              | :precondition_required
              | :too_many_requests
              | :request_header_fields_too_large
              | :unavailable_for_legal_reasons
              | :internal_server_error
              | :not_implemented
              | :bad_gateway
              | :service_unavailable
              | :gateway_timeout
              | :http_version_not_supported
              | :variant_also_negotiates
              | :insufficient_storage
              | :loop_detected
              | :not_extended
              | :network_authentication_required

  @type t :: %ErrorMessage{code: code, message: String.t(), details: any()}
  @type t(details) :: %ErrorMessage{code: code, message: String.t(), details: details}

  @type t_map :: %{code: code, message: String.t(), details: any(), request_id: String.t()} |
                 %{code: code, message: String.t(), details: any()}

  @type t_map(details) :: %{code: code, message: String.t(), details: details, request_id: String.t()} |
                          %{code: code, message: String.t(), details: details}

  @type t_res :: {:ok, term} | {:error, t}
  @type t_res(result_type) :: {:ok, result_type} | {:error, t}
  @type t_res(result_type, details_type) :: {:ok, result_type} | {:error, t(details_type)}

  @type t_ok_res :: :ok | {:error, t}
  @type t_ok_res(details_type) :: :ok | {:error, t(details_type)}

  @http_error_codes ~w(
    multiple_choices
    moved_permanently
    found
    see_other
    not_modified
    use_proxy
    switch_proxy
    temporary_redirect
    permanent_redirect
    bad_request
    unauthorized
    payment_required
    forbidden
    not_found
    method_not_allowed
    not_acceptable
    proxy_authentication_required
    request_timeout
    conflict
    gone
    length_required
    precondition_failed
    request_entity_too_large
    request_uri_too_long
    unsupported_media_type
    requested_range_not_satisfiable
    expectation_failed
    im_a_teapot
    misdirected_request
    unprocessable_entity
    locked
    failed_dependency
    too_early
    upgrade_required
    precondition_required
    too_many_requests
    request_header_fields_too_large
    unavailable_for_legal_reasons
    internal_server_error
    not_implemented
    bad_gateway
    service_unavailable
    gateway_timeout
    http_version_not_supported
    variant_also_negotiates
    insufficient_storage
    loop_detected
    not_extended
    network_authentication_required
  )a

  for error_code <- @http_error_codes do
    @doc """
    Create #{error_code} error message for status code #{Plug.Conn.Status.code(error_code)}

    ## Example

        iex> ErrorMessage.#{error_code}("error message")
        %ErrorMessage{code: :#{error_code}, message: "error message"}
    """
    @spec unquote(error_code)(message :: String.t) :: t
    @spec unquote(error_code)(message :: String.t, details :: any) :: t
    def unquote(error_code)(message) do
      %ErrorMessage{code: unquote(error_code), message: message}
    end

    @doc """
    Create #{error_code} error message for status code #{Plug.Conn.Status.code(error_code)} with a details item which is
    passed in as the details key under the `ErrorMessage` struct

    ## Example

        iex> ErrorMessage.#{error_code}("error message", %{item: 1234})
        %ErrorMessage{code: :#{error_code}, message: "error message", details: %{item: 1234}}
    """
    def unquote(error_code)(message, details) do
      %ErrorMessage{code: unquote(error_code), message: message, details: details}
    end
  end

  @doc """
  Converts an `%ErrorMessage{}` struct to a string formatted error message

    ## Example

        iex> ErrorMessage.to_string(ErrorMessage.internal_server_error("Something bad happened", %{result: :unknown}))
        "internal_server_error - Something bad happened\\nDetails: \\n%{result: :unknown}"
  """
  @spec to_string(error_message :: t) :: String.t
  defdelegate to_string(error_message), to: ErrorMessage.Serializer

  @doc """
  Converts an `%ErrorMessage{}` struct to a map and makes sure that the
  contents of the details map can be converted to json

    ## Example

        iex> ErrorMessage.to_jsonable_map(ErrorMessage.not_found("couldn't find user", %{user_id: "as21fasdfJ"}))
        %{code: :not_found, message: "couldn't find user", details: %{user_id: "as21fasdfJ"}}

        iex> error = ErrorMessage.im_a_teapot("teapot", %{
        ...>   user: %{health: {:alive, 500}},
        ...>   test: %TestStruct{a: [Date.new!(2020, 1, 10)]}
        ...> })
        iex> ErrorMessage.to_jsonable_map(error)
        %{
          code: :im_a_teapot,
          message: "teapot",
          details: %{
            user: %{health: [:alive, 500]},
            test: %{struct: "ErrorMessageTest.TestStruct", data: %{a: ["2020-01-10"]}}
          }
        }
  """
  @spec to_jsonable_map(error_message :: t) :: t_map
  defdelegate to_jsonable_map(error_message), to: ErrorMessage.Serializer

  @doc """
  Returns the http reason as an atom for the http error code

  ## Example

      iex> ErrorMessage.http_code_reason_atom(500)
      :internal_server_error
  """
  @spec http_code_reason_atom(error_code :: non_neg_integer()) :: code
  defdelegate http_code_reason_atom(error_code), to: Plug.Conn.Status, as: :reason_atom

  @doc """
  Returns the http code for an error message or error code atom

  ## Example

      iex> ErrorMessage.http_code(:internal_server_error)
      500

      iex> ErrorMessage.http_code(ErrorMessage.not_found("some_message"))
      404
  """
  @spec http_code(error_code :: code) :: non_neg_integer()
  @spec http_code(error_message :: t) :: non_neg_integer()
  def http_code(%ErrorMessage{code: code}), do: http_code(code)

  defdelegate http_code(error_code), to: Plug.Conn.Status, as: :code

  defimpl String.Chars do
    def to_string(%ErrorMessage{} = e) do
      ErrorMessage.to_string(e)
    end
  end
end