defmodule Goal do @moduledoc ~S""" Goal is a parameter validation library based on Ecto. It takes the parameters, e.g. from a Phoenix controller, validates them against a schema, and returns an atom-based map with the validated data. An `Ecto.Changeset` is returned if any of the parameters is invalid. Goal uses the validation rules from `Ecto.Changeset`, which means you can use any validation that is available for database fields for validating parameters with Goal. A common use-case is parsing and validating parameters from Phoenix controllers: ```elixir defmodule MyApp.SomeController do import Goal @schema %{ id: [format: :uuid, required: true], name: [min: 3, max: 20, required: true] } def create(conn, params) do with {:ok, attrs} <- validate_params(params, @schema) do ... end end end ``` With the `defschema` macro: ```elixir defmodule MyApp.SomeController do import Goal import Goal.Syntax def create(conn, params) do with {:ok, attrs} <- validate_params(params, schema()) do ... end end def schema do defschema do required :id, format: :uuid required :name, min: 3, max: 20 end end end ``` ## Defining validations Define field types with `:type`: - `:string` - `:integer` - `:boolean` - `:float` - `:decimal` - `:date` - `:time` - `:map` - `{:array, inner_type}`, where `inner_type` can be any of the field types - See [Ecto.Schema](https://hexdocs.pm/ecto/Ecto.Schema.html#module-primitive-types) for the full list The default field type is `:string`. That means you don't have to define this field in the schema if the value will be a string. Define map fields with `:properties`. Define string validations: - `:equals`, string value - `:is`, string length - `:min`, minimum string length - `:max`, maximum string length - `:trim`, boolean to remove leading and trailing spaces - `:squish`, boolean to trim and collapse spaces - `:format`, atom to define the regex (available are: `:uuid`, `:email`, `:password`, `:url`) Define integer validations: - `:is`, integer value - `:min`, minimum integer value - `:max`, maximum integer value - `:greater_than`, minimum integer value - `:less_than`, maximum integer value - `:greater_than_or_equal_to`, minimum integer value - `:less_than_or_equal_to`, maximum integer value - `:equal_to`, integer value - `:not_equal_to`, integer value Define enum validations: - `:excluded`, list of disallowed values - `:included`, list of allowed values - `:subset`, list of values ## Bring your own regex Goal has sensible defaults for string format validation. If you'd like to use your own regex, e.g. for validating email addresses or passwords, you can configure your own regexes in your application configuration. ```elixir config :goal, uuid_regex: ~r/^[[:alpha:]]+$/, email_regex: ~r/^[[:alpha:]]+$/, password_regex: ~r/^[[:alpha:]]+$/, url_regex: ~r/^[[:alpha:]]+$/ ``` ## Deeply nested maps Goal efficiently builds error changesets for nested maps. There is no limitation on depth. If the schema is becoming too verbose, you could consider splitting up the schema into reusable components. ```elixir params = %{ "nested_map" => %{ "map" => %{ "inner_map" => %{ "id" => 123, "list" => [1, 2, 3] } } } } schema = %{ nested_map: [ type: :map, properties: %{ inner_map: [ type: :map, properties: %{ map: [ type: :map, properties: %{ id: [type: :integer, required: true], list: [type: {:array, :integer}] } ] } ] } ] } iex(3)> Goal.validate_params(params, schema) {:ok, %{nested_map: %{inner_map: %{map: %{id: 123, list: [1, 2, 3]}}}}} ``` ## Use defschema to reduce boilerplate Goal provides a macro called `Goal.Syntax.defschema/1` to build validation schemas without all the boilerplate code. The previous example of deeply nested maps can be rewritten as: ```elixir import Goal.Syntax params = %{...} schema = defschema do optional :nested_map, :map do optional :inner_map, :map do optional :map, :map do required :id, :integer optional :list, {:array, :integer} end end end end iex(3)> Goal.validate_params(params, schema) {:ok, %{nested_map: %{inner_map: %{map: %{id: 123, list: [1, 2, 3]}}}}} ``` ## Human-readable error messages Use `Goal.traverse_errors/2` to build readable errors. Ecto and Phoenix by default use `Ecto.Changeset.traverse_errors/2`, which works for embedded Ecto schemas but not for the plain nested maps used by Goal. ```elixir def translate_errors(changeset) do Goal.traverse_errors(changeset, &translate_error/1) end ``` ## Credits This library is based on [Ecto](https://github.com/elixir-ecto/ecto) and I had to copy and adapt `Ecto.Changeset.traverse_errors/2`. Thanks for making such an awesome library! 🙇 """ import Ecto.Changeset alias Ecto.Changeset @type params :: map() @type schema :: map() @type error :: {String.t(), Keyword.t()} @doc ~S""" Validates the parameters against a schema. ## Examples iex> validate_params(%{"email" => "jane@example.com"}, %{email: [format: :email]}) {:ok, %{email: "jane@example.com"}} iex> validate_params(%{"email" => "invalid"}, %{email: [format: :email]}) {:error, %Ecto.Changeset{valid?: false, errors: [email: {"has invalid format", ...}]}} """ @spec validate_params(params, schema) :: {:ok, map} | {:error, Changeset.t()} def validate_params(params, schema) do case build_changeset(params, schema) do %Changeset{valid?: true, changes: changes} -> {:ok, changes} %Changeset{valid?: false} = changeset -> {:error, changeset} end end @doc ~S""" Traverses changeset errors and applies the given function to error messages. ## Examples iex> traverse_errors(changeset, fn {msg, opts} -> ...> Regex.replace(~r"%{(\w+)}", msg, fn _, key -> ...> opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() ...> end) ...> end) %{title: ["should be at least 3 characters"]} """ @spec traverse_errors(Changeset.t(), (error -> binary) | (Changeset.t(), atom, error -> binary)) :: %{atom => [term]} defdelegate traverse_errors(changeset, msg_func), to: Goal.Changeset defp get_types(schema) do Enum.reduce(schema, %{}, fn {field, rules}, acc -> case Keyword.get(rules, :type, :string) do :enum -> values = rules |> Keyword.get(:values, []) |> Enum.map(&String.to_atom/1) Map.put(acc, field, {:parameterized, Ecto.Enum, Ecto.Enum.init(values: values)}) type -> Map.put(acc, field, type) end end) end defp build_changeset(params, schema) do types = get_types(schema) {%{}, types} |> Changeset.cast(params, Map.keys(types)) |> validate_required_fields(schema) |> validate_basic_fields(schema) |> validate_nested_fields(types, schema) end defp validate_required_fields(%Changeset{} = changeset, schema) do required_fields = Enum.reduce(schema, [], fn {field, rules}, acc -> if Keyword.get(rules, :required, false), do: [field | acc], else: acc end) validate_required(changeset, required_fields) end defp validate_basic_fields(%Changeset{changes: changes} = changeset, schema) do Enum.reduce(changes, changeset, fn {field, _value}, changeset_acc -> schema |> Map.get(field, []) |> validate_fields(field, changeset_acc) end) end defp validate_fields(rules, field, changeset) do Enum.reduce(rules, changeset, fn {:equals, value}, inner_acc -> validate_inclusion(inner_acc, field, [value]) {:excluded, values}, inner_acc -> validate_exclusion(inner_acc, field, values) {:included, values}, inner_acc -> validate_inclusion(inner_acc, field, values) {:subset, values}, inner_acc -> validate_subset(inner_acc, field, values) {:is, integer}, inner_acc -> change = get_in(inner_acc, [Access.key(:changes), Access.key(field)]) if is_integer(change), do: validate_number(inner_acc, field, equal_to: integer), else: validate_length(inner_acc, field, is: integer) {:min, integer}, inner_acc -> change = get_in(inner_acc, [Access.key(:changes), Access.key(field)]) if is_integer(change), do: validate_number(inner_acc, field, greater_than_or_equal_to: integer), else: validate_length(inner_acc, field, min: integer) {:max, integer}, inner_acc -> change = get_in(inner_acc, [Access.key(:changes), Access.key(field)]) if is_integer(change), do: validate_number(inner_acc, field, less_than_or_equal_to: integer), else: validate_length(inner_acc, field, max: integer) {:trim, true}, inner_acc -> update_change(inner_acc, field, &String.trim/1) {:squish, true}, inner_acc -> update_change(inner_acc, field, &Goal.String.squish/1) {:format, :uuid}, inner_acc -> validate_format(inner_acc, field, Goal.Regex.uuid()) {:format, :email}, inner_acc -> validate_format(inner_acc, field, Goal.Regex.email()) {:format, :password}, inner_acc -> validate_format(inner_acc, field, Goal.Regex.password()) {:format, :url}, inner_acc -> validate_format(inner_acc, field, Goal.Regex.url()) {:less_than, integer}, inner_acc -> validate_number(inner_acc, field, less_than: integer) {:greater_than, integer}, inner_acc -> validate_number(inner_acc, field, greater_than: integer) {:less_than_or_equal_to, integer}, inner_acc -> validate_number(inner_acc, field, less_than_or_equal_to: integer) {:greater_than_or_equal_to, integer}, inner_acc -> validate_number(inner_acc, field, greater_than_or_equal_to: integer) {:equal_to, integer}, inner_acc -> validate_number(inner_acc, field, equal_to: integer) {:not_equal_to, integer}, inner_acc -> validate_number(inner_acc, field, not_equal_to: integer) {_name, _setting}, inner_acc -> inner_acc end) end defp validate_nested_fields(%Changeset{changes: changes} = changeset, types, schema) do Enum.reduce(types, changeset, fn {field, :map}, acc -> inner_params = Map.get(changes, field) inner_rules = Map.get(schema, field) inner_schema = Keyword.get(inner_rules, :properties) if inner_schema && inner_params do inner_params |> build_changeset(inner_schema) |> case do %Changeset{valid?: true, changes: changes} -> put_in(acc, [Access.key(:changes), Access.key(field)], changes) %Changeset{valid?: false} = inner_changeset -> acc |> put_in([Access.key(:changes), Access.key(field)], inner_changeset) |> Map.put(:valid?, false) end else acc end {field, {:array, :map}}, acc -> inner_params = Map.get(changes, field) inner_rules = Map.get(schema, field) inner_schema = Keyword.get(inner_rules, :properties) if inner_schema do {valid?, changesets} = Enum.reduce(inner_params, {true, []}, fn params, {boolean, list} -> params |> build_changeset(inner_schema) |> case do %Changeset{valid?: true, changes: changes} -> {boolean, append_to_list(list, changes)} %Changeset{valid?: false} = inner_changeset -> {false, append_to_list(list, inner_changeset)} end end) acc |> put_in([Access.key(:changes), Access.key(field)], changesets) |> Map.put(:valid?, valid?) else acc end {_field, _type}, acc -> acc end) end defp append_to_list(list, value), do: Enum.reverse([value | Enum.reverse(list)]) end