lib/response.ex

defmodule Solicit.Response do
  @moduledoc """
  Standardized responses
  """

  import Plug.Conn
  import Phoenix.Controller

  alias Ecto.Changeset
  alias Solicit.ResponseError

  # 200
  @doc """
  Returns a successful response.
  """
  @spec ok(
          Plug.Conn.t(),
          term(),
          term()
        ) :: Plug.Conn.t()
  def ok(
        conn,
        result,
        fields \\ nil
      ) do
    conn
    |> json(as_json(result, fields))
    |> halt()
  end

  @doc """
  Returns a successful response with no payload.
  """
  @spec ok(Plug.Conn.t()) :: Plug.Conn.t()
  def ok(conn) do
    conn
    |> json(nil)
    |> halt()
  end

  # 201
  @doc """
  Returns a successful created response.
  """
  @spec created(Plug.Conn.t(), term(), term()) :: Plug.Conn.t()
  def created(conn, result, fields \\ nil) do
    conn
    |> put_status(:created)
    |> json(as_json(result, fields))
    |> halt()
  end

  # 202
  @doc """
  Returns a successful accepted response. Normally used to signify that the request was accepted, but might not have finished processing.
  """
  @spec accepted(Plug.Conn.t()) :: Plug.Conn.t()
  def accepted(conn) do
    conn
    |> put_status(:accepted)
    |> json(nil)
    |> halt()
  end

  @spec accepted(Plug.Conn.t(), any()) :: Plug.Conn.t()
  def accepted(conn, details) when is_binary(details) do
    conn
    |> put_status(:accepted)
    |> json(%{details: details})
    |> halt()
  end

  @spec accepted(Plug.Conn.t(), term(), term()) :: Plug.Conn.t()
  def accepted(conn, details, fields \\ nil) do
    conn
    |> put_status(:accepted)
    |> json(as_json(details, fields))
    |> halt()
  end

  # 204
  @doc """
  Returns a successful no_content response. Normally used when deleting.
  """
  @spec no_content(Plug.Conn.t()) :: Plug.Conn.t()
  def no_content(conn) do
    conn
    |> send_resp(:no_content, "")
    |> halt()
  end

  # 400
  @doc """
  Signifies a bad request.
  """
  @spec bad_request(Plug.Conn.t()) :: Plug.Conn.t()
  def bad_request(conn) do
    conn
    |> put_status(:bad_request)
    |> json(%{errors: [ResponseError.bad_request()]})
    |> halt()
  end

  @spec bad_request(Plug.Conn.t(), Ecto.Changeset.t()) :: Plug.Conn.t()
  def bad_request(conn, %Ecto.Changeset{} = changeset) do
    conn
    |> put_status(:bad_request)
    |> json(%{errors: ResponseError.from_changeset(changeset)})
    |> halt()
  end

  @spec bad_request(Plug.Conn.t(), binary()) :: Plug.Conn.t()
  def bad_request(conn, message) do
    conn
    |> put_status(:bad_request)
    |> json(%{errors: [message]})
    |> halt()
  end

  # 401
  @doc """
  Signifies an unauthorized request.
  """
  @spec unauthorized(Plug.Conn.t(), list()) :: Plug.Conn.t()
  def unauthorized(conn, errors) do
    conn
    |> put_status(:unauthorized)
    |> json(%{errors: errors})
    |> halt()
  end

  @spec unauthorized(Plug.Conn.t()) :: Plug.Conn.t()
  def unauthorized(conn) do
    conn
    |> put_status(:unauthorized)
    |> json(%{errors: [ResponseError.unauthorized()]})
    |> halt()
  end

  # 403
  @doc """
  Signifies a forbidden response.
  """
  @spec forbidden(Plug.Conn.t(), list()) :: Plug.Conn.t()
  def forbidden(conn, errors) do
    conn
    |> put_status(:forbidden)
    |> json(%{errors: errors})
    |> halt()
  end

  @spec forbidden(Plug.Conn.t()) :: Plug.Conn.t()
  def forbidden(conn) do
    conn
    |> put_status(:forbidden)
    |> json(%{errors: [ResponseError.forbidden()]})
    |> halt()
  end

  # 404
  @doc """
  Signifies a not found response.
  """
  @spec not_found(Plug.Conn.t(), list()) :: Plug.Conn.t()
  def not_found(conn, errors) do
    conn
    |> put_status(:not_found)
    |> json(%{errors: errors})
    |> halt()
  end

  @spec not_found(Plug.Conn.t()) :: Plug.Conn.t()
  def not_found(conn) do
    conn
    |> put_status(:not_found)
    |> json(%{errors: [ResponseError.not_found()]})
    |> halt()
  end

  # 405
  @doc """
  Signifies a method not allowed response.
  """
  @spec method_not_allowed(Plug.Conn.t(), list()) :: Plug.Conn.t()
  def method_not_allowed(conn, errors) do
    conn
    |> put_status(:method_not_allowed)
    |> json(%{errors: errors})
    |> halt()
  end

  @spec method_not_allowed(Plug.Conn.t()) :: Plug.Conn.t()
  def method_not_allowed(conn) do
    conn
    |> put_status(:method_not_allowed)
    |> json(%{errors: [ResponseError.method_not_allowed()]})
    |> halt()
  end

  # 408
  @doc """
  Signifies a request timeout response.
  """
  @spec timeout(Plug.Conn.t(), list()) :: Plug.Conn.t()
  def timeout(conn, errors) do
    conn
    |> put_status(:request_timeout)
    |> json(%{errors: errors})
    |> halt()
  end

  @spec timeout(Plug.Conn.t()) :: Plug.Conn.t()
  def timeout(conn) do
    conn
    |> put_status(:request_timeout)
    |> json(%{errors: [ResponseError.timeout()]})
    |> halt()
  end

  # 409
  @doc """
  Signifies a conflicting response.
  """
  @spec conflict(Plug.Conn.t()) :: Plug.Conn.t()
  def conflict(conn) do
    conn
    |> put_status(:conflict)
    |> json(%{errors: [ResponseError.conflict()]})
    |> halt()
  end

  @spec conflict(Plug.Conn.t(), list() | binary()) :: Plug.Conn.t()
  def conflict(conn, errors) when is_list(errors) do
    conn
    |> put_status(:conflict)
    |> json(%{errors: errors})
    |> halt()
  end

  def conflict(conn, description) when is_binary(description) do
    conn
    |> put_status(:conflict)
    |> json(%{errors: [ResponseError.conflict(description)]})
    |> halt()
  end

  # 410
  @doc """
  Signifies a gone response.
  """
  @spec gone(Plug.Conn.t()) :: Plug.Conn.t()
  def gone(conn) do
    conn
    |> put_status(:gone)
    |> json(%{errors: [ResponseError.gone()]})
    |> halt()
  end

  @spec gone(Plug.Conn.t(), binary()) :: Plug.Conn.t()
  def gone(conn, description) do
    conn
    |> put_status(:gone)
    |> json(%{errors: [ResponseError.gone(description)]})
    |> halt()
  end

  # 413
  @doc """
  Signifies a request_entity_too_large response.
  """
  @spec request_entity_too_large(Plug.Conn.t()) :: Plug.Conn.t()
  def request_entity_too_large(conn) do
    conn
    |> put_status(:request_entity_too_large)
    |> json(%{errors: [ResponseError.request_entity_too_large()]})
    |> halt()
  end

  @spec request_entity_too_large(Plug.Conn.t(), binary()) :: Plug.Conn.t()
  def request_entity_too_large(conn, description) do
    conn
    |> put_status(:request_entity_too_large)
    |> json(%{errors: [ResponseError.request_entity_too_large(description)]})
    |> halt()
  end

  # 415
  @doc """
  Signifies a unsupported_media_type response.
  """
  @spec unsupported_media_type(Plug.Conn.t()) :: Plug.Conn.t()
  def unsupported_media_type(conn) do
    conn
    |> put_status(:unsupported_media_type)
    |> json(%{errors: [ResponseError.unsupported_media_type()]})
    |> halt()
  end

  @spec unsupported_media_type(Plug.Conn.t(), binary()) :: Plug.Conn.t()
  def unsupported_media_type(conn, description) do
    conn
    |> put_status(:unsupported_media_type)
    |> json(%{errors: [ResponseError.unsupported_media_type(description)]})
    |> halt()
  end

  # 422
  @doc """
  Signifies an unprocessable_entity response.
  """
  @spec unprocessable_entity(
          Plug.Conn.t(),
          Ecto.Changeset.t() | binary() | atom() | Postgrex.Error.t() | list()
        ) ::
          Plug.Conn.t()
  def unprocessable_entity(conn, %Changeset{} = changeset) do
    conn
    |> put_status(:unprocessable_entity)
    |> json(%{errors: ResponseError.from_changeset(changeset)})
    |> halt()
  end

  def unprocessable_entity(conn, %Postgrex.Error{} = postgrex_error) do
    conn
    |> put_status(:unprocessable_entity)
    |> json(%{errors: [postgrex_error.postgres.message]})
    |> halt()
  end

  def unprocessable_entity(conn, message) when is_binary(message) or is_atom(message) do
    conn
    |> put_status(:unprocessable_entity)
    |> json(%{errors: [message]})
    |> halt()
  end

  def unprocessable_entity(conn, errors) when is_list(errors) do
    conn
    |> put_status(:unprocessable_entity)
    |> json(%{errors: errors})
    |> halt()
  end

  @spec unprocessable_entity(Plug.Conn.t()) :: Plug.Conn.t()
  def unprocessable_entity(conn) do
    conn
    |> put_status(:unprocessable_entity)
    |> json(%{errors: [ResponseError.unprocessable_entity()]})
    |> halt()
  end

  # 429
  @doc """
  Signifies an too_many_requests request.
  """
  @spec too_many_requests(Plug.Conn.t()) :: Plug.Conn.t()
  def too_many_requests(conn) do
    conn
    |> put_status(:too_many_requests)
    |> json(%{errors: [ResponseError.too_many_requests()]})
    |> halt()
  end

  @spec too_many_requests(Plug.Conn.t(), binary()) :: Plug.Conn.t()
  def too_many_requests(conn, message) when is_binary(message) do
    conn
    |> put_status(:too_many_requests)
    |> json(%{errors: [ResponseError.too_many_requests(message)]})
    |> halt()
  end

  # 500
  @doc """
  Signifies an internal_server_error request.
  """
  @spec internal_server_error(Plug.Conn.t(), binary() | list()) :: Plug.Conn.t()
  def internal_server_error(conn, message) when is_binary(message) do
    conn
    |> put_status(:internal_server_error)
    |> json(%{errors: [message]})
    |> halt()
  end

  def internal_server_error(conn, errors) when is_list(errors) do
    conn
    |> put_status(:internal_server_error)
    |> json(%{errors: errors})
    |> halt()
  end

  @spec internal_server_error(Plug.Conn.t()) :: Plug.Conn.t()
  def internal_server_error(conn) do
    conn
    |> put_status(:internal_server_error)
    |> json(%{errors: [ResponseError.internal_server_error()]})
    |> halt()
  end

  # 502
  @doc """
  Signifies a bad gateway request.
  """
  @spec bad_gateway(Plug.Conn.t(), binary() | list(ResponseError.t())) :: Plug.Conn.t()
  def bad_gateway(conn, message) when is_binary(message) do
    conn
    |> put_status(:bad_gateway)
    |> json(%{errors: [ResponseError.bad_gateway(message)]})
    |> halt()
  end

  def bad_gateway(conn, errors) when is_list(errors) do
    conn
    |> put_status(:bad_gateway)
    |> json(%{errors: errors})
    |> halt()
  end

  @spec bad_gateway(Plug.Conn.t()) :: Plug.Conn.t()
  def bad_gateway(conn) do
    conn
    |> put_status(:bad_gateway)
    |> json(%{errors: [ResponseError.bad_gateway()]})
    |> halt()
  end

  # 503
  @doc """
  Signifies a service unavailable request.
  """
  @spec service_unavailable(Plug.Conn.t(), binary() | list(ResponseError.t())) :: Plug.Conn.t()
  def service_unavailable(conn, message) when is_binary(message) do
    conn
    |> put_status(:service_unavailable)
    |> json(%{errors: [ResponseError.service_unavailable(message)]})
    |> halt()
  end

  def service_unavailable(conn, errors) when is_list(errors) do
    conn
    |> put_status(:service_unavailable)
    |> json(%{errors: errors})
    |> halt()
  end

  @spec service_unavailable(Plug.Conn.t()) :: Plug.Conn.t()
  def service_unavailable(conn) do
    conn
    |> put_status(:service_unavailable)
    |> json(%{errors: [ResponseError.service_unavailable()]})
    |> halt()
  end

  @doc """
  Returns structs or maps with specific fields extracted.

  ## Example

      def show(%{assigns: %{user: user}} = conn, _params) do
        conn
        |> as_json(user, [
          :id,
          :first_name,
          :last_name,
          :email
        ])
      end

  ## Example w/ Association

      def show(%{assigns: %{unit: unit}} = conn, _params) do
        conn
        |> as_json(unit, [
          :id,
          :marketing_name,
          :street_address_1,
          :street_address_2,
          :city,
          :state,
          full_address: fn unit ->
            "\#{unit.street_address_1}, \#{unit.city}, \#{unit.state}"
          end
          group: [
            :id,
            :marketing_name
          ],
          residents: [
            :id,
            :first_name,
            :last_name
          ]
        ])
      end

  If you don't add an associations fields and just the key `:group`, it will throw an `ArgumentError` letting you know that you forgot to add the related fields.

  *Note: Associations must be added last in the list or you will result in a syntax error*

  ### Incorrect:

      def show(%{assigns: %{unit: unit}} = conn, _params) do
        conn
        |> as_json(unit, [
          :id,
          :marketing_name,
          :street_address_1,
          :street_address_2,
          group: [
            :id,
            :marketing_name
          ],
          :city,
          :state
        ])
      end

  ### Correct:

      def show(%{assigns: %{unit: unit}} = conn, _params) do
        conn
        |> as_json(unit, [
          :id,
          :marketing_name,
          :street_address_1,
          :street_address_2,
          :city,
          :state,
          group: [
            :id,
            :marketing_name
          ],
        ])
      end
  """
  def as_json(struct, nil), do: struct

  def as_json(nil, _fields), do: nil

  def as_json(struct, fields) do
    Enum.reduce(fields, %{}, fn field, acc ->
      {field, value} =
        case field do
          {field, generate} when is_function(generate, 1) ->
            {field, generate.(struct)}

          {field, {:array, assoc_fields}} ->
            transformed_array =
              struct
              |> Map.get(field)
              |> Enum.map(&as_json(&1, assoc_fields))

            {field, transformed_array}

          {field, assoc_fields} ->
            convert_assoc_struct(struct, field, assoc_fields)

          field ->
            convert_struct(struct, field)
        end

      Map.put(acc, field, value)
    end)
  end

  defp convert_struct(nil, _field), do: nil

  defp convert_struct(struct, field) do
    {field, Map.get(struct, field)}
  end

  defp convert_assoc_struct(struct, field, assoc_fields) do
    assoc = Map.get(struct, field)

    if is_list(assoc) do
      {field,
       Enum.map(assoc, fn assoc_struct ->
         as_json(assoc_struct, assoc_fields)
       end)}
    else
      {field, as_json(assoc, assoc_fields)}
    end
  end
end