Current section

Files

Jump to
exjoi lib exjoi validator.ex
Raw

lib/exjoi/validator.ex

defmodule ExJoi.Validator do
@moduledoc """
Core validation engine that validates data against schemas.
"""
alias ExJoi.{Rule, Schema}
alias DateTime
alias NaiveDateTime
alias Date
@default_truthy [true, "true", "True", "TRUE", "1", 1, "yes", "Yes", "YES", "on", "On", "ON"]
@default_falsy [false, "false", "False", "FALSE", "0", 0, "no", "No", "NO", "off", "Off", "OFF"]
@email_regex ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/
@doc """
Validates data against a schema.
Returns `{:ok, validated_data}` if validation passes, or `{:error, errors}` if it fails.
"""
def validate(data, schema, opts \\ [])
def validate(data, %Schema{fields: fields, defaults: defaults}, opts) when is_map(data) do
convert = Keyword.get(opts, :convert, false)
data_with_defaults = apply_defaults(data, defaults)
{errors, coerced_data} =
Enum.reduce(fields, {%{}, data_with_defaults}, fn {field_name, rule}, {error_acc, data_acc} ->
case validate_field(data_acc, field_name, rule, convert) do
{:ok, :missing} ->
{error_acc, data_acc}
{:ok, {key, value}} ->
{error_acc, Map.put(data_acc, key, value)}
{:error, field_errors} ->
{Map.put(error_acc, field_name, field_errors), data_acc}
end
end)
if map_size(errors) == 0 do
{:ok, coerced_data}
else
{:error, format_errors(errors)}
end
end
def validate(_data, _schema, _opts) do
{:error, format_errors(%{_schema: [error(:invalid_data, "data must be a map")]})}
end
defp validate_field(data, field_name, %Rule{} = rule, convert) do
case fetch_field_value(data, field_name) do
:missing when rule.required ->
{:error, [error(:required, "is required")]}
:missing ->
{:ok, :missing}
{:ok, key, value} ->
case validate_value(value, rule, convert) do
{:ok, coerced} ->
{:ok, {key, coerced}}
{:error, errors} ->
{:error, errors}
end
end
end
defp fetch_field_value(data, field_name) when is_atom(field_name) do
case Map.fetch(data, field_name) do
{:ok, value} when not is_nil(value) ->
{:ok, field_name, value}
_ ->
string_name = Atom.to_string(field_name)
case Map.fetch(data, string_name) do
{:ok, value} when not is_nil(value) -> {:ok, string_name, value}
_ -> :missing
end
end
end
defp fetch_field_value(data, field_name) when is_binary(field_name) do
case Map.fetch(data, field_name) do
{:ok, value} when not is_nil(value) ->
{:ok, field_name, value}
_ ->
atom_name =
try do
String.to_existing_atom(field_name)
rescue
ArgumentError -> nil
end
if atom_name && Map.has_key?(data, atom_name) do
case Map.fetch(data, atom_name) do
{:ok, value} when not is_nil(value) -> {:ok, atom_name, value}
_ -> :missing
end
else
:missing
end
end
end
defp validate_value(value, %Rule{type: :string} = rule, convert) do
with {:ok, normalized} <- ensure_string(value, convert),
[] <- string_constraint_errors(normalized, rule) do
{:ok, normalized}
else
{:error, errors} -> {:error, errors}
errors when is_list(errors) -> {:error, errors}
end
end
defp validate_value(value, %Rule{type: :number} = rule, convert) do
with {:ok, number} <- ensure_number(value, convert),
[] <- number_constraint_errors(number, rule) do
{:ok, number}
else
{:error, errors} -> {:error, errors}
errors when is_list(errors) -> {:error, errors}
end
end
defp validate_value(value, %Rule{type: :boolean} = rule, convert) do
case coerce_boolean(value, rule, convert) do
{:ok, bool} -> {:ok, bool}
:error -> {:error, [error(:boolean, "must be a boolean")]}
end
end
defp validate_value(value, %Rule{type: :array} = rule, convert) do
with {:ok, list} <- coerce_array(value, rule.delimiter),
[] <- array_constraint_errors(list, rule),
{:ok, coerced_list} <- validate_array_elements(list, rule.of, convert) do
{:ok, coerced_list}
else
{:error, errors} -> {:error, errors}
errors when is_list(errors) -> {:error, errors}
end
end
defp validate_value(value, %Rule{type: :object, schema: %Schema{} = schema}, convert) do
if is_map(value) do
case validate(value, schema, convert: convert) do
{:ok, coerced} ->
{:ok, coerced}
{:error, %{errors: nested_errors}} ->
{:error, nested_errors}
end
else
{:error, [error(:object, "must be an object/map")]}
end
end
defp validate_value(value, %Rule{type: :date} = _rule, convert) do
case ensure_date(value, convert) do
{:ok, datetime} -> {:ok, datetime}
:error -> {:error, [error(:date, "must be an ISO8601 date/time")]}
end
end
defp ensure_string(value, convert) when is_binary(value) do
normalized =
if convert do
value
|> String.trim()
|> String.replace(~r/\s+/, " ")
else
value
end
{:ok, normalized}
end
defp ensure_string(_value, _convert), do: {:error, [error(:string, "must be a string")]}
defp ensure_number(value, _convert) when is_number(value), do: {:ok, value}
defp ensure_number(value, true) when is_binary(value) do
case parse_number_from_string(value) do
{:ok, number} -> {:ok, number}
:error -> {:error, [error(:number, "must be a number")]}
end
end
defp ensure_number(_value, _convert), do: {:error, [error(:number, "must be a number")]}
defp string_constraint_errors(value, %Rule{} = rule) do
[]
|> maybe_add(not is_nil(rule.min) and String.length(value) < rule.min, fn ->
error(:string_min, "must be at least #{rule.min} characters", %{min: rule.min})
end)
|> maybe_add(not is_nil(rule.max) and String.length(value) > rule.max, fn ->
error(:string_max, "must be at most #{rule.max} characters", %{max: rule.max})
end)
|> maybe_add(rule.pattern && !Regex.match?(rule.pattern, value), fn ->
error(:string_pattern, "must match required pattern")
end)
|> maybe_add(rule.email && !Regex.match?(@email_regex, value), fn ->
error(:string_email, "must be a valid email")
end)
end
defp number_constraint_errors(value, %Rule{} = rule) do
[]
|> maybe_add(not is_nil(rule.min) and value < rule.min, fn ->
error(:number_min, "must be greater than or equal to #{rule.min}", %{min: rule.min})
end)
|> maybe_add(not is_nil(rule.max) and value > rule.max, fn ->
error(:number_max, "must be less than or equal to #{rule.max}", %{max: rule.max})
end)
|> maybe_add(rule.integer && not is_integer(value), fn ->
error(:number_integer, "must be an integer")
end)
end
defp matches_truthy?(value, values),
do: values |> normalize_boolean_collection() |> MapSet.member?(normalize_boolean_value(value))
defp matches_falsy?(value, values),
do: values |> normalize_boolean_collection() |> MapSet.member?(normalize_boolean_value(value))
defp normalize_boolean_collection(nil), do: MapSet.new()
defp normalize_boolean_collection(values) do
values
|> Enum.reject(&is_nil/1)
|> Enum.map(&normalize_boolean_value/1)
|> MapSet.new()
end
defp normalize_boolean_value(value) when is_binary(value) do
value |> String.trim() |> String.downcase()
end
defp normalize_boolean_value(value), do: value
defp maybe_add(errors, false, _fun), do: errors
defp maybe_add(errors, nil, _fun), do: errors
defp maybe_add(errors, true, fun), do: errors ++ [fun.()]
defp apply_defaults(data, defaults) when map_size(defaults) == 0, do: data
defp apply_defaults(data, defaults) do
defaults
|> Enum.reduce(data, fn {key, default_value}, acc ->
case fetch_field_value(acc, key) do
:missing -> Map.put(acc, key, default_value)
_ -> acc
end
end)
end
defp coerce_array(value, _delimiter) when is_list(value), do: {:ok, value}
defp coerce_array(value, delimiter) when is_binary(value) and is_binary(delimiter) do
list =
value
|> String.split(delimiter)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
{:ok, list}
end
defp coerce_array(_value, _delimiter), do: {:error, [error(:array, "must be an array/list")]}
defp array_constraint_errors(list, %Rule{} = rule) do
count = length(list)
[]
|> maybe_add(not is_nil(rule.min_items) and count < rule.min_items, fn ->
error(:array_min_items, "must contain at least #{rule.min_items} items", %{min_items: rule.min_items})
end)
|> maybe_add(not is_nil(rule.max_items) and count > rule.max_items, fn ->
error(:array_max_items, "must contain at most #{rule.max_items} items", %{max_items: rule.max_items})
end)
|> maybe_add(rule.unique && count != length(Enum.uniq(list)), fn ->
error(:array_unique, "must contain unique items")
end)
end
defp validate_array_elements(list, nil, _convert), do: {:ok, list}
defp validate_array_elements(list, %Rule{} = rule, convert) do
{errors, values} =
list
|> Enum.with_index()
|> Enum.reduce({%{}, []}, fn {item, idx}, {err_acc, val_acc} ->
case validate_value(item, rule, convert) do
{:ok, coerced} ->
{err_acc, [coerced | val_acc]}
{:error, element_errors} ->
{Map.put(err_acc, idx, element_errors), val_acc}
end
end)
if map_size(errors) == 0 do
{:ok, Enum.reverse(values)}
else
{:error, errors}
end
end
defp parse_number_from_string(value) do
trimmed = String.trim(value)
cond do
trimmed == "" ->
:error
true ->
with {int_value, ""} <- Integer.parse(trimmed) do
{:ok, int_value}
else
_ ->
case Float.parse(trimmed) do
{float_value, ""} -> {:ok, float_value}
_ -> :error
end
end
end
end
defp coerce_boolean(value, _rule, _convert) when is_boolean(value), do: {:ok, value}
defp coerce_boolean(value, rule, convert) do
truthy_values = rule.truthy || default_truthy(convert)
falsy_values = rule.falsy || default_falsy(convert)
cond do
should_coerce_boolean?(truthy_values) && matches_truthy?(value, truthy_values) ->
{:ok, true}
should_coerce_boolean?(falsy_values) && matches_falsy?(value, falsy_values) ->
{:ok, false}
true ->
:error
end
end
defp should_coerce_boolean?(nil), do: false
defp should_coerce_boolean?(_values), do: true
defp ensure_date(%DateTime{} = value, _convert), do: {:ok, value}
defp ensure_date(%NaiveDateTime{} = value, _convert) do
case DateTime.from_naive(value, "Etc/UTC") do
{:ok, datetime} -> {:ok, datetime}
{:error, _} -> :error
end
end
defp ensure_date(%Date{} = value, _convert) do
case DateTime.new(value, ~T[00:00:00], "Etc/UTC") do
{:ok, datetime} -> {:ok, datetime}
{:error, _} -> :error
end
end
defp ensure_date(value, true) when is_binary(value) do
case DateTime.from_iso8601(value) do
{:ok, datetime, _offset} ->
{:ok, datetime}
{:error, _} ->
case NaiveDateTime.from_iso8601(value) do
{:ok, naive} -> {:ok, DateTime.from_naive!(naive, "Etc/UTC")}
{:error, _} -> :error
end
end
end
defp ensure_date(_value, _convert), do: :error
defp default_truthy(true), do: @default_truthy
defp default_truthy(_), do: nil
defp default_falsy(true), do: @default_falsy
defp default_falsy(_), do: nil
defp error(code, message, meta \\ %{}) do
%{code: code, message: message, meta: meta}
end
defp format_errors(errors) do
%{
message: "Validation failed",
errors: errors
}
end
end