Exop
Elixir library that provides macros which allow you to encapsulate business logic and validate incoming parameters with predefined contract.
Install / Use
/learn @implicitly-awesome/ExopREADME
Exop
A library that helps you to organize your Elixir code in more domain-driven way. Exop provides macros which helps you to encapsulate business logic and offers you additionally: incoming params validation (with predefined contract), params coercion, policy check, fallback behavior, operations chaining and more.
Exop family:
ExopData
Interested in property-based testing? Check out new Exop family member - ExopData. If you use Exop to organize your code with ExopData you can get property generators in the most easiest way.
ExopPlug
The new ExopPlug library provides a convenient way to validate incoming parameters of your Phoenix application's controllers by offering you small but useful DSL.
Table of Contents
Here is the CHANGELOG that was started from ver. 0.4.1 ¯\_(ツ)_/¯
Installation
def deps do
[{:exop, "~> 1.4"}]
end
Operation definition
defmodule IntegersDivision do
use Exop.Operation
parameter :a, type: :integer, default: 1
parameter :b, type: :integer, required: false,
numericality: %{greater_than: 0}
def process(params) do
result = params[:a] / params[:b]
IO.inspect "The division result is: #{result}"
end
end
Exop.Operation provides parameter macro, which is responsible for the contract definition.
Its spec is @spec parameter(atom | String.t, Keyword.t) :: none, we define parameter name as the first argument and parameter options as the second Keyword argument.
A parameter name could be either an atom or a string. You could even mix atom-named and string-named parameters in an operation's contract.
Parameter options determine a contract of a parameter, a set of parameters contracts is an operation contract.
Business logic of an operation is defined in process/1 function, which is required by the Exop.Operation module
behaviour.
After the contract and business logic were defined, you can invoke the operation simply by calling run/1 function:
iex> IntegersDivision.run(a: 50, b: 5)
{:ok, "The division result is: 10"}
Return type will be either {:ok, any()} (where the second item in the tuple is process/1 function's result) or
{:error, {:validation, map()}} (where the map() is validation errors map).
for more information see Operation results section
Parameter checks
A parameter options could have various checks. Here the list of available checks:
typerequireddefaultnumericalityequals(exactly)innot_informat(regex)lengthinnerstructlist_itemfuncallow_nilfromsubset_of
type
Checks whether a parameter's value is of declared type.
parameter :some_param, type: :map
Exop handle almost all Elixir types and some additional:
- :boolean
- :integer
- :float
- :string
- :tuple
- :map
- :keyword
- :list
- :atom
- :module
- :function
- :uuid
Unknown type always generates ArgumentError exception on compile time.
module 'type' means Exop expects a parameter's value to be an atom (a module name) and this module should be already loaded (ready to call it's functions)
uuid is not actually a "type" but I placed this under :type check because there is no reason to have dedicated :uuid check.
required
Checks the presence/absence of a parameter in passed to run/1 params collection.
Given parameters collection fails the validation only if required parameter is missed,
if required parameter's value is nil this parameter will pass this check.
parameter :param_a # the same as required: true, required by default
parameter :param_b, required: false # this parameter is not required
By default, a parameter is required (since version 1.2.0, required: true).
If you want to specify a parameter is not required, provide required: false.
Why? Because you might find that you repetitively type required: true for almost every parameter in a contract. I think if you provide a parameter to an operation (define it in a contract) you expect to get it. Cases, when you need a parameter passed into an operation (and don't really care whether it is present or not), are pretty rare.
Since version 1.1.0 the behavior of this check has been changed. Check out CHANGELOG for more info.
default
Checks the presence of a parameter in passed to run/1 params collection,
and if the parameter is missed - assign default value to it.
parameter :some_param, default: "default value"
# default value can be also a 1-arity function output
parameter :a, type: :integer, default: &__MODULE__.default_a/1
parameter :b, type: :integer
# this function takes params given to `run/1`
def default_a(params), do: params.b + 1
#iex> YourOperation.run(b: 1)
#iex> %{a: 2, b: 1}
numericality
Checks whether a parameter's value is a number and other numeric constraints. All possible constraints are listed in the example below.
parameter :some_param, numericality: %{equal_to: 10, # (aliases: `equals`, `is`, `eq`)
greater_than: 0, # (alias: `gt`)
greater_than_or_equal_to: 10 # (aliases: `min`, `gte`),
less_than: 20, # (alias: `lt`)
less_than_or_equal_to: 10 # (aliases: `max`, `lte`)}
equals
(alias: exactly)
Checks whether a parameter's value exactly equals given value (with type equality).
parameter :some_param, equals: 100.5
parameter :some_param, exactly: 100.5
in
Checks whether a parameter's value is within a given list.
parameter :some_param, in: ~w(a b c)
not_in
Checks whether a parameter's value is not within a given list.
parameter :some_param, not_in: ~w(a b c)
format
(alias: regex)
Checks wether parameter's value matches given regex.
parameter :some_param, format: ~r/foo/
parameter :some_param, regex: ~r/foo/
length
Checks the length of a parameter's value. The value should be one of handled types:
- list (items count)
- string (chars count)
- atom (treated as string)
- map (key-value pairs count)
- tuple (items count)
length check is complex as numericality (should define map of inner checks).
All possible checks are listed in the example below.
parameter :some_param, length: %{gte: 5, gt: 4, min: 5, lte: 10, lt: 9, max: 10, is: 7, in: 5..8}
inner
Checks the inner of either Map or Keyword parameter. It applies checks described in inner map to
related inner items.
# some_param = %{a: 3, b: "inner_b_attr"}
parameter :some_param, type: :map, inner: %{
a: [type: :integer],
b: [type: :string, length: %{min: 1, max: 6}]
}
# you can omit `type` and `inner` checks keywords in order to check inner of your parameter,
# when `type` hasn't been specified explicitly, both keyword and map types pass the `type` validation
parameter :some_param, %{
a: [type: :integer],
b: [type: :string, length: %{min: 1, max: 6}]
}
And, of course, all checks on a parent parameter (:some_param in the example) are still applied.
struct
Checks whether the given parameter is expected structure.
parameter :some_param, struct: %SomeStruct{}
# or
parameter :some_param, struct: SomeStruct
list_item
Checks whether each of list items conforms defined checks. An item's checks could be any that Exop offers:
# list_param = ["1234567", "7chars"]
# you can omit `type` check while you're passing a list to an operation
parameter :list_param, list_item: %{type: :string, length: %{min: 7}}
Even more complex like inner:
# list_param = [
# %TestStruct{a: 3, b: "6chars"},
# %TestStruct{a: nil, b: "7charss"}
# ]
parameter :list_param, list_item: %{inner: %{
a: %{type: :integer},
b: %{type: :string, length: %{min: 7}}
}}
Moreover, coerce_with and default options are available too.
