XUtils

goal

A parameter validation library for LiveViews and JSON/HTML controllers - based on Ecto.


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! 🙇


Articles

  • coming soon...