defmodule Goal do @moduledoc ~S""" Goal is a parameter validation library based on [Ecto](https://github.com/elixir-ecto/ecto). It can be used with JSON APIs, HTML controllers and LiveViews. Goal builds a changeset from a validation schema and controller or LiveView parameters, and returns the validated parameters or an `Ecto.Changeset`, depending on the function you use. If your frontend and backend use different parameter cases, you can recase parameter keys with the `:recase_keys` option. `PascalCase`, `camelCase`, `kebab-case` and `snake_case` are supported. You can configure your own regexes for password, email, and URL format validations. This is helpful in case of backward compatibility, where Goal's defaults might not match your production system's behavior. ## Installation Add `goal` to the list of dependencies in `mix.exs`: ```elixir def deps do [ {:goal, "~> 0.2"} ] end ``` ## Examples Goal can be used with LiveViews and JSON and HTML controllers. ### Example with controllers With JSON and HTML-based APIs, Goal takes the `params` from a controller action, validates those against a validation schema using `validate/3`, and returns an atom-based map or an error changeset. ```elixir defmodule MyApp.SomeController do use MyApp, :controller use Goal def create(conn, params) do with {:ok, attrs} <- validate(:create, params)) do ... else {:error, changeset} -> {:error, changeset} end end defparams :create do required :uuid, :string, format: :uuid required :name, :string, min: 3, max: 3 optional :age, :integer, min: 0, max: 120 optional :gender, :enum, values: ["female", "male", "non-binary"] optional :data, :map do required :color, :string optional :money, :decimal optional :height, :float end end end ``` ### Example with LiveViews With LiveViews, Goal builds a changeset in `mount/3` that is assigned in the socket, and then it takes the `params` from `handle_event/3`, validates those against a validation schema, and returns an atom-based map or an error changeset. ```elixir defmodule MyApp.SomeLiveView do use MyApp, :live_view use Goal def mount(params, _session, socket) do changeset = changeset(:new, %{}) socket = assign(socket, :changeset, changeset) {:ok, socket} end def handle_event("validate", %{"some" => params}, socket) do changeset = changeset(:new, params) socket = assign(socket, :changeset, changeset) {:noreply, socket} end def handle_event("save", %{"some" => params}, socket) do with {:ok, attrs} <- validate(:new, params)) do ... else {:error, changeset} -> {:noreply, assign(socket, :changeset, changeset)} end end defparams :new do required :uuid, :string, format: :uuid required :name, :string, min: 3, max: 3 optional :age, :integer, min: 0, max: 120 optional :gender, :enum, values: ["female", "male", "non-binary"] optional :data, :map do required :color, :string optional :money, :decimal optional :height, :float end end end ``` ### Example with isolated schema Validation schemas can be defined in a separate namespace, for example `MyAppWeb.MySchema`: ```elixir defmodule MyAppWeb.MySchema do use Goal defparams :show do required :id, :string, format: :uuid optional :query, :string end end iex(1)> MySchema.validate(:show, %{"id" => "f86b1460-c2dc-4b7f-a28b-e3f21f3ebe7b"}) {:ok, %{id: "f86b1460-c2dc-4b7f-a28b-e3f21f3ebe7b"}} iex(2)> MySchema.changeset(:show, %{id: "f86b1460-c2dc-4b7f-a28b-e3f21f3ebe7b"}) %Ecto.Changeset{valid?: true, changes: %{id: "f86b1460-c2dc-4b7f-a28b-e3f21f3ebe7b"}} ``` ## Features ### Deeply nested maps Goal efficiently builds error changesets for nested maps, and has support for lists of nested maps. There is no limitation on depth. ```elixir use Goal defparams do optional :nested_map, :map do required :id, :integer optional :inner_map, :map do required :id, :integer optional :map, :map do required :id, :integer optional :list, {:array, :integer} end end end end iex(1)> Goal.validate_params(schema(), params) {:ok, %{nested_map: %{inner_map: %{map: %{id: 123, list: [1, 2, 3]}}}}} ``` ### Readable error messages Use `Goal.traverse_errors/2` to build readable errors. Phoenix by default uses `Ecto.Changeset.traverse_errors/2`, which works for embedded Ecto schemas but not for the plain nested maps used by Goal. Goal's `traverse_errors/2` is compatible with (embedded) `Ecto.Schema`, so you don't have to make any changes to your existing logic. ```elixir def translate_errors(changeset) do Goal.traverse_errors(changeset, &translate_error/1) end ``` ### Recasing inbound keys By default, Goal will look for the keys defined in `defparams`. But sometimes frontend applications send parameters in a different format. For example, in `camelCase` but your backend uses `snake_case`. For this scenario, Goal has the `:recase_keys` option: ```elixir config :goal, recase_keys: [from: :camel_case] iex(1)> MySchema.validate(:show, %{"firstName" => "Jane"}) {:ok, %{first_name: "Jane"}} ``` ### Recasing outbound keys Use `recase_keys/2` to recase outbound keys. For example, in your views: ```elixir config :goal, recase_keys: [to: :camel_case] defmodule MyAppWeb.UserJSON do import Goal def show(%{user: user}) do %{data: %{first_name: user.first_name}} |> recase_keys() end def error(%{changeset: changeset}) do errors = changeset |> Goal.Changeset.traverse_errors(&translate_error/1) |> recase_keys() %{errors: errors} end end iex(1)> UserJSON.show(%{user: %{first_name: "Jane"}}) %{data: %{firstName: "Jane"}} iex(2)> UserJSON.error(%Ecto.Changeset{errors: [first_name: {"can't be blank", [validation: :required]}]}) %{errors: %{firstName: ["can't be blank"]}} ``` ### 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, then you can add your own regex in the configuration: ```elixir config :goal, uuid_regex: ~r/^[[:alpha:]]+$/, email_regex: ~r/^[[:alpha:]]+$/, password_regex: ~r/^[[:alpha:]]+$/, url_regex: ~r/^[[:alpha:]]+$/ ``` ### Available validations The field types and available validations are: | Field type | Validations | Description | | ---------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------- | | `:uuid` | `:equals` | string value | | `:string` | `:equals` | string value | | | `:is` | string length | | | `:min` | minimum string length | | | `:max` | maximum string length | | | `:trim` | oolean to remove leading and trailing spaces | | | `:squish` | boolean to trim and collapse spaces | | | `:format` | `:uuid`, `:email`, `:password`, `:url` | | | `:subset` | list of required strings | | | `:included` | list of allowed strings | | | `:excluded` | list of disallowed strings | | `:integer` | `:equals` | integer value | | | `: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 | | | `:subset` | list of required integers | | | `:included` | list of allowed integers | | | `:excluded` | list of disallowed integers | | `:float` | | all of the integer validations | | `:decimal` | | all of the integer validations | | `:boolean` | `:equals` | boolean value | | `:date` | `:equals` | date value | | `:time` | `:equals` | time value | | `:enum` | `:values` | list of allowed values | | `:map` | `:properties` | use `:properties` to define the fields | | `{:array, :map}` | `:properties` | use `:properties` to define the fields | | `{:array, inner_type}` | | `inner_type` can be any of the basic types | | More basic types | | See [Ecto.Schema](https://hexdocs.pm/ecto/Ecto.Schema.html#module-primitive-types) for the full list | The default basic type is `:string`. You don't have to define this field if you are using the basic syntax. All field types, excluding `:map` and `{:array, :map}`, can use `:equals`, `:subset`, `:included`, `:excluded` validations. ## 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 @typedoc false @type name :: atom() | binary() @typedoc false @type schema :: map() @typedoc false @type params :: map() @typedoc false @type cases :: :camel_case | :snake_case | :pascal_case | :kebab_case @typedoc false @type opts :: [recase_keys: [from: cases(), to: cases()]] @typedoc false @type error :: {String.t(), Keyword.t()} @typedoc false @type changeset :: Changeset.t() @typedoc false @type block :: {:__block__, any, any} @typedoc false @type do_block :: [do: block()] @doc false @spec __using__(block()) :: any() defmacro __using__(_) do quote do import Goal, only: [defparams: 1, defparams: 2, build_changeset: 2, recase_keys: 3] @typedoc false @type name :: atom() | binary() @typedoc false @type params :: map() @typedoc false @type cases :: :camel_case | :snake_case | :pascal_case | :kebab_case @typedoc false @type opts :: [recase_keys: [from: cases()]] @typedoc false @type changeset :: Changeset.t() @doc """ Builds a changeset from the schema and params. """ @spec changeset(name(), params()) :: changeset() def changeset(name, params \\ %{}) do name |> schema() |> build_changeset(params) end @doc """ Returns the validated parameters or an error changeset. """ @spec validate(changeset()) :: {:ok, params()} | {:error, changeset()} def validate(%Changeset{valid?: true, changes: changes}), do: {:ok, changes} def validate(%Changeset{valid?: false} = changeset), do: {:error, changeset} @doc """ Returns the validated parameters or an error changeset. Expects a schema to be defined with `defparams`. """ @spec validate(name(), params(), opts()) :: {:ok, params()} | {:error, changeset()} def validate(name, params \\ %{}, opts \\ []) do schema = schema(name) params = recase_keys(schema, params, opts) schema |> build_changeset(params) |> Map.put(:action, :validate) |> validate() end end end @doc """ A macro for defining validation schemas. Can be assigned to a variable. ```elixir import Goal defp schema do defschema do required :id, :string, format: :uuid required :name, :string optional :age, :integer, min: 0, max: 120 optional :gender, :enum, values: ["female", "male", "non-binary"] required :data, :map do required :city, :string optional :birthday, :date end end end ``` """ @spec defschema(do_block()) :: any defmacro defschema(do: block) do block |> generate_schema() |> Macro.escape() end @doc """ A macro for defining validation schemas encapsulated in a `schema` function with arity 0. ```elixir defmodule MySchema do use Goal defparams do required :id, :string, format: :uuid end end iex(1)> schema() %{id: [type: :integer, required: true]}] ``` """ @spec defparams(do_block()) :: any defmacro defparams(do: block) do quote do def schema do unquote(block |> generate_schema() |> Macro.escape()) end end end @doc """ A macro for defining validation schemas encapsulated in a `schema` function with arity 1. The argument can be an atom or a binary. ```elixir defmodule MySchema do use Goal defparams :index do required :id, :string, format: :uuid end end iex(1)> MySchema.schema(:index) %{id: [type: :integer, required: true]}] iex(2)> MySchema.changeset(:index, %{id: 12}) %Ecto.Changeset{valid?: true, changes: %{id: 12}} iex(3)> MySchema.validate(:index, %{id: 12}) {:ok, %{id: 12}} ``` """ @spec defparams(name(), do_block()) :: any defmacro defparams(name, do: block) do quote do def schema(unquote(name)) do unquote(block |> generate_schema() |> Macro.escape()) end end end @doc ~S""" Validates parameters against a validation schema. ## Examples iex> validate_params(%{email: [format: :email]}, %{"email" => "jane@example.com"}) {:ok, %{email: "jane@example.com"}} iex> validate_params(%{email: [format: :email]}, %{"email" => "invalid"}) {:error, %Ecto.Changeset{valid?: false, errors: [email: {"has invalid format", ...}]}} """ @spec validate_params(schema(), params(), opts()) :: {:ok, params()} | {:error, changeset()} def validate_params(schema, params, opts \\ []) do params = recase_keys(schema, params, opts) schema |> build_changeset(params) |> Map.put(:action, :validate) |> case do %Changeset{valid?: true, changes: changes} -> {:ok, changes} %Changeset{valid?: false} = changeset -> {:error, changeset} end end @doc ~S""" Builds an `Ecto.Changeset` using the parameters and a validation schema. ## Examples iex> build_changeset(%{email: [format: :email]}, %{"email" => "jane@example.com"}) %Ecto.Changeset{valid?: true, changes: %{email: "jane@example.com"}} iex> build_changeset(%{email: [format: :email]}, %{"email" => "invalid"}) %Ecto.Changeset{valid?: false, errors: [email: {"has invalid format", ...}]} """ @spec build_changeset(schema(), params()) :: Changeset.t() def build_changeset(schema, params) do types = get_types(schema) {%{}, types} |> Changeset.cast(params, Map.keys(types), force_changes: true) |> validate_required_fields(schema) |> validate_basic_fields(schema) |> validate_nested_fields(types, schema) end @doc """ Recases parameter keys. Use only when you have full control of the data. For example, to render JSON responses. ## Examples iex> recase_keys(%{"first_name" => "Jane"}, recase_keys: [to: :camel_case]) %{firstName: "Jane"} Supported are `:camel_case`, `:pascal_case`, `:kebab_case` and `:snake_case`. """ @spec recase_keys(params(), opts()) :: params() def recase_keys(params, opts \\ []) do settings = Keyword.get(opts, :recase_keys) || Application.get_env(:goal, :recase_keys) if settings do to_case = Keyword.get(settings, :to) is_atom_map = is_atom_map?(params) recase_outbound_keys(params, to_case, is_atom_map) else params end end @doc """ Recases parameter keys that are present in the schema. Use this instead of `recase_keys/2` for incoming parameters. For example, for user requests. ## Examples iex> recase_keys(%{first_name: [type: :string]}, %{"firstName" => "Jane"}, recase_keys: [from: :camel_case]) %{first_name: "Jane"} Supported are `:camel_case`, `:pascal_case`, `:kebab_case` and `:snake_case`. """ @spec recase_keys(schema(), params(), opts()) :: params() def recase_keys(schema, params, opts) do settings = Keyword.get(opts, :recase_keys) || Application.get_env(:goal, :recase_keys) if settings do from_case = Keyword.get(settings, :from) is_atom_map = is_atom_map?(params) recase_inbound_keys(schema, params, from_case, is_atom_map) else params 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(), (error() -> binary()) | (changeset(), atom(), error() -> binary()) ) :: %{atom() => [term()]} defdelegate traverse_errors(changeset, msg_func), to: Goal.Changeset defp generate_schema({:__block__, _lines, contents}) do Enum.reduce(contents, %{}, fn function, acc -> Map.merge(acc, generate_schema(function)) end) end defp generate_schema({:optional, _lines, [field, type]}) do %{field => [{:type, type}]} end defp generate_schema({:optional, _lines, [field, type, options]}) do if block_or_function = Keyword.get(options, :do) do properties = generate_schema(block_or_function) clean_options = Keyword.delete(options, :do) %{field => [{:type, type} | [{:properties, properties} | clean_options]]} else %{field => [{:type, type} | options]} end end defp generate_schema({:required, _lines, [field, type]}) do %{field => [{:type, type}, {:required, true}]} end defp generate_schema({:required, _lines, [field, type, options]}) do if block_or_function = Keyword.get(options, :do) do properties = generate_schema(block_or_function) clean_options = Keyword.delete(options, :do) %{ field => [ {:type, type} | [{:required, true} | [{:properties, properties} | clean_options]] ] } else %{field => [{:type, type} | [{:required, true} | options]]} end end 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)}) :uuid -> Map.put(acc, field, Ecto.UUID) type -> Map.put(acc, field, type) end end) 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([], _field, changeset), do: changeset defp validate_fields(rules, field, changeset) do Enum.reduce(rules, changeset, fn {:equals, value}, acc -> validate_inclusion(acc, field, [value]) {:excluded, values}, acc -> validate_exclusion(acc, field, values) {:included, values}, acc -> validate_inclusion(acc, field, values) {:subset, values}, acc -> validate_subset(acc, field, values) {:is, integer}, acc -> change = get_in(acc, [Access.key(:changes), Access.key(field)]) if is_binary(change), do: validate_length(acc, field, is: integer), else: validate_number(acc, field, equal_to: integer) {:min, integer}, acc -> change = get_in(acc, [Access.key(:changes), Access.key(field)]) if is_binary(change), do: validate_length(acc, field, min: integer), else: validate_number(acc, field, greater_than_or_equal_to: integer) {:max, integer}, acc -> change = get_in(acc, [Access.key(:changes), Access.key(field)]) if is_binary(change), do: validate_length(acc, field, max: integer), else: validate_number(acc, field, less_than_or_equal_to: integer) {:trim, true}, acc -> update_change(acc, field, &String.trim/1) {:squish, true}, acc -> update_change(acc, field, &Goal.String.squish/1) {:format, :uuid}, acc -> validate_format(acc, field, Goal.Regex.uuid()) {:format, :email}, acc -> validate_format(acc, field, Goal.Regex.email()) {:format, :password}, acc -> validate_format(acc, field, Goal.Regex.password()) {:format, :url}, acc -> validate_format(acc, field, Goal.Regex.url()) {:less_than, integer}, acc -> validate_number(acc, field, less_than: integer) {:greater_than, integer}, acc -> validate_number(acc, field, greater_than: integer) {:less_than_or_equal_to, integer}, acc -> validate_number(acc, field, less_than_or_equal_to: integer) {:greater_than_or_equal_to, integer}, acc -> validate_number(acc, field, greater_than_or_equal_to: integer) {:equal_to, integer}, acc -> validate_number(acc, field, equal_to: integer) {:not_equal_to, integer}, acc -> validate_number(acc, field, not_equal_to: integer) {_name, _setting}, acc -> acc end) end defp validate_nested_fields(%Changeset{changes: changes} = changeset, types, schema) do Enum.reduce(types, changeset, fn {field, :map}, acc -> validate_map_field(changes, field, schema, acc) {field, {:array, :map}}, acc -> validate_array_field(changes, field, schema, acc) {_field, _type}, acc -> acc end) end defp validate_map_field(changes, field, schema, changeset) do params = Map.get(changes, field) rules = Map.get(schema, field) schema = Keyword.get(rules, :properties) if schema && params do schema |> build_changeset(params) |> case do %Changeset{valid?: true, changes: inner_changes} -> put_in(changeset, [Access.key(:changes), Access.key(field)], inner_changes) %Changeset{valid?: false} = inner_changeset -> changeset |> put_in([Access.key(:changes), Access.key(field)], inner_changeset) |> Map.put(:valid?, false) end else changeset end end defp validate_array_field(changes, field, schema, changeset) do params = Map.get(changes, field) rules = Map.get(schema, field) schema = Keyword.get(rules, :properties) if schema do {valid?, changesets} = Enum.reduce(params, {true, []}, fn params, {boolean, list} -> schema |> build_changeset(params) |> case do %Changeset{valid?: true, changes: inner_changes} -> {boolean, [inner_changes | list]} %Changeset{valid?: false} = inner_changeset -> {false, [inner_changeset | list]} end end) changeset |> put_in([Access.key(:changes), Access.key(field)], Enum.reverse(changesets)) |> Map.put(:valid?, valid?) else changeset end end defp is_atom_map?(map) when is_map(map) do Enum.reduce_while(map, false, fn {key, _value}, _acc -> {:halt, is_atom(key)} end) end defp recase_inbound_keys(_schema, value, _from_case, _is_atom_map) when is_struct(value), do: value defp recase_inbound_keys(schema, params, from_case, is_atom_map) when is_map(params) do Enum.reduce(schema, %{}, fn {field, rules}, acc -> recased_field = field |> Atom.to_string() |> recase_key(from_case) recased_field = if is_atom_map, do: String.to_atom(recased_field), else: recased_field fallback_field = if is_atom_map, do: field, else: Atom.to_string(field) cond do Map.has_key?(params, recased_field) -> Map.put(acc, field, get_value(rules, params, recased_field, from_case, is_atom_map)) Map.has_key?(params, fallback_field) -> Map.put(acc, field, get_value(rules, params, fallback_field, from_case, is_atom_map)) true -> acc end end) end defp recase_inbound_keys(schema, value, from_case, is_atom_map) when is_list(value) do Enum.map(value, &recase_inbound_keys(schema, &1, from_case, is_atom_map)) end defp recase_inbound_keys(_schema, value, _from_case, _is_atom_map), do: value defp get_value(rules, params, field, from_case, is_atom_map) do value = Map.get(params, field) cond do is_map(value) -> rules |> Keyword.get(:properties) |> recase_inbound_keys(value, from_case, is_atom_map) is_list(value) -> rules |> Keyword.get(:properties) |> recase_inbound_keys(value, from_case, is_atom_map) true -> value end end defp recase_outbound_keys(struct, _to_case, _is_atom_map) when is_struct(struct), do: struct defp recase_outbound_keys(params, to_case, is_atom_map) when is_map(params) do Enum.reduce(params, %{}, fn {key, value}, acc -> value = recase_outbound_keys(value, to_case, is_atom_map) key = if is_atom(key), do: Atom.to_string(key), else: key key = recase_key(key, to_case) key = if is_atom_map, do: String.to_atom(key), else: key Map.put(acc, key, value) end) end defp recase_outbound_keys(value, to_case, is_atom_map) when is_list(value) do Enum.map(value, &recase_outbound_keys(&1, to_case, is_atom_map)) end defp recase_outbound_keys(value, _to_case, _is_atom_map), do: value defp recase_key(string, :camel_case), do: Recase.to_camel(string) defp recase_key(string, :snake_case), do: Recase.to_snake(string) defp recase_key(string, :kebab_case), do: Recase.to_kebab(string) defp recase_key(string, :pascal_case), do: Recase.to_pascal(string) end