lib/request.ex

# credo:disable-for-this-file
defmodule Request.Validator do
  alias Ecto.Changeset
  alias Request.Validator.{DefaultRules, Rules}

  @type validation_result :: :ok | {:error, map()}

  @doc ~S"""
  Get the validation rules that apply to the request.
  """
  @callback rules(Plug.Conn.t()) :: keyword() | Changeset.t()

  @doc ~S"""
  Determine if the user is authorized to make this request.
  ```elixir
  def authorize(conn) do
    user(conn).is_admin
  end
  ```
  """
  @callback authorize(Plug.Conn.t()) :: boolean()

  @spec validate(module(), map() | keyword(), keyword()) :: validation_result()
  def validate(module, params, opts \\ []) do
    rules =
      cond do
        function_exported?(module, :rules, 1) ->
          module.rules(opts[:conn])

        function_exported?(module, :rules, 0) ->
          module.rules()
      end

    errors = collect_errors(params, rules, set_strict_default(opts))

    case Enum.empty?(errors) do
      true ->
        :ok

      false ->
        {:error, errors}
    end
  end

  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      import Request.Validator.Rules
      import Request.Validator.Helper

      @before_compile Request.Validator
      @behaviour Request.Validator

      @spec validate(Plug.Conn.t() | map()) :: Request.Validator.validation_result()
      def validate(%Plug.Conn{} = conn) do
        params = conn.query_params |> Map.merge(conn.body_params) |> Map.merge(conn.path_params)
        Request.Validator.validate(__MODULE__, params, unquote(opts) ++ [conn: conn])
      end

      def validate(params) when is_map(params) do
        Request.Validator.validate(__MODULE__, params, unquote(opts))
      end
    end
  end

  defmacro __before_compile__(_) do
    mod = __CALLER__.module

    quote bind_quoted: [mod: mod] do
      if not Module.defines?(mod, {:authorize, 1}) do
        def authorize(_), do: true
      end
    end
  end

  defp collect_errors(_, %Ecto.Changeset{} = changeset, _opts) do
    Changeset.traverse_errors(changeset, fn {key, errors} ->
      Enum.reduce(errors, key, fn {key, value}, acc ->
        String.replace(acc, "%{#{key}}", to_string(value))
      end)
    end)
  end

  defp collect_errors(params, validations, opts) do
    case undeclared_fields(params, validations, opts) do
      [] -> Enum.reduce(validations, %{}, errors_collector(params, opts))
      fields -> fields |> Enum.map(&{&1, ["This field is unknown"]}) |> Map.new()
    end
  end

  defp errors_collector(params, opts) do
    fn
      {field, %Rules.Bail{rules: rules}}, acc ->
        value = Map.get(params, to_string(field))

        result =
          Enum.find_value(rules, nil, fn callback ->
            case run_rule(callback, value, field, params, acc, opts) do
              :ok ->
                nil

              a ->
                a
            end
          end)

        case is_binary(result) do
          true -> Map.put(acc, field, [result])
          _ -> acc
        end

      {field, %Rules.Array{attrs: rules}}, acc ->
        value = Map.get(params, to_string(field))

        with true <- is_list(value),
             result <- Enum.map(value, &collect_errors(&1, rules, opts)) do
          # result <- Enum.reject(result, &Enum.empty?/1) do
          result =
            result
            |> Enum.map(fn val ->
              index = Enum.find_index(result, &(val == &1))

              if Enum.empty?(val) do
                nil
              else
                {index, val}
              end
            end)
            |> Enum.reject(&is_nil/1)
            |> Enum.reduce(%{}, fn {index, errors}, acc ->
              errors =
                errors
                |> Enum.map(fn {key, val} -> {"#{field}.#{index}.#{key}", val} end)
                |> Enum.into(%{})

              Map.merge(acc, errors)
            end)

          Map.merge(acc, result)
        else
          _ ->
            Map.put(acc, field, ["This field is expected to be an array."])
        end

      {field, %Rules.Object{attrs: rules, nullable: nullable}}, acc ->
        value = Map.get(params, to_string(field))

        with %{} <- value,
             result <- collect_errors(value, rules, opts),
             {true, _} <- {Enum.empty?(result), result} do
          acc
        else
          {false, result} ->
            result =
              result
              |> Enum.map(fn {key, val} -> {"#{field}.#{key}", val} end)
              |> Enum.into(%{})

            Map.merge(acc, result)

          val ->
            if nullable and is_nil(val) do
              acc
            else
              Map.put(acc, field, ["This field is expected to be a map."])
            end
        end

      {field, vf}, acc ->
        value = Map.get(params, to_string(field))

        case run_rules(vf, value, field, params, acc, opts) do
          {:error, errors} -> Map.put(acc, field, errors)
          _ -> acc
        end
    end
  end

  defp run_rule(callback, value, field, fields, errors, opts) do
    module = rules_module(opts)
    opts = [field: field, fields: fields, errors: errors]

    {callback, args} =
      case callback do
        cb when is_atom(cb) ->
          {cb, [value, opts]}

        {cb, params} when is_atom(cb) ->
          {cb, [value, params, opts]}
      end

    case apply(module, :run_rule, [callback] ++ args) do
      :ok -> true
      {:error, msg} -> msg
    end
  end

  defp run_rules(rules, value, field, fields, errors, opts) do
    results =
      Enum.map(rules, fn callback ->
        run_rule(callback, value, field, fields, errors, opts)
      end)
      |> Enum.filter(&is_binary/1)

    if Enum.empty?(results), do: nil, else: {:error, results}
  end

  defp rules_module(opts) do
    from_config = Application.get_env(:request_validator, :rules_module, DefaultRules)
    Keyword.get(opts, :rules_module, from_config)
  end

  defp set_strict_default(opts) do
    Keyword.put_new(opts, :strict, Application.get_env(:request_validator, :strict, false))
  end

  defp undeclared_fields(params, rules, opts) do
    opts
    |> Keyword.get(:strict, false)
    |> case do
      false ->
        []

      true ->
        rule_fields = rules |> Keyword.keys() |> Enum.map(&to_string/1) |> MapSet.new()

        params
        |> Map.keys()
        |> MapSet.new()
        |> MapSet.difference(rule_fields)
        |> MapSet.to_list()
        |> Enum.map(&String.to_atom/1)
    end
  end
end