Skip to main content

lib/triple/types/validators.ex

defmodule Triple.Types.Validators do
  @moduledoc false

  # Generic, composable field validators. Each takes/returns an accumulator
  # of `{field, message}` errors so they chain through a pipe:
  #
  #     []
  #     |> required(request, :merchant_name)
  #     |> length_between(request, :merchant_name, 1, 255)
  #
  # Note: some constraints here (e.g. exact 3-letter country/currency
  # codes) are convenience checks based on the *documented* format
  # (ISO 3166 alpha-3 / ISO 4217), not necessarily hard server-side
  # constraints — the API is always the final source of truth.

  @type errors :: [{atom(), String.t()}]

  @spec required(errors(), map(), atom()) :: errors()
  def required(errors, map, field) do
    case Map.get(map, field) do
      nil -> [{field, "is required"} | errors]
      "" -> [{field, "is required"} | errors]
      _ -> errors
    end
  end

  @spec length_between(errors(), map(), atom(), non_neg_integer(), non_neg_integer()) :: errors()
  def length_between(errors, map, field, min, max) do
    case Map.get(map, field) do
      nil ->
        errors

      value when is_binary(value) ->
        len = String.length(value)

        if len < min or len > max do
          [{field, "must be between #{min} and #{max} characters"} | errors]
        else
          errors
        end

      _other ->
        [{field, "must be a string"} | errors]
    end
  end

  @spec inclusion(errors(), map(), atom(), [String.t()]) :: errors()
  def inclusion(errors, map, field, allowed) do
    case Map.get(map, field) do
      nil ->
        errors

      value ->
        if value in allowed do
          errors
        else
          [{field, "must be one of: #{Enum.join(allowed, ", ")}"} | errors]
        end
    end
  end

  @amount_regex ~r/^-?\d{0,10}(?:\.\d{0,2})?$/

  @spec amount_format(errors(), map(), atom()) :: errors()
  def amount_format(errors, map, field) do
    case Map.get(map, field) do
      nil ->
        errors

      value when is_number(value) ->
        errors

      value when is_binary(value) ->
        if Regex.match?(@amount_regex, value) do
          errors
        else
          [{field, "must be a decimal string with at most 2 decimal places"} | errors]
        end

      _other ->
        [{field, "must be a numeric string"} | errors]
    end
  end

  @spec currency_code(errors(), map(), atom()) :: errors()
  def currency_code(errors, map, field) do
    case Map.get(map, field) do
      nil -> errors
      value when is_binary(value) and byte_size(value) == 3 -> errors
      _other -> [{field, "must be a 3-letter ISO 4217 currency code"} | errors]
    end
  end

  @spec country_code(errors(), map(), atom()) :: errors()
  def country_code(errors, map, field) do
    case Map.get(map, field) do
      nil -> errors
      value when is_binary(value) and byte_size(value) == 3 -> errors
      _other -> [{field, "must be a 3-letter ISO 3166-1 alpha-3 country code"} | errors]
    end
  end

  @spec iso8601_datetime(errors(), map(), atom()) :: errors()
  def iso8601_datetime(errors, map, field) do
    case Map.get(map, field) do
      nil ->
        errors

      %DateTime{} ->
        errors

      value when is_binary(value) ->
        case DateTime.from_iso8601(value) do
          {:ok, _dt, _offset} ->
            errors

          {:error, _reason} ->
            [{field, "must be an ISO 8601 UTC datetime, e.g. 2019-08-24T14:15:22Z"} | errors]
        end

      _other ->
        [{field, "must be an ISO 8601 datetime string"} | errors]
    end
  end

  @spec integer_between(errors(), map(), atom(), integer(), integer()) :: errors()
  def integer_between(errors, map, field, min, max) do
    case Map.get(map, field) do
      nil ->
        errors

      value when is_integer(value) and value >= min and value <= max ->
        errors

      _other ->
        [{field, "must be an integer between #{min} and #{max}"} | errors]
    end
  end
end