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
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
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.