lib/tarams.ex

defmodule Tarams do
  @moduledoc """
  Params provide some helpers method to work with parameters
  """

  @doc """
  A plug which do srubbing params

  **Use in Router**

      defmodule MyApp.Router do
        ...
        plug Tarams.plug_scrub
        ...
      end

  **Use in controller**

      plug Tarams.plug_scrub when action in [:index, :show]
      # or specify which field to scrub
      plug Tarams.plug_scrub, ["id", "keyword"] when action in [:index, :show]

  """
  def plug_scrub(conn, keys \\ []) do
    params =
      if keys == [] do
        scrub_param(conn.params)
      else
        Enum.reduce(keys, conn.params, fn key, params ->
          case Map.fetch(conn.params, key) do
            {:ok, value} -> Map.put(params, key, scrub_param(value))
            :error -> params
          end
        end)
      end

    %{conn | params: params}
  end

  @doc """
  Convert all parameter which value is empty string or string with all whitespace to nil. It works with nested map and list too.

  **Example**

      params = %{"keyword" => "   ", "email" => "", "type" => "customer"}
      Tarams.scrub_param(params)
      # => %{"keyword" => nil, "email" => nil, "type" => "customer"}

      params = %{user_ids: [1, 2, "", "  "]}
      Tarams.scrub_param(params)
      # => %{user_ids: [1, 2, nil, nil]}
  """
  def scrub_param(%{__struct__: mod} = struct) when is_atom(mod) do
    struct
  end

  def scrub_param(%{} = param) do
    Enum.reduce(param, %{}, fn {k, v}, acc ->
      Map.put(acc, k, scrub_param(v))
    end)
  end

  def scrub_param(param) when is_list(param) do
    Enum.map(param, &scrub_param/1)
  end

  def scrub_param(param) do
    if scrub?(param), do: nil, else: param
  end

  defp scrub?(" " <> rest), do: scrub?(rest)
  defp scrub?(""), do: true
  defp scrub?(_), do: false

  @doc """
  Clean all nil field from params, support nested map and list.

  **Example**

      params = %{"keyword" => nil, "email" => nil, "type" => "customer"}
      Tarams.clean_nil(params)
      # => %{"type" => "customer"}

      params = %{user_ids: [1, 2, nil]}
      Tarams.clean_nil(params)
      # => %{user_ids: [1, 2]}
  """
  @spec clean_nil(any) :: any
  def clean_nil(%{__struct__: mod} = param) when is_atom(mod) do
    param
  end

  def clean_nil(%{} = param) do
    Enum.reduce(param, %{}, fn {k, v}, acc ->
      if is_nil(v) do
        acc
      else
        Map.put(acc, k, clean_nil(v))
      end
    end)
  end

  def clean_nil(param) when is_list(param) do
    Enum.reduce(param, [], fn item, acc ->
      if is_nil(item) do
        acc
      else
        [clean_nil(item) | acc]
      end
    end)
    |> Enum.reverse()
  end

  def clean_nil(param), do: param

  alias Tarams.Type

  @doc """
  Cast and validate params with given schema.
  See `Tarams.Schema` for instruction on how to define a schema
  And then use it like this

  ```elixir
  def index(conn, params) do
    index_schema = %{
      status: [type: :string, required: true],
      type: [type: :string, in: ["type1", "type2", "type3"]],
      keyword: [type: :string, length: [min: 3, max: 100]],
    }

    with {:ok, data} <- Tarams.cast(params, index_schema) do
      # do query data
    else
      {:error, errors} -> IO.puts(errors)
    end
  end
  ```
  """

  @spec cast(data :: map(), schema :: map()) :: {:ok, map()} | {:error, errors :: map()}
  def cast(data, schema) do
    {status, results} =
      schema
      |> Tarams.Schema.expand()
      |> Enum.map(&cast_field(data, &1))
      |> collect_schema_result()

    {status, Map.new(results)}
  end

  def cast!(data, schema) do
    case cast(data, schema) do
      {:ok, value} -> value
      _ -> raise "Tarams :: bad input data"
    end
  end

  defp cast_field(data, {field_name, definitions}) do
    {alias, definitions} = Keyword.pop(definitions, :as, field_name)
    {custom_message, definitions} = Keyword.pop(definitions, :message)

    # remote transform option from definition
    validations = Keyword.drop(definitions, [:into, :type, :cast_func, :default])

    # 1. cast value
    with {:ok, value} <- do_cast(data, field_name, definitions),
         # 2. apply validation
         :ok <- apply_validations(value, validations),
         {:ok, value} <- apply_transform(value, definitions, data) do
      {:ok, {alias, value}}
    else
      {:error, error} ->
        # 3.2 Handle custom error message
        if custom_message do
          {:error, {field_name, [custom_message]}}
        else
          errors = if is_binary(error), do: [error], else: error

          {:error, {field_name, errors}}
        end
    end
  end

  # cast data to proper type
  defp do_cast(data, field_name, definitions) do
    value =
      case Map.fetch(data, field_name) do
        {:ok, value} -> value
        _ -> Map.get(data, "#{field_name}", definitions[:default])
      end

    cast_result =
      case definitions[:cast_func] do
        nil ->
          cast_value(value, definitions[:type])

        func when is_function(func, 1) ->
          func.(value)

        func when is_function(func, 2) ->
          func.(value, data)

        {mod, func} when is_atom(mod) and is_atom(func) ->
          apply(mod, func, [value, data])

        _ ->
          {:error, "invalid cast function"}
      end

    case cast_result do
      :error -> {:error, "is invalid"}
      others -> others
    end
  end

  defp cast_value(nil, _), do: {:ok, nil}

  # cast array of custom map
  defp cast_value(value, {:array, %{} = type}) do
    cast_array({:embed, __MODULE__, type}, value)
  end

  # cast nested map
  defp cast_value(value, %{} = type) do
    Type.cast({:embed, __MODULE__, type}, value)
  end

  defp cast_value(value, type) do
    Type.cast(type, value)
  end

  # rewrite cast_array for more detail errors
  def cast_array(type, value, acc \\ [])

  def cast_array(type, [value | t], acc) do
    case Type.cast(type, value) do
      {:ok, data} -> cast_array(type, t, [data | acc])
      error -> error
    end
  end

  def cast_array(_, [], acc), do: {:ok, Enum.reverse(acc)}

  # apply list of validation to value
  defp apply_validations(value, validations) do
    validations
    |> Enum.map(fn validation ->
      do_validate(value, validation)
    end)
    |> collect_validation_result()
  end

  # handle custom validation for required
  defp do_validate(value, {:required, _} = validator) do
    Valdi.validate(value, [validator])
  end

  # skip validation for nil
  defp do_validate(nil, _), do: :ok

  defp do_validate(value, validator) do
    Valdi.validate(value, [validator])
  end

  # transform data
  defp apply_transform(value, definitions, data) do
    result =
      case definitions[:into] do
        nil ->
          {:ok, value}

        {mod, func} when is_atom(mod) and is_atom(func) ->
          cond do
            Kernel.function_exported?(mod, func, 1) ->
              apply(mod, func, [value])

            Kernel.function_exported?(mod, func, 2) ->
              apply(mod, func, [value, data])

            true ->
              {:error, "invalid transform function"}
          end

        func when is_function(func, 1) ->
          func.(value)

        func when is_function(func, 2) ->
          func.(value, data)

        _ ->
          {:error, "invalid transform function"}
      end

    # support function return tuple or value
    case result do
      {status, _value} when status in [:error, :ok] -> result
      value -> {:ok, value}
    end
  end

  defp collect_validation_result(results) do
    summary =
      Enum.reduce(results, :ok, fn
        :ok, acc -> acc
        {:error, msg}, :ok -> {:error, [msg]}
        {:error, msg}, {:error, acc_msg} -> {:error, [msg | acc_msg]}
      end)

    case summary do
      :ok ->
        :ok

      {:error, errors} ->
        errors =
          errors
          |> Enum.map(fn item ->
            if is_list(item) do
              item
            else
              [item]
            end
          end)
          |> Enum.concat()

        {:error, errors}
    end
  end

  defp collect_schema_result(results) do
    Enum.reduce(results, {:ok, []}, fn
      {:ok, data}, {:ok, acc} -> {:ok, [data | acc]}
      {:error, error}, {:ok, _} -> {:error, [error]}
      {:error, error}, {:error, acc} -> {:error, [error | acc]}
      _, acc -> acc
    end)
  end
end