lib/veli.ex

defmodule Veli do
  @moduledoc """
  Veli (**V**alidation in **eli**xir) is a simple validation library for elixir.

  ## Rule

  Rule is a keyword list that contains some rules for validation.

  A rule has these options:
  - `type`: for validating types / affects some other rules like `min` and `max`. Supports following types:
      - `string`
      - `integer`
      - `number`
      - `bool`
      - `list`
      - `map`
  - `min`: Minimum value to accept.
      - for `number` and `integer`, it checks the number.
      - for `string`, it checks string length.
      - for `list`, it checks list length.
      - for `map`, it checks key length.
  - `max`: Same with `min` rule but for maximum values.
  - `match`: For matching a value.
      - for `string`, it uses regex to match.
      - for any other type, it will just compare both values.
  - `run`: Allows you to add custom filtering function inside it that returns a boolean. If it returns false, validation will fail.

  Here is an example for rule defination.

      username_rule = [type: :string, min: 3, max: 32, match: ~r/^[a-zA-Z0-9_]*$/]

  And for validating forms:

      form_rules = %{
        "username" => [type: :string, min: 3, max: 32, match: ~r/^[a-zA-Z0-9_]*$/],
        "age" => [type: :integer, min: 13]
      }

  """

  @doc """
  Validate a form with rules.

  Returns list of results which contains a tuple that includes key with result.

  ## Example

      iex(1)> form = %{"username" => "john", "age" => 17}
      %{"age" => 17, "username" => "john"}
      iex(2)> rules = %{"username" => [type: :string, min: 3, max: 32, match: ~r/^[a-zA-Z0-9_]*$/], "age" => [type: :integer, min: 13]}
      %{
        "age" => [type: :integer, min: 13],
        "username" => [type: :string, min: 3, max: 32, match: ~r/^[a-zA-Z0-9_]*$/]
      }
      iex(3)> Veli.validate_form(form, rules)
      [{"age", :ok}, {"username", :ok}]
      iex(4)> form = %{form | "age" => 10}
      %{"age" => 10, "username" => "john"}
      iex(5)> Veli.validate_form(form, rules)
      [{"age", :min_error}, {"username", :ok}]

  """
  @spec validate_form(map, map) :: list
  def validate_form(form, rules) do
    form
    |> Enum.map(fn {key, value} -> {key, check_value({:type, value}, rules[key])} end)
  end

  @doc """
  Validate list elements with a rule

  Returns list of results which contains a tuple that includes index with result.

  ## Example

      iex(1)> rule = [type: :integer, run: fn value -> rem(value, 2) == 0 end]
      [type: :integer, max: 100]
      iex(2)> Veli.validate_list([4, 6, 8, 10], rule) |> Veli.get_error()
      nil
      iex(3)> Veli.validate([4, 3, 8], rule) |> Veli.get_error()
      {1, :run_error}

  """
  @spec validate_list(list, keyword) :: list
  def validate_list(list, rule) do
    list
    |> Enum.with_index()
    |> Enum.map(fn {value, index} -> {index, check_value({:type, value}, rule)} end)
  end

  @doc """
  Validate a value with rule.

  Returns an atom.

  ## Examples

  Simple usage

      iex(1)> rule = [type: :integer, max: 100]
      [type: :integer, max: 100]
      iex(2)> Veli.validate(96, rule)
      :ok
      iex(3)> Veli.validate(101, rule)
      :max_error

  Adding custom filtering functions

      iex(1)> rule = [type: :string, run: fn value -> String.reverse(value) === value end]
      [type: :string, run: #Function<42.3316493/1 in :erl_eval.expr/6>]
      iex(2)> Veli.validate("wow", rule)
      :ok
      iex(3)> Veli.validate("hello", rule)
      :run_error

  """
  @spec validate(any, keyword) :: :match_error | :max_error | :min_error | :ok | :type_error
  def validate(value, rule) do
    check_value({:type, value}, rule)
  end

  @doc """
  An helper function for validate_form/2 that finds first error from form validation result.

  Returns first error from form validation result. `nil` if success.

  ## Example

      iex(1)> form = %{"username" => "james", "age" => 10}
      %{"age" => 10, "username" => "james"}
      iex(2)> rules = %{"username" => [type: :string], "age" => [type: :integer, min: 13]}
      %{
        "age" => [type: :integer, min: 13],
        "username" => [type: :string]
      }
      iex(3)> Veli.validate_form(form, rules)
      [{"age", :min_error}, {"username", :ok}]
      iex(4)> Veli.validate_form(form, rules) |> Veli.get_error()
      {"age", :min_error}
      iex(5)> form = %{form | "age" => 20}
      %{"age" => 20, "username" => "james"}
      iex(6)> Veli.validate_form(form, rules) |> Veli.get_error()
      nil

  """
  @spec get_error(list) :: tuple | nil
  def get_error(result) do
    result
    |> Enum.filter(fn {_, status} -> status !== :ok end)
    |> List.first()
  end

  defp check_value({:type, value}, rule) do
    result =
      case rule[:type] do
        :string ->
          is_binary(value)

        :integer ->
          is_integer(value)

        :number ->
          is_number(value)

        :bool ->
          is_boolean(value)

        :list ->
          is_list(value)

        :map ->
          is_map(value)

        _ ->
          false
      end

    if result === false and rule[:type] !== nil do
      :type_error
    else
      check_value({:min, value}, rule)
    end
  end

  defp check_value({:min, value}, rule) do
    result =
      case rule[:type] do
        :string ->
          String.length(value) >= rule[:min]

        :integer ->
          value >= rule[:min]

        :number ->
          value >= rule[:min]

        :list ->
          :erlang.length(value) >= rule[:min]

        :map ->
          Map.keys(value) |> :erlang.length() >= rule[:min]

        _ ->
          false
      end

    if result === false and rule[:min] !== nil do
      :min_error
    else
      check_value({:max, value}, rule)
    end
  end

  defp check_value({:max, value}, rule) do
    result =
      case rule[:type] do
        :string ->
          String.length(value) <= rule[:max]

        :integer ->
          value <= rule[:max]

        :number ->
          value <= rule[:max]

        :list ->
          :erlang.length(value) <= rule[:max]

        :map ->
          Map.keys(value) |> :erlang.length() <= rule[:max]

        _ ->
          false
      end

    if result === false and rule[:max] !== nil do
      :max_error
    else
      check_value({:match, value}, rule)
    end
  end

  defp check_value({:match, value}, rule) do
    result =
      case rule[:type] do
        :string ->
          Regex.match?(rule[:match] || ~r//, value)

        _ ->
          value === rule[:match]
      end

    if result === false and rule[:match] !== nil do
      :match_error
    else
      check_value({:run, value}, rule)
    end
  end

  defp check_value({:run, value}, rule) do
    if rule[:run] !== nil and rule[:run].(value) === false do
      :run_error
    else
      :ok
    end
  end
end