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