lib/validator.ex

defmodule DryValidation.Validator do
  @moduledoc """
  Contains the schema validation logic.
  """
  alias DryValidation.Types

  @doc """
  Validates a schema agains an input map.
  """
  def validate(schema, input) do
    {:ok, pid} = start_agent()

    Enum.each(schema, &walk(&1, input, [], pid))
    result = get_all(pid)

    stop_agent(pid)
    format_result(result)
  end

  @doc false
  defp walk(%{rule: :required, name: name, type: nil}, input, level, pid) do
    value = Map.get(input, name)

    if value,
      do: put_result(pid, level, %{name => value}),
      else: put_error(pid, level, %{name => "Is missing"})
  end

  defp walk(%{rule: :required, name: name, type: type}, input, level, pid) do
    value = Map.get(input, name)

    if value,
      do: validate_and_put_value(type, name, value, level, pid),
      else: put_error(pid, level, %{name => "Is missing"})
  end

  defp walk(%{rule: :optional, name: name, type: type}, input, level, pid) do
    value = Map.get(input, name)

    if value, do: validate_and_put_value(type, name, value, level, pid)
  end

  defp walk(%{rule: :map_list, name: name, optional: true} = rule, input, level, pid) do
    value = Map.get(input, name)

    if value do
      walk(%{rule | optional: false}, input, level, pid)
    end
  end

  defp walk(%{rule: :map_list, name: name, inner: inner, optional: false}, input, level, pid) do
    value = Map.get(input, name)

    if value do
      if is_list(value) do
        result =
          Enum.map(value, fn v ->
            {:ok, nested_pid} = start_agent()

            Enum.each(inner, &walk(&1, v, [], nested_pid))
            result = get_all(nested_pid)

            stop_agent(nested_pid)
            result
          end)

        errors? = Enum.any?(result, fn item -> item.errors != %{} end)

        if errors? do
          final =
            result
            |> Enum.with_index()
            |> Enum.map(fn {item, index} -> [index, item.errors] end)

          put_error(pid, level, %{name => final})
        else
          final = Enum.map(result, fn item -> item.result end)
          put_result(pid, level, %{name => final})
        end
      else
        put_error(pid, level, %{name => "#{inspect(value)} is not a List"})
      end
    else
      put_error(pid, level, %{name => "Is missing"})
    end
  end

  defp walk(%{rule: :map, name: name, inner: inner, optional: false}, input, level, pid) do
    value = Map.get(input, name)

    if value do
      Enum.each(inner, &walk(&1, value, level ++ [name], pid))
    else
      put_error(pid, level, %{name => "Is missing"})
    end
  end

  defp walk(%{rule: :map, name: name, inner: inner, optional: true}, input, level, pid) do
    value = Map.get(input, name)

    if value do
      Enum.each(inner, &walk(&1, value, level ++ [name], pid))
    end
  end

  @doc false
  def validate_and_put_value(Types.List, name, value, level, pid) do
    validate_and_put_value(%Types.List{}, name, value, level, pid)
  end

  def validate_and_put_value(%Types.List{type: type} = list, name, value, level, pid) do
    case Types.List.call(list, value) do
      {:ok, new_value} ->
        put_result(pid, level, %{name => new_value})

      {:error, :not_a_list} ->
        put_error(pid, level, %{name => "#{inspect(value)} is not a List"})

      {:error, invalid_values} ->
        put_error(pid, level, %{
          name => "#{inspect(invalid_values)} are not of type #{inspect(type)}"
        })

      {:error, invalid_values, error_message} ->
        put_error(pid, level, %{
          name => "#{inspect(invalid_values)} #{error_message}"
        })
    end
  end

  def validate_and_put_value(%Types.Func{type: nil} = func, name, value, level, pid) do
    value = Types.Func.cast(func, value)

    if Types.Func.call(func, value) do
      put_result(pid, level, %{name => value})
    else
      put_error(pid, level, %{name => "#{inspect(value)} #{func.error_message}"})
    end
  end

  def validate_and_put_value(%Types.Func{type: type} = func, name, value, level, pid) do
    value = Types.Func.cast(func, value)

    if type.valid?(value) do
      if Types.Func.call(func, value) do
        put_result(pid, level, %{name => value})
      else
        put_error(pid, level, %{name => "#{inspect(value)} #{func.error_message}"})
      end
    else
      put_error(pid, level, %{
        name => "#{inspect(value)} is not a valid type; Expected type is #{inspect(type)}"
      })
    end
  end

  def validate_and_put_value(nil, name, value, level, pid) do
    put_result(pid, level, %{name => value})
  end

  def validate_and_put_value(type, name, value, level, pid) do
    value = type.cast(value)

    if type.valid?(value) do
      put_result(pid, level, %{name => value})
    else
      put_error(pid, level, %{
        name => "#{inspect(value)} is not a valid type; Expected type is #{inspect(type)}"
      })
    end
  end

  @doc false
  def format_result(%{result: result, errors: errors}) when map_size(errors) == 0 do
    {:ok, result}
  end

  @doc false
  def format_result(%{errors: errors}) do
    {:error, errors}
  end

  @doc false
  defp put_result(pid, level, map) do
    Agent.update(
      pid,
      fn state ->
        update_in(state, [:result] ++ level, fn result -> Map.merge(result || %{}, map) end)
      end
    )
  end

  @doc false
  defp put_error(pid, level, map) do
    Agent.update(
      pid,
      fn state ->
        update_in(state, [:errors] ++ level, fn result -> Map.merge(result || %{}, map) end)
      end
    )
  end

  @doc false
  defp get_all(pid) do
    Agent.get(pid, & &1)
  end

  @doc false
  defp start_agent do
    Agent.start_link(fn ->
      %{result: %{}, errors: %{}}
    end)
  end

  @doc false
  defp stop_agent(pid) do
    Agent.stop(pid)
  end
end