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 This will generate two `validate/2` functions for your module with the action and params as the arguments. 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 def update(conn, params) do with {:ok, data} <- UserPara.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 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()} @type data :: %{atom => term} @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 OrderPara do use Para validator :create do required :title required :data, {:array, :map} end end # Validate action with parameters OrderPara.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 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 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 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 """ 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 It accepts similar schema definition like `validator/2`. ## Examples defmodule ParentPara 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 OrderPara 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 -> 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]) _ -> 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 end