VBT.Validation (vbt v0.1.0) View Source

Helpers for validating and normalizing "free-form" maps, such as maps representing input parameters in Phoenix controllers.

This module can be considered as a lightweight equivalent of GraphQL schemas for REST and socket interfaces. It is typically used in Phoenix controllers, sockets, or LiveView modules to normalize the input data. The module can also help with normalization of 3rd party API JSON responses.

Link to this section Summary

Functions

Normalizes a free-form map according to the given specification.

Link to this section Types

Specs

field_name() :: atom()

Specs

field_opts() :: [{:required, boolean()}]

Specs

field_spec() ::
  {field_name(), field_type()} | {field_name(), {field_type(), field_opts()}}

Specs

field_specs() :: [field_spec(), ...]

Specs

field_type() ::
  atom()
  | {:enum, [atom()]}
  | {module(), any()}
  | nested()
  | {:array, field_type()}

Specs

nested() :: {:map, field_specs() | {field_specs(), normalize_opts()}}

Specs

normalize_opts() :: [
  action: Ecto.Changeset.action(),
  validate: (Ecto.Changeset.t() -> Ecto.Changeset.t())
]

Link to this section Functions

Link to this function

normalize(data, specs, opts \\ [])

View Source

Specs

normalize(map(), field_specs(), normalize_opts()) ::
  {:ok, map()} | {:error, Ecto.Changeset.t()}

Normalizes a free-form map according to the given specification.

Example:

iex> VBT.Validation.normalize(
...>   %{"foo" => "bar", "baz" => "1"},
...>   foo: :string,
...>   baz: {:integer, required: :true},
...>   qux: :string
...> )
{:ok, %{foo: "bar", baz: 1}}

This function is a wrapper around schemaless changesets. The code above is roughly similar to the following manual version:

data = %{"foo" => "bar", "baz" => "1"}
types = %{foo: :string, baz: :integer, qux: :string}

{%{}, types}
|> Ecto.Changeset.cast(data, Map.keys(types))
|> Ecto.Changeset.validate_required(~w/baz/a)
|> Ecto.Changeset.apply_action(:insert)

Since it is based on Ecto changesets, the function supports the same types (see here for details).

In addition, custom {:enum, values} can be provided for the type. In this case, Ecto.Enum will be used to validate the value and normalize the result to the atom type.

Finally, you can provide {module, arg} for the type, where module implements the Ecto.ParameterizedType behaviour. Note that you can only provide the parameterized type in the fully expanded form, i.e. as field_name: {{module, arg}, field_opts}.

If validation fails, an error changeset is returned, with the action set to :insert. You can set a different action with the :action option.

If you're using this function in Phoenix controllers and rendering the error changeset in a form, you need to provide the underlying type name explicitly:

<%= form_for @changeset, some_path, [as: :user], fn f -> %>
  # ...
<% end %>

See Phoenix.HTML.Form.form_for/4 for details.

Custom validations

You can perform additional custom validations with the :validate option:

Validation.normalize(
  data,
  [password: :string],
  validate: &Ecto.Changeset.validate_confirmation(&1, :password, required: true)
)

The :validate option is a function which takes a changeset and returns the changeset with extra custom validations performed.

Nested data structures

This function can be used to normalize the nested data (maps inside maps, and list of maps).

However, due to limitations in Ecto changesets, this function doesn't return errors which can work with Phoenix HTML forms. Therefore it is not recommended to use this feature in such situations. Instead consider using embedded schemas.

On the other hand, you can use this feature to normalize the data from 3rd party APIs. In case of validation errors, the resulting changeset will contain detailed information (see the "Nested errors" section for details).

Direct nesting

A nested map can be described as {:map, nested_type_spec}. For example:

order_item_spec = {:map, product_id: :integer, quantity: :integer}
order_spec = [user_id: :integer, order_item: order_item_spec]

data = %{
  "user_id" => "1",
  "order_item" => %{"product_id" => "2", "quantity" => "3"}
}

Validation.normalize(data, order_spec)

If you want to provide additional normalization options you can use a tuple form:

order_item_spec =
  {
    :map,
    {
      # nested type specification
      [product_id: :integer, quantity: :integer],

      # normalization options for this nested type
      validate: &custom_order_item_validation/1
    }
  }

order_spec = [user_id: :integer, order_item: order_item_spec]
Validation.normalize(data, order_spec)

Nesting inside lists

A list of nested maps can be described as follows:

order_item_spec = {:map, product_id: :integer, quantity: :integer}
order_spec = [user_id: :integer, order_items: {:array, order_item_spec}]

data = %{
  "user_id" => "1",
  "order_items" => [
    %{"product_id" => "2", "quantity" => "3"},
    %{"product_id" => "4", "quantity" => "5"}
  ]
}

Validation.normalize(data, order_spec)

Nested errors

The resulting changeset will contain expanded errors for all nested structures. For example, suppose we're trying to cast two order items, where 1st one has two errors, and the 2nd one has three errors. The final changeset will contain 5 errors, all of them residing under the :order_items field.

Each error will contain the :path meta that points to the problematic field. For example the :path of an error in the :product_id field of the 2nd item will be [1, :product_id], where 1 represents an index in the list, and :product_id the field name inside the nested data structure.