Examples
Goal can be used with LiveViews and JSON and HTML controllers.
Example with JSON and HTTP 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 AppWeb.SomeController do
use AppWeb, :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 :hobbies, {:array, :string}, max: 3, rules: [trim: true, min: 1]
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 AppWeb.SomeLiveView do
use AppWeb, :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 :hobbies, {:array, :string}, max: 3, rules: [trim: true, min: 1]
optional :data, :map do
required :color, :string
optional :money, :decimal
optional :height, :float
end
end
end
Example with GraphQL resolvers
With GraphQL, you may want to validate input fields without marking them as non-null
to enhance
backward compatibility. You can use Goal inside GraphQL resolvers to validate the input fields:
defmodule AppWeb.MyResolver do
use Goal
defparams(:create_user) do
required(:id, :uuid)
required(:input, :map) do
required(:first_name, :string)
required(:last_name, :string)
end
end
def create_user(args, info) do
with {:ok, attrs} <- validate(:create_user) do
...
end
end
end
Example with isolated schemas
Validation schemas can be defined in a separate namespace, for example AppWeb.MySchema
:
defmodule AppWeb.MySchema do
use Goal
defparams :show do
required :id, :string, format: :uuid
optional :query, :string
end
end
defmodule AppWeb.SomeController do
use AppWeb, :controller
alias AppWeb.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
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 AppWeb.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:]]+$/
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! 🙇