Current section
Files
Jump to
Current section
Files
lib/para.ex
defmodule Para do
@moduledoc """
Para is an Elixir library that provides structured and
declarative way to parse and validate parameters.
Para uses Ecto under the hood and therefore inherits most of
its utilities such as changeset and built-in validators.
## Usage
Let's imagine that you have a controller named `Web.UserController` and
wanted to validate the parameters for its `:create` and `:update` actions
First, let's define your parameters schema
defmodule Web.UserPara do
use Para
validator :create do
required :name, :string
required :age, :integer
required :email, :string
optional :phone, :string
end
validator :update do
required :name, :string
required :age, :integer
required :email, :string
optional :phone, :string
end
end
You can now use this module as a validator in your controller
defmodule Web.UserController do
use Web, :schema
alias Web.UserPara
def create(conn, params) do
with {:ok, data} <- UserPara.validate(:create, params) do
# ...
end
end
end
## Inline validators
Inline validator is a convenient way to validate your fields. This is
especially useful when you need to perform some basic validation
using `Ecto.Changeset`'s built-in validators.
defmodule UserPara do
use Para
validator :update do
required :name, :string, validator: {:validate_length, [min: 3, max: 100]}
end
end
You can also use custom inline validators by supplying the function name
as an atom. Custom inline validator will receive `changeset` and the
original `params` as the arguments.
defmodule UserPara do
use Para
validator :update do
required :age, :string, validator: :validate_age
end
def validate_age(changeset, params) do
# ...
end
end
## Callback validator
Sometimes, you might want to use custom validators or need to perform
additional data manipulations. For this, you can use the `callback/1` macro.
The `callback/1` macro will always be the last function to be called
after the validator has parsed and validated the parameters.
defmodule Web.UserPara do
use Para
validator :create do
required :name, :string
required :age, :integer
required :email, :string
optional :phone, :string
callback :create_validators
end
def create_validators(changeset, params) do
changeset
|> format_email(params)
|> format_phone(params)
|> validate_age()
end
def format_email(changeset, params) do
# ...
end
def format_phone(changeset, params) do
# ...
end
def validate_age(changeset) do
# ...
end
end
"""
@type t :: {:ok, map()} | {:error, Ecto.Changeset.t()}
@doc false
defmacro __using__(_) do
quote do
import Para,
only: [
validator: 2,
required: 1,
required: 2,
required: 3,
optional: 1,
optional: 2,
optional: 3,
callback: 1,
embeds_one: 2,
embeds_many: 2
]
@doc """
Parse and validate parameters for a given action
"""
@spec validate(atom, map) :: {:ok, map} | {:error, Ecto.Changeset.t()}
def validate(action, params)
end
end
@doc """
Define a validator schema with an action name and field definitions.
This will generate a new function called `validate/2` with the action `name`
and `params` as the arguments.
iex> defmodule UserPara do
...> use Para
...>
...> validator :create do
...> required :name
...> end
...> end
...>
...> UserPara.validate(:create, %{"name" => "Syamil MJ"})
{:ok, %{name: "Syamil MJ"}}
"""
defmacro validator(name, do: block) do
fields =
case block do
{:__block__, _, fields} -> fields
block -> [block]
end
quote do
def validate(unquote(name), params) do
Para.validate(__MODULE__, unquote(fields), params)
end
end
end
@doc """
Define custom callback to perform any additional parsing or validation
to the processed changeset or parameters
"""
defmacro callback(name) do
quote do
{:callback, unquote(name)}
end
end
@doc """
Define a required field.
## Options
* `:default` - Assign a default value if the not set by input parameters
* `:validator` - Define either one of the built-in Ecto.Changeset's validators
or use your own custom inline validator. Refer: [Custom inline validator](#required/3-custom-inline-validator)
* `:droppable` - Drop the field when the key doesn't exist in parameters. This
is useful when you need to perform partial update by leaving out certain fields.
## Custom inline validator
You can define your own validator as such:
def validate_country(changeset, field) do
# ...
end
Then use it as an inline validator for your field
validator :create do
required :country, :string, [validator: :validate_country]
end
"""
defmacro required(name, type \\ :string, opts \\ []) do
quote do
{:required, unquote(name), unquote(type), unquote(opts)}
end
end
@doc """
Define an optional field.
Similar to `required/3`, it also accepts the same Options
"""
defmacro optional(name, type \\ :string, opts \\ []) do
quote do
{:optional, unquote(name), unquote(type), unquote(opts)}
end
end
@doc """
Define an embedded map field
"""
defmacro embeds_one(name, do: block) do
fields =
case block do
{:__block__, _, fields} -> fields
block -> [block]
end
quote do
{:embed_one, unquote(name), unquote(fields)}
end
end
@doc """
Define an embedded array of maps field
"""
defmacro embeds_many(name, do: block) do
fields =
case block do
{:__block__, _, fields} -> fields
block -> [block]
end
quote do
{:embed_many, unquote(name), unquote(fields)}
end
end
@doc false
def validate(module, fields, params) do
case changeset = do_validate(module, fields, params) do
%{valid?: true} -> {:ok, apply_changes(changeset)}
_ -> {:error, changeset}
end
end
@doc false
def do_validate(module, fields, params) do
spec = build_spec(fields, params)
callback =
Enum.find_value(fields, fn
{:callback, name} -> name
_ -> nil
end)
{spec.data, spec.types}
|> Ecto.Changeset.cast(params, spec.permitted)
|> Ecto.Changeset.validate_required(spec.required)
|> validate_embeds(module, spec)
|> apply_inline_validators(module, spec.validators)
|> apply_callback(module, callback, params)
end
@doc false
def build_spec(fields, params) do
default = %{data: %{}, types: %{}, embeds: %{}, permitted: [], required: [], validators: %{}}
fields
|> discard_droppable_fields(params)
|> Enum.reduce(default, fn
{:embed_one, name, block}, acc ->
acc
|> put_in([:data, name], nil)
|> put_in([:embeds, name], {:embed_one, block})
|> put_in([:types, name], {:map, :string})
{:embed_many, name, block}, acc ->
acc
|> put_in([:data, name], nil)
|> put_in([:embeds, name], {:embed_many, block})
|> put_in([:types, name], {:map, :string})
{requirement, name, type, opts}, acc ->
acc
|> put_in([:data, name], opts[:default])
|> put_in([:types, name], type)
|> assign_permitted_fields(name)
|> assign_required_fields(requirement, name)
|> assign_inline_validators(name, opts)
_, acc ->
acc
end)
end
@doc false
def discard_droppable_fields(fields, params) do
Enum.filter(fields, fn
# optional/required fields
{_, name, _, opts} ->
with true <- opts[:droppable],
false <- Map.has_key?(params, Atom.to_string(name)) do
false
else
_ -> true
end
# embed fields
{_, name, opts} ->
with true <- opts[:droppable],
false <- Map.has_key?(params, Atom.to_string(name)) do
false
else
_ -> true
end
any ->
any
end)
end
@doc false
def assign_permitted_fields(spec, name) do
put_in(spec, [:permitted], spec.permitted ++ [name])
end
@doc false
def assign_required_fields(spec, requirement, name) do
case requirement do
:required ->
put_in(spec, [:required], spec.required ++ [name])
:optional ->
spec
end
end
@doc false
def assign_inline_validators(spec, name, opts) do
if validator = opts[:validator] do
put_in(spec, [:validators, name], validator)
else
put_in(spec, [:validators], spec.validators)
end
end
@doc false
def validate_embeds(changeset, module, %{embeds: embeds}) do
Enum.reduce(embeds, changeset, fn {name, embed}, acc ->
validate_embed(acc, module, name, embed, changeset.params)
end)
end
def validate_embeds(changeset, _, _), do: changeset
@doc false
def validate_embed(changeset, module, name, {:embed_one, block}, params) do
params = Map.get(params, Atom.to_string(name))
case do_validate(module, block, params) do
%{valid?: true} = valid_changeset ->
Ecto.Changeset.put_change(changeset, name, valid_changeset)
invalid_changeset ->
Ecto.Changeset.put_change(%{changeset | valid?: false}, name, invalid_changeset)
end
end
def validate_embed(changeset, module, name, {:embed_many, block}, params) do
params = Map.get(params, Atom.to_string(name))
if is_list(params) do
Enum.reduce(params, changeset, fn embedded_params, acc ->
embedded_changesets = Ecto.Changeset.get_change(acc, name, [])
case do_validate(module, block, embedded_params) do
%{valid?: true} = valid_changeset ->
Ecto.Changeset.put_change(
acc,
name,
embedded_changesets ++ [valid_changeset]
)
invalid_changeset ->
Ecto.Changeset.put_change(
%{acc | valid?: false},
name,
embedded_changesets ++ [invalid_changeset]
)
end
end)
end
end
@doc false
def apply_inline_validators(changeset, module, validators) do
Enum.reduce(validators, changeset, fn {key, validator}, acc ->
apply_inline_validator(acc, module, key, validator)
end)
end
@doc false
def apply_inline_validator(changeset, module, key, validator) do
case validator do
{function, data_or_opts} ->
do_apply_inline_validator(module, function, [changeset, key] ++ [data_or_opts])
{function, data, opts} ->
do_apply_inline_validator(module, function, [changeset, key] ++ [data, opts])
_ ->
changeset
end
end
@doc false
def do_apply_inline_validator(module, function, params) do
arity = length(params)
if function_exported?(Ecto.Changeset, function, arity) do
apply(Ecto.Changeset, function, params)
else
apply(module, function, params)
end
end
@doc false
def apply_callback(changeset, _, nil, _), do: changeset
def apply_callback(changeset, module, callback, params) do
apply(module, callback, [changeset, params])
end
@doc false
def apply_changes(list) when is_list(list) do
Enum.map(list, &apply_changes/1)
end
def apply_changes(%{changes: changes, data: data}) do
Enum.reduce(changes, data, fn
{key, list}, acc when is_list(list) ->
Map.put(acc, key, apply_changes(list))
{key, %Ecto.Changeset{} = changeset}, acc ->
Map.put(acc, key, apply_changes(changeset))
{key, value}, acc ->
Map.put(acc, key, value)
end)
end
end