lib/password_validator.ex

defmodule PasswordValidator do
  @moduledoc """
  Primary interface to PasswordValidator. The two main methods are `validate/3`
  and `validate_password/2`.

  ## Examples

      iex> opts = [
      ...>   length: [max: 6],
      ...> ]
      iex> PasswordValidator.validate_password("too_long", opts)
      {:error, ["String is too long. 8 but maximum is 6"]}

      iex> opts = [
      ...>   length: [min: 5, max: 30],
      ...>   character_set: [
      ...>     lower_case: 1,  # at least one lower case letter
      ...>     upper_case: [3, :infinity], # at least three upper case letters
      ...>     numbers: [0, 4],  # at most 4 numbers
      ...>     special: [0, 0],  # no special characters allowed
      ...>   ]
      ...> ]
      iex> changeset = Ecto.Changeset.change({%{password: "Simple_pass12345"}, %{}}, %{})
      iex> changeset = PasswordValidator.validate(changeset, :password, opts)
      iex> changeset.errors
      [password: {"Too many special (1 but maximum is 0)", [
        {:validator, PasswordValidator.Validators.CharacterSetValidator},
        {:error_type, :too_many_special}
      ]},
      password: {"Too many numbers (5 but maximum is 4)", [
        {:validator, PasswordValidator.Validators.CharacterSetValidator},
        {:error_type, :too_many_numbers}
      ]},
      password: {"Not enough upper_case characters (only 1 instead of at least 3)", [
        {:validator, PasswordValidator.Validators.CharacterSetValidator},
        {:error_type, :too_few_upper_case}
      ]}]
  """

  alias PasswordValidator.Validators

  @validators [
    Validators.LengthValidator,
    Validators.CharacterSetValidator
  ]

  @spec validate(Ecto.Changeset.t(), atom(), list()) :: Ecto.Changeset.t()
  def validate(changeset, field, opts \\ []) do
    password = Ecto.Changeset.get_field(changeset, field)

    case do_validate(password, opts) do
      :ok ->
        changeset

      {:error, errors} ->
        Enum.reduce(errors, changeset, fn
          error, cset when is_binary(error) ->
            Ecto.Changeset.add_error(cset, field, error)

          {error_message, additional_info}, cset when is_binary(error_message) ->
            Ecto.Changeset.add_error(cset, field, error_message, additional_info)
        end)
    end
  end

  defp do_validate(password, opts) do
    results =
      validators(opts)
      |> Enum.map(&run_validator(&1, password, opts))

    errors =
      for({:error, reason} <- results, do: reason)
      |> List.flatten()

    if length(errors) > 0 do
      {:error, errors}
    else
      :ok
    end
  end

  @spec validate_password(String.t(), list()) :: :ok | {:error, nonempty_list()}
  def validate_password(password, opts \\ []) do
    results =
      validators(opts)
      |> Enum.map(&run_validator(&1, password, opts))

    errors =
      for {:error, errors} <- results do
        Enum.map(errors, fn
          {error_message, _} when is_binary(error_message) -> error_message
          error_message when is_binary(error_message) -> error_message
        end)
      end
      |> List.flatten()

    if length(errors) > 0 do
      {:error, errors}
    else
      :ok
    end
  end

  defp run_validator(validator, password, opts) do
    validator.validate(password, opts)
  end

  defp validators(opts) do
    additional_validators(opts)
    |> Enum.concat(@validators)
  end

  defp additional_validators(opts) do
    case Keyword.get(opts, :additional_validators, []) do
      validators when is_list(validators) -> validators
      non_list -> raise "Expected a list of validators, instead received #{inspect(non_list)}"
    end
  end
end