lib/veli.ex

defmodule Veli do
  @moduledoc """
  Veli is a simple validation library for elixir.
  
  ## Rules
  ### Simple Rules
  When you validate simple types (like a string or an integer),
  you must use simple rules. Which is a keyword list.
  
      rule = [type: :string, run: fn value -> String.reverse(value) === value end]
      Veli.valid("wow", rule) |> Veli.error() === nil
  
  ### List Rules
  When you need to validate every item on a list,
  you must use `Veli.Types.List` struct so validator can know if it is validating a string or a value.
  
      rule = %Veli.Types.List{rule: [type: :integer]}
      Veli.valid([4, 2, 7, 1], rule) |> Veli.error() === nil
  
  ### Map Rules
  When you need to validate a map (an object),
  you must use `Veli.Types.Map` struct so validator can know if it is validating a map or a value.
  
      rule = %Veli.Types.Map{rule: %{
        username: [type: :string],
        age: [type: :integer, min: 13]
      }}
      Veli.valid(%{username: "bob", age: 16}, rule) |> Veli.error() === nil
  
  ## Ordering
  You must order your rules correctly to make them work properly. For example, if you put "nullable" rule after "type" rule, you will get type error.
  
      rule = [nullable: true, type: :integer]
      Veli.valid(5, rule) # nil
      Veli.valid(nil, rule) # nil
  
      rule = [type: :integer, nullable: true]
      Veli.valid(5, rule) # nil
      Veli.valid(nil, rule) # {:type, false}
  
  ## Custom Errors
  By default, Any error returns `false`. You can specify custom errors with adding underscore (_) prefix.
  
      rule = [type: :integer, _type: "Value must be an integer!"]
      Veli.valid(10, rule) |> Veli.error() # nil
      Veli.valid("invalid value", rule) |> Veli.error() # "Value must be an integer!"
  
  ### Custom Errors for Map or List
  As you can see in `Veli.Types.Map` or `Veli.Types.List`, they both have a field named "error" which is nil by default.
  You can specify custom errors with "error" field.
  
      rule = %Veli.Types.Map{
        rule: %{
          username: [type: :string],
          age: [type: :integer, min: 13]
        },
        error: "Not a valid object."
      }
      Veli.valid(%{username: "bob", age: 16}, rule) |> Veli.error() # nil
      Veli.valid(96, rule) |> Veli.error() # "Not a valid object."
  
  ### More Example
  You can read library tests for more example.
  """

  @default_validators [
    format: Veli.Validators.Format,
    empty: Veli.Validators.Empty,
    nullable: Veli.Validators.Nullable,
    type: Veli.Validators.Type,
    run: Veli.Validators.Run,
    outside: Veli.Validators.Outside,
    inside: Veli.Validators.Inside,
    min: Veli.Validators.Min,
    max: Veli.Validators.Max,
    match: Veli.Validators.Match
  ]

  @doc """
  Validate a value with rules.
  Returns a keyword list which contains results. You should not process that result yourself. Use `Veli.errors` or `Veli.error` for processing results instead.
  
  ## Example
  
      rule = [type: :string, match: ~r/^https?/]
      Veli.valid("wow", rule) |> Veli.error() !== nil
      Veli.valid("https://hex.pm", rule) |> Veli.error() === nil
  
  More examples can be found in library tests.
  """
  @spec valid(any, keyword | Veli.Types.List | Veli.Types.Map) :: keyword | tuple | nil
  def valid(values, %{rule: rule, error: error} = rules) when is_struct(rules, Veli.Types.List) do
    if is_list(values) do
      values
      |> Enum.with_index()
      |> Enum.map(fn {value, index} -> {index, valid(value, rule)} end)
    else
      [type: error || false]
    end
  end

  def valid(values, %{rule: rule, strict: strict, error: error} = rules)
      when is_struct(rules, Veli.Types.Map) do
    if is_map(values) or is_struct(values) do
      rule_keys = Map.keys(rule)
      values_keys = Map.keys(values)

      if strict === true and Enum.sort(rule_keys) !== Enum.sort(values_keys) do
        [type: error || false]
      else
        Enum.map(rule_keys, fn key ->
          value = values[key]
          rule = rule[key]

          {key, valid(value, rule)}
        end)
      end
    else
      [type: error || false]
    end
  end

  def valid(value, rules) when is_list(rules) do
    init_validator_table()

    rules
    |> Enum.each(fn {atom, rule} ->
      case :ets.lookup(:veli_validators, atom) do
        [{_atom, module} | _other] ->
          case module.valid?(value, rule) do
            nil ->
              throw(nil)

            false ->
              fail_msg = rules[String.to_atom("_" <> Atom.to_string(atom))]
              throw({atom, fail_msg || false})

            _ ->
              :ok
          end

        _ ->
          :ok
      end
    end)

    nil
  catch
    result -> result
  end

  def valid(_value, _rules) do
    [type: false]
  end

  @doc """
  Returns all failed validations.
  
  ## Example
  
      rule = %Veli.Types.List{rule: [type: :float]}
      Veli.valid([5, 3.2, "how"], rule) |> Veli.errors()
  """
  @spec errors(keyword | tuple) :: keyword
  def errors(result) when is_tuple(result) do
    [result]
  end

  def errors(result) when is_nil(result) do
    []
  end

  def errors(result) do
    result
    |> Enum.map(fn {_atom, value} -> if is_list(value), do: errors(value), else: value end)
    |> List.flatten()
    |> Enum.filter(fn value -> value !== nil end)
  end

  @doc """
  Returns first error from validate result.
  Returns `nil` if everything is valid.
  
  ## Example
  
      rule = %Veli.Types.List{rule: [type: :float]}
      Veli.valid([5, 3.2, "how"], rule) |> Veli.error()
  """
  @spec error(keyword | tuple) :: tuple | nil
  def error(result) do
    result
    |> errors
    |> List.first()
  end

  @doc """
  Add a custom validator to validator table.
  Given module must have a function named "valid?".
  Check validators in source code to get more information about implementing your own validator.
  
  ## Example
  
      defmodule ModValidator do
        def valid?(value, rule) when is_number(value) do
          rem(value, rule) === 0
        end
  
        def valid?(_value, _rule) do
          false
        end
      end
  
      Veli.add_validator(:mod, ModValidator)
  
      rule = %Veli.Types.List{rule: [nullable: false, type: :integer, mod: 2]}
      Veli.valid([2, 4, 6], rule) |> Veli.error()
  """
  @spec add_validator(atom, module) :: true
  def add_validator(name, module) do
    init_validator_table()
    :ets.insert(:veli_validators, {name, module})
  end

  defp init_validator_table do
    if :ets.whereis(:veli_validators) === :undefined do
      :ets.new(:veli_validators, [:set, :protected, :named_table])

      @default_validators
      |> Enum.each(fn {name, module} ->
        :ets.insert(:veli_validators, {name, module})
      end)
    else
      :ok
    end
  end
end