lib/ash_json_api/error/error.ex

defmodule AshJsonApi.Error do
  @moduledoc "Represents an AshJsonApi Error"
  defstruct id: :undefined,
            about: :undefined,
            code: :undefined,
            title: :undefined,
            detail: :undefined,
            source_pointer: :undefined,
            source_parameter: :undefined,
            meta: :undefined,
            status_code: :undefined,
            internal_description: nil,
            log_level: :error

  @callback new(Keyword.t()) :: %AshJsonApi.Error{} | list(%AshJsonApi.Error{})

  @type t :: %__MODULE__{}

  alias Ash.Error.{Forbidden, Framework, Invalid, Unknown}

  alias AshJsonApi.Error.FrameworkError

  def to_json_api_errors(resource, errors, type) when is_list(errors) do
    Enum.flat_map(errors, &to_json_api_errors(resource, &1, type))
  end

  def to_json_api_errors(resource, %Unknown{errors: errors} = unknown, type) do
    inner_errors = List.flatten(List.wrap(Map.get(unknown, :error)))
    to_json_api_errors(resource, inner_errors ++ errors, type)
  end

  def to_json_api_errors(resource, %mod{errors: errors}, type)
      when mod in [Forbidden, Framework, Invalid] do
    Enum.flat_map(errors, &to_json_api_errors(resource, &1, type))
  end

  def to_json_api_errors(_resource, %__MODULE__{} = error, _type) do
    [error]
  end

  def to_json_api_errors(resource, %{class: :invalid} = error, type)
      when type in [:create, :update] do
    case error do
      %{fields: fields} = error ->
        Enum.map(fields, fn field ->
          %__MODULE__{
            id: Ash.ErrorKind.id(error),
            status_code: class_to_status(error.class),
            code: Ash.ErrorKind.code(error),
            title: Ash.ErrorKind.code(error),
            detail: Ash.ErrorKind.message(error),
            source_pointer: source_pointer(resource, field, type)
          }
        end)

      %{field: field} = error ->
        [
          %__MODULE__{
            id: Ash.ErrorKind.id(error),
            status_code: class_to_status(error.class),
            code: Ash.ErrorKind.code(error),
            title: Ash.ErrorKind.code(error),
            detail: Ash.ErrorKind.message(error),
            source_pointer: source_pointer(resource, field, type)
          }
        ]

      error ->
        [
          %__MODULE__{
            id: Ash.ErrorKind.id(error),
            status_code: class_to_status(error.class),
            code: Ash.ErrorKind.code(error),
            title: Ash.ErrorKind.code(error),
            detail: Ash.ErrorKind.message(error)
          }
        ]
    end
  end

  def to_json_api_errors(_resource, %{class: :forbidden} = error, _type) do
    [
      %__MODULE__{
        id: Ash.ErrorKind.id(error),
        status_code: class_to_status(error.class),
        code: "forbidden",
        title: "Forbidden",
        detail: "forbidden"
      }
    ]
  end

  def to_json_api_errors(_resource, error, _type) do
    [
      FrameworkError.new(
        internal_description:
          "something went wrong. Error messaging is incomplete so far: #{inspect(error)}"
      )
    ]
  end

  defp source_pointer(resource, field, type) when type in [:create, :update] do
    cond do
      Ash.Resource.Info.public_attribute(resource, field) ->
        "/data/attributes/#{field}"

      Ash.Resource.Info.public_relationship(resource, field) ->
        "/data/relationships/#{field}"

      true ->
        :undefined
    end
  end

  defp source_pointer(_resource, _field, _type) do
    :undefined
  end

  defp class_to_status(:forbidden), do: 403
  defp class_to_status(:invalid), do: 400

  def new(opts) do
    struct(__MODULE__, opts)
  end

  def format_log(error) when is_bitstring(error) do
    format_log(FrameworkError.new([]))
  end

  def format_log(error) do
    code =
      if is_bitstring(error.code) do
        [error.code, ": "]
      else
        ""
      end

    title =
      if is_bitstring(error.title) do
        error.title
      else
        "Unknown Error"
      end

    description =
      cond do
        is_bitstring(error.internal_description) ->
          error.internal_description

        is_bitstring(error.detail) ->
          error.detail

        true ->
          "No description"
      end

    [code, title, " | ", description]
  end

  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      @detail Module.get_attribute(__MODULE__, :detail, opts[:detail]) ||
                raise("Must provide a detail for #{__MODULE__}")
      @title Module.get_attribute(__MODULE__, :title, opts[:title]) ||
               raise("Must provide a title for #{__MODULE__}")
      @status_code Module.get_attribute(__MODULE__, :status_code, opts[:status_code]) ||
                     raise("Must provide a status_code for #{__MODULE__}")
      @code Module.get_attribute(__MODULE__, :code, opts[:code]) ||
              String.trim_leading(inspect(__MODULE__), "AshJsonApi.Error.")

      @behaviour AshJsonApi.Error

      def new(opts) do
        [
          detail: @detail,
          title: @title,
          code: @code,
          status_code: @status_code,
          id: Ecto.UUID.generate()
        ]
        |> Keyword.merge(opts)
        |> Keyword.update!(:detail, &String.trim/1)
        |> Keyword.update!(:title, &String.trim/1)
        |> Keyword.update!(:code, fn code ->
          case opts[:code_suffix] do
            suffix when is_bitstring(suffix) ->
              code <> ":" <> suffix

            _ ->
              code
          end
        end)
        |> Keyword.delete(:code_suffix)
        |> AshJsonApi.Error.new()
      end

      defoverridable new: 1
    end
  end
end