View Source Goal (goal v0.1.0)

Goal is a parameter validation library based on Ecto.

It takes the parameters, e.g. from a Phoenix controller, validates them against a schema, and returns an atom-based map with the validated data. An Ecto.Changeset is returned if any of the parameters is invalid.

Goal uses the validation rules from Ecto.Changeset, which means you can use any validation that is available for database fields for validating parameters with Goal.

A common use-case is parsing and validating parameters from Phoenix controllers:

defmodule MyApp.SomeController do
  import Goal

  @schema %{
    id: [format: :uuid, required: true],
    name: [min: 3, max: 20, required: true]
  }

  def create(conn, params) do
    with {:ok, attrs} <- validate_params(params, @schema) do
      ...
    end
  end
end

With the defschema macro:

defmodule MyApp.SomeController do
  import Goal
  import Goal.Syntax

  def create(conn, params) do
    with {:ok, attrs} <- validate_params(params, schema()) do
      ...
    end
  end

  def schema do
    defschema do
      required :id, format: :uuid
      required :name, min: 3, max: 20
    end
  end
end

defining-validations

Defining validations

Define field types with :type:

  • :string
  • :integer
  • :boolean
  • :float
  • :decimal
  • :date
  • :time
  • :map
  • {:array, inner_type}, where inner_type can be any of the field types
  • See Ecto.Schema for the full list

The default field type is :string. That means you don't have to define this field in the schema if the value will be a string.

Define map fields with :properties.

Define string validations:

  • :equals, string value
  • :is, string length
  • :min, minimum string length
  • :max, maximum string length
  • :trim, boolean to remove leading and trailing spaces
  • :squish, boolean to trim and collapse spaces
  • :format, atom to define the regex (available are: :uuid, :email, :password, :url)

Define integer validations:

  • :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

Define enum validations:

  • :excluded, list of disallowed values
  • :included, list of allowed values
  • :subset, list of values

bring-your-own-regex

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, you can configure your own regexes in your application configuration.

config :goal,
  uuid_regex: ~r/^[[:alpha:]]+$/,
  email_regex: ~r/^[[:alpha:]]+$/,
  password_regex: ~r/^[[:alpha:]]+$/,
  url_regex: ~r/^[[:alpha:]]+$/

deeply-nested-maps

Deeply nested maps

Goal efficiently builds error changesets for nested maps. There is no limitation on depth. If the schema is becoming too verbose, you could consider splitting up the schema into reusable components.

params = %{
  "nested_map" => %{
    "map" => %{
      "inner_map" => %{
        "id" => 123,
        "list" => [1, 2, 3]
      }
    }
  }
}

schema = %{
  nested_map: [
    type: :map,
    properties: %{
      inner_map: [
        type: :map,
        properties: %{
          map: [
            type: :map,
            properties: %{
              id: [type: :integer, required: true],
              list: [type: {:array, :integer}]
            }
          ]
        }
      ]
    }
  ]
}

iex(3)> Goal.validate_params(params, schema)
{:ok, %{nested_map: %{inner_map: %{map: %{id: 123, list: [1, 2, 3]}}}}}

use-defschema-to-reduce-boilerplate

Use defschema to reduce boilerplate

Goal provides a macro called Goal.Syntax.defschema/1 to build validation schemas without all the boilerplate code. The previous example of deeply nested maps can be rewritten as:

import Goal.Syntax

params = %{...}

schema =
  defschema do
    optional :nested_map, :map do
      optional :inner_map, :map do
        optional :map, :map do
          required :id, :integer
          optional :list, {:array, :integer}
        end
      end
    end
  end

iex(3)> Goal.validate_params(params, schema)
{:ok, %{nested_map: %{inner_map: %{map: %{id: 123, list: [1, 2, 3]}}}}}

human-readable-error-messages

Human-readable error messages

Use Goal.traverse_errors/2 to build readable errors. Ecto and Phoenix by default use Ecto.Changeset.traverse_errors/2, which works for embedded Ecto schemas but not for the plain nested maps used by Goal.

def translate_errors(changeset) do
  Goal.traverse_errors(changeset, &translate_error/1)
end

credits

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

Link to this section Summary

Functions

Traverses changeset errors and applies the given function to error messages.

Validates the parameters against a schema.

Link to this section Types

@type error() :: {String.t(), Keyword.t()}
@type params() :: map()
@type schema() :: map()

Link to this section Functions

Link to this function

traverse_errors(changeset, msg_func)

View Source
@spec traverse_errors(
  Ecto.Changeset.t(),
  (error() -> binary()) | (Ecto.Changeset.t(), atom(), error() -> binary())
) :: %{required(atom()) => [term()]}

Traverses changeset errors and applies the given function to error messages.

examples

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(params, schema)

View Source
@spec validate_params(params(), schema()) ::
  {:ok, map()} | {:error, Ecto.Changeset.t()}

Validates the parameters against a schema.

examples

Examples

iex> validate_params(%{"email" => "jane@example.com"}, %{email: [format: :email]})
{:ok, %{email: "jane@example.com"}}

iex> validate_params(%{"email" => "invalid"}, %{email: [format: :email]})
{:error, %Ecto.Changeset{valid?: false, errors: [email: {"has invalid format", ...}]}}