Current section
Files
Jump to
Current section
Files
lib/para.ex
defmodule Para do
@moduledoc """
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.UserParams 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
This will generate two `validate/2` functions for your module
with action `name` and `params` as arguments.
defmodule Web.UserController do
use Web, :controller
alias Web.UserParams, as: Params
def create(conn, params) do
with {:ok, data} <- Params.validate(:create, params) do
# ...
end
end
def update(conn, params) do
with {:ok, data} <- Params.validate(:update, params) do
# ...
end
end
end
The `validate/2` function will return either an `{:ok, map}` or `{:error, changeset}`
tuple.
## 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 UserParams 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. Similar to most of Ecto's built-in validators, the function will
receive `changeset`, `key`, and `opts` as the arguments.
defmodule UserParams do
use Para
validator :update do
required :age, :string, validator: :validate_age
required :gender, :string, validator: {:validate_gender, [allow: :non_binary]}
end
def validate_age(changeset, key, opts) 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 parametes have been parsed and validated.
defmodule Web.UserParams 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()}
@type data :: %{atom => term}
@type spec :: %{
data: map,
types: map,
embeds: map,
permitted: list,
required: list,
validators: map
}
@doc """
Parse and validate parameters for a given action.
The function will cast all the returned map keys into atoms except
for embedded map or list.
## Examples
defmodule OrderParams do
use Para
validator :create do
required :title
required :data, {:array, :map}
end
end
# Validate action with parameters
OrderParams.validate(:create, %{
"title" => "test"
"data" => [%{"color" => "black", "material" => "cotton"}]
})
#=> {:ok, %{
title: "test"
data: [%{"color" => "black", "material" => "cotton"}]
}}
"""
@callback validate(atom, map) :: {:ok, data} | {:error, Ecto.Changeset.t()}
@doc """
Returns a basic spec.
This is useful when you need to build a custom changeset,
or when you just need the basic structure of your schema.
Also see: `Ecto.Changeset.change/2`
## Examples
defmodule OrderParams do
use Para
validator :create do
required :title
required :data, {:array, :map}
end
end
def changeset(:new, params) do
spec = spec(:create, params)
Ecto.Changeset.change(spec.data, spec.types)
end
"""
@callback spec(atom, map) :: spec()
@doc false
defmacro __using__(_) do
quote do
@behaviour Para
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
]
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 UserParams do
...> use Para
...>
...> validator :create do
...> required :name
...> end
...> end
...>
...> UserParams.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
def spec(unquote(name), params) do
Para.build_spec(unquote(fields), params)
end
end
end
@doc """
Define a custom callback function that will be called to perform any
additional manipulation to the changeset or parameters.
The callback function must accept two arguments namely `changeset` and
`params` and return an `Ecto.Changeset` struct.
## Examples
# Define callback function to be called
validator :create do
callback :validate_price
end
def validate_price(changeset, params) do
#...
end
"""
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
You can also supply options with your custom inline validator
validator :create do
required :country, :string, [validator: {:validate_country, region: :asia}]
end
"""
defmacro required(name, type \\ :string, opts \\ []) do
quote do
{:required, unquote(name), unquote(type), unquote(opts)}
end
end
@doc """
Define an optional field.
Please refer to `required/3` for the list of available 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
It accepts similar schema definition like `validator/2`.
## Examples
defmodule ParentParams do
use Para
validator :create do
embeds_one :child do
optional :name, :string
optional :age, :integer
end
end
end
"""
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.
It accepts similar schema definition like `validator/2`.
## Examples
defmodule OrderParams do
use Para
validator :create do
embeds_many :items do
required :title
required :price, :float
end
end
end
"""
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 ->
type = init_field_type(type, opts)
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, :required, name) do
put_in(spec, [:required], spec.required ++ [name])
end
def assign_required_fields(spec, _, _), do: spec
@doc false
def assign_inline_validators(spec, name, opts) do
if validator = opts[:validator] do
put_in(spec, [:validators, name], validator)
else
spec
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)
else
changeset
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])
function when is_atom(function) ->
do_apply_inline_validator(module, function, [changeset, key, []])
_ ->
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(%{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
def apply_changes(list) when is_list(list) do
Enum.map(list, &apply_changes/1)
end
def apply_changes(any), do: any
# We don't perform advanced validation here, as Ecto will handle the rest
defp init_field_type(type, opts) when is_atom(type) do
cond do
Ecto.Type.base?(type) ->
type
Code.ensure_compiled(type) == {:module, type} ->
cond do
function_exported?(type, :type, 0) ->
type
function_exported?(type, :type, 1) ->
Ecto.ParameterizedType.init(type, opts)
true ->
raise ArgumentError,
"module #{inspect(type)} is not an Ecto.Type/Ecto.ParameterizedType"
end
true ->
type
end
end
defp init_field_type(type, _), do: type
end