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 typeValidationsDescription
:uuid:equalsstring value
:string:equalsstring value
:isexact string length
:minminimum string length
:maxmaximum string length
:trimoolean to remove leading and trailing spaces
:squishboolean to trim and collapse spaces
:format:uuid, :email, :password, :url
:subsetlist of required strings
:includedlist of allowed strings
:excludedlist of disallowed strings
:integer:equalsinteger value
:isinteger value
:minminimum integer value
:maxmaximum integer value
:greater_thanminimum integer value
:less_thanmaximum integer value
:greater_than_or_equal_tominimum integer value
:less_than_or_equal_tomaximum integer value
:equal_tointeger value
:not_equal_tointeger value
:subsetlist of required integers
:includedlist of allowed integers
:excludedlist of disallowed integers
:floatall of the integer validations
:decimalall of the integer validations
:boolean:equalsboolean value
:date:equalsdate value
:time:equalstime value
:enum:valueslist of allowed values
:map:propertiesuse :properties to define the fields
{:array, :map}:propertiesuse :properties to define the fields
{:array, inner_type}:rulesinner_type can be any basic type. rules supported all validations available for inner_type
:minminimum array length
:maxmaximum array length
:isexact array length
More basic typesSee 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

Link to this function

build_changeset(schema, params)

View Source
@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", ...}]}
Link to this macro

defparams(list)

View Source (macro)
@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]}]
Link to this macro

defparams(name, list)

View Source (macro)
@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}}
Link to this function

recase_keys(params, opts \\ [])

View Source
@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.

Link to this function

recase_keys(schema, params, opts)

View Source
@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.

Link to this function

traverse_errors(changeset, msg_func)

View Source
@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"]}
Link to this function

validate_params(schema, params, opts \\ [])

View Source
@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", ...}]}}