Exop v0.4.6 Exop

A library that provides a macros which allow you to encapsulate business logic and validate incoming params over predefined contract.

Operation definition

defmodule IntegersDivision do
  use Exop.Operation

  parameter :a, type: :integer, default: 1
  parameter :b, type: :integer, required: true,
                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, Keyword.t) :: none, we define parameter name as the first atom attribute and parameter options as the second Keyword attribute.

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

Parameter checks

A parameter options could have various checks. Here the list of checks available yet:

  • type
  • required
  • default
  • numericality
  • in
  • not_in
  • format
  • length
  • inner
  • struct
  • func

type

Checks whether a parameter’s value is of declared type.

parameter :some_param, type: :map

Exop handle almost all Elixir types:

  • :boolean
  • :integer
  • :float
  • :string
  • :tuple
  • :map
  • :struct
  • :list
  • :atom
  • :function

Unknown type always passes this check.

required

Checks the presence of a parameter in passed to run/1 params collection.

parameter :some_param, required: true

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"

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,
                                      greater_than: 0,
                                      greater_than_or_equal_to: 10,
                                      less_than: 20,
                                      less_than_or_equal_to: 10}

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

Checks wether parameter’s value matches given regex.

parameter :some_param, format: ~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: %{min: 5, 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, required: true],
  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{}

func

Checks whether an item is valid over custom validation function. If this function returns false, validation will fail with default message "isn't valid".

parameter :some_param, func: &__MODULE__.your_validation/2

def your_validation(_params, param), do: !is_nil(param)

A custom validation function can also return a user-specified message which will be displayed in map of validation errors.

def your_validation(_params, param) do
  if param > 99 do
    true
  else
    {:error, "Custom error message"}
  end
end

Therefore, validation will fail, if the function returns either false or {:error, your_error_msg} tuple.

func/2 receives two arguments: the first is a contract of an operation (parameters with their values), the second - the actual parameter value to check. So, now you can validate a parameter depending on other parameters values.

parameter :a, type: :integer
parameter :b, func: &__MODULE__.your_validation/2

def your_validation(params, b), do: params[:a] > 0 && !is_nil(b)

it’s possible to combine :func check with others (though not preferable), just make sure this check is the last check in the list

Defined params

If for some reason you have to deal only with parameters that were defined in the contract, or you need to get a map of contract parameters with their values, you can get it with defined_params/1 function.

# ...
parameter :a
parameter :b, default: 2

def process(params) do
  params |> defined_params
end
# ...

SomeOperation.run(a: 1, c: 3) # {:ok, %{a: 1, b: 2}}

Interrupt

In some cases you might want to make an ‘early return’ from process/1 function. For this purpose you can call interrupt/1 function within process/1 and pass an interruption reason to it. An operation will be interrupted and return {:interrupt, your_reason}

# ...
def process(_params) do
  interrupt(%{fail: "oops"})
  :ok # will not return it
end
# ...

SomeOperation.run(a: 1) # {:interrupt, %{fail: "oops"}}

Coercion

It is possible to coerce a parameter before the contract validation, all validation checks will be invoked on coerced parameter value. Since coercion changes a parameter before any validation has been invoked, default values are resolved (with :default option) before the coercion. The flow looks like: Resolve param default value -> Coerce -> Validate coerced

parameter :some_param, default: 1, numericality: %{greater_than: 0}, coerce_with: &__MODULE__.coerce/1

def coerce(x), do: x * 2

Policy check

It is possible to define a policy that will be used for authorizing the possibility of a user to invoke an operation. So far, there is simple policy implementation and usage:

  • first of all, define a policy with Exop.Policy macro

    defmodule MyPolicy do use Exop.Policy

    def read(_user, _opts), do: true

    def write(_user, _opts), do: false end

In this policy two actions (checks) defined (read & write). Every action expects a user (an action subject/caller) and options (Keyword). It’s up to you how to handle this arguments and turn it into a check.

Bear in mind: only true return-value treated as true, everything else returned form an action treated as false

  • next step - link an operation and a policy

    defmodule ReadOperation do use Exop.Operation

    policy MyPolicy, :read

    parameter :user, required: true, struct: %User{}

    def process(_params) do

    # make some reading...

    end end

  • finally - call authorize/2 within process/1

    defmodule ReadOperation do use Exop.Operation

    policy MyPolicy, :read

    parameter :user, required: true, struct: %User{}

    def process(params) do

    authorize(params[:user])
    
    # make some reading...

    end end

Please, note: if authorization fails, any code after (below) auth check will be postponed (an error {:error, {:auth, _reason}} will be returned immediately)

Operation invocation

As said earlier, operations in most cases called by run/1 function. This function receives parameters collection. It’s not required to pass to run/1 function parameters only described in the operation’s contract, but only described parameters will be validated.

run/1 function validate received parameters over the contract and if all parameters passed the validation, the run/1 function calls the code defined in process/1 function.

iex> SomeOperation.run(param1: 1, param2: "2")
_some_result_

If at least one of the given parameters didn’t pass the validation process/1 function’s code will not be invoked and corresponding warning in the application’s log will appear.

There is “bang” version of run/1 exists. Function run!/1 does the same things that its sibling does, the only difference is a result of invocation, it might be:

  • if a contract validation passed - the actual result of an operation (result of a code, described in process/1)
  • if a contract validation failed - an error Exop.Validation.ValidationError raising
  • in case of manual interruption - {:interrupt, _reason}

You always can bypass the validation simply by calling process/1 function itself, if needed.

Operation results

If received parameters passed a contract validation, a code defined in process/1 will be invoked. Or you will receive @type validation_error :: {:error, :validation_failed, map()} as a result otherwise. map() as errors reasons might look like this:

%{param1: ["has wrong type"], param2: ["is required", "must be equal to 3"]}

An operation can return one of results listed below (depends on passed in params and operation definition):

  • an operation was completed successfully:
  • {:error, _your_error_reason_} (if {:error, _your_error_reason_} tuple was returned by process/1 function)
  • {:ok, any()} (otherwise)
  • a contract validation failed: {:error, {:validation, map()}}
  • if interrupt/1 was invoked: {:interrupt, any()}
  • policy check failed:
  • {:error, {:auth, :undefined_user}}
  • {:error, {:auth, :undefined_policy}}
  • {:error, {:auth, :undefined_action}}
  • {:error, {:auth, atom()}}

For the “bang” version of run/1 see results description above.