View Source Goal (goal v0.3.2)
Goal is a parameter validation library based on 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
:
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.
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.
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 schemas
Validation schemas can be defined in a separate namespace, for example MyAppWeb.MySchema
:
defmodule MyAppWeb.MySchema do
use Goal
defparams :show do
required :id, :string, format: :uuid
optional :query, :string
end
end
defmodule MyApp.SomeController do
use MyApp, :controller
alias MyAppWeb.MySchema
def show(conn, params) do
with {:ok, attrs} <- MySchema.validate(:show, params) do
...
else
{:error, changeset} -> {:error, changeset}
end
end
end
Features
Presence checks
Sometimes all you need is to check if a parameter is present:
use Goal
defparams :show do
required :id
optional :query
end
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.
use Goal
defparams :schema 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)> validate(:show, params)
{:ok, %{nested_map: %{inner_map: %{map: %{id: 123, list: [1, 2, 3]}}}}}
Powerful array validations
If you need expressive validations for arrays types, look no further!
Arrays can be made optional/required or the number of items can be set via min
, max
and is
.
Additionally, rules
allows specifying any validations that are available for the inner type.
Of course, both can be combined:
use Goal
defparams do
required :my_list, {:array, :string}, max: 2, rules: [trim: true, min: 1]
end
iex(1)> Goal.validate_params(schema(), %{"my_list" => ["hello ", " world "]})
{:ok, %{my_list: ["hello", "world"]}}
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.
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:
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:
config :goal,
recase_keys: [to: :camel_case]
defmodule MyAppWeb.UserJSON do
import Goal
def show(%{user: user}) do
recase_keys(%{data: %{first_name: user.first_name}})
end
def error(%{changeset: changeset}) do
recase_keys(%{errors: Goal.Changeset.traverse_errors(changeset, &translate_error/1)})
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:
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 | exact 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} | :rules | inner_type can be any basic type. rules supported all validations available for inner_type |
:min | minimum array length | |
:max | maximum array length | |
:is | exact array length | |
More basic types | See Ecto.Schema for the full list |
All field types, excluding :map
and {:array, :map}
, can use :equals
, :subset
,
:included
, :excluded
validations.
Credits
This library is based on Ecto and I had to copy and adapt
Ecto.Changeset.traverse_errors/2
. Thanks for making such an awesome library! 🙇
Summary
Functions
Builds an Ecto.Changeset
using the parameters and a validation schema.
A macro for defining validation schemas encapsulated in a schema
function with arity 0.
A macro for defining validation schemas encapsulated in a schema
function with arity 1.
The argument can be an atom or a binary.
Recases parameter keys.
Recases parameter keys that are present in the schema.
Traverses changeset errors and applies the given function to error messages.
Validates parameters against a validation schema.
Functions
@spec build_changeset(schema(), params()) :: Ecto.Changeset.t()
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 defparams(do_block()) :: any()
A macro for defining validation schemas encapsulated in a schema
function with arity 0.
defmodule MySchema do
use Goal
defparams do
required :id, :string, format: :uuid
end
end
iex(1)> schema()
%{id: [type: :integer, required: true]}]
@spec defparams(name(), do_block()) :: any()
A macro for defining validation schemas encapsulated in a schema
function with arity 1.
The argument can be an atom or a binary.
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 recase_keys(params(), opts()) :: params()
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(schema(), params(), opts()) :: params()
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 traverse_errors( changeset(), (error() -> binary()) | (changeset(), atom(), error() -> binary()) ) :: %{required(atom()) => [term()]}
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 validate_params(schema(), params(), opts()) ::
{:ok, params()} | {:error, changeset()}
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", ...}]}}