lib/cast_function_error.ex

defmodule DataSchema.CastFunctionError do
  @moduledoc """
  An error for when a casting function does not return the correct data.

  All casting functions get wrapped  in a `rescue` to catch any unexpected exceptions. This
  lets us add more useful information about where the error occurred that the stack trace
  cannot provide - mainly which field failed the casting.

  We then raise using the original stacktrace and printing the captured error message.

  ### Matching on Specific Exceptions

  If a user wishes to capture specific exceptions as part of casting they may do so by
  matching on the `:wrapped_error` field. For example:

      try do
        DataSchema.to_struct(my_input, MySchema)
      rescue
        %DataSchema.CastFunctionError{wrapped_error: %RuntimeError{}} ->
          Logger.error("Expected Runtime Error")
        error ->
          reraise error, __STACKTRACE__
      end
  """
  defexception [
    :message,
    :casted_value,
    :leaf_field,
    :path,
    :wrapped_error,
    :stacktrace_of_wrapped_error
  ]

  @doc """
  Creates an appropriate error message from the given struct.
  """
  def error_message(%__MODULE__{
        casted_value: value,
        path: path,
        leaf_field: leaf_field,
        wrapped_error: wrapped_error
      }) do
    """


    Unexpected error when casting value #{inspect(value)}
    #{field_message(path, leaf_field)}
    Full path to field was:

    #{inspect_path(path)}
    The casting function raised the following error:

    #{Exception.format(:error, wrapped_error)}
    """
  end

  defp field_message(path, leaf_field) do
    case List.last(path) do
      {schema, field} when is_atom(field) ->
        """
        for field #{inspect(field)} in schema #{inspect(schema)}
        """

      field when is_atom(field) ->
        """
        for field #{inspect(field)} in this part of the schema:

        #{formatted_field(leaf_field)}
        """
    end
  end

  defp inspect_path(path) do
    [first | rest] = path |> Enum.reverse()

    rest
    |> Enum.reduce("      #{node_to_string(first)}", fn field, acc ->
      acc <> "Under " <> node_to_string(field)
    end)
  end

  defp node_to_string({module, field}) do
    "Field  #{inspect(field)} in #{inspect(module)}\n"
  end

  defp node_to_string(field) do
    "Field  #{inspect(field)}\n"
  end

  # What happens with aggregate?
  defp formatted_field({:field, rest}) do
    "field: #{inspect(rest)},"
  end

  defp formatted_field({:has_one, rest}) do
    "has_one: #{inspect(rest)},"
  end

  defp formatted_field({:has_many, rest}) do
    "has_many: #{inspect(rest)},"
  end

  defp formatted_field({:list_of, rest}) do
    "list_of: #{inspect(rest)},"
  end

  defp formatted_field({:aggregate, {field, path, cast, opts}}) do
    """
    @aggregate_fields [
      #{Enum.map_join(path, "\n  ", &formatted_field/1)}
    ]
    aggregate: {#{inspect(field)}, @aggregate_fields, #{inspect(cast)}, #{inspect(opts)}},
    """
  end

  defp formatted_field({:aggregate, {field, path, cast}}) do
    """
    @aggregate_fields [
      #{Enum.map_join(path, "\n  ", &formatted_field/1)}
    ]
    aggregate: {#{inspect(field)}, @aggregate_fields, #{inspect(cast)}},
    """
  end
end