README.md

# Exaop

A minimal elixir library for aspect-oriented programming.

## Installation

Add `exaop` to your list of dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:exaop, "~> 0.1"}
  ]
end
```

## Usage

Unlike common AOP patterns, Exaop does not introduce any additional behavior to
existing functions, as it may bring complexity and make the control flow
obscured. Elixir developers prefer explicit over implicit, thus invoking the
cross-cutting behavior by simply calling the plain old function generated by
pointcut definitions is better than using some magic like module attributes and
macros to decorate and weave a function.

### Hello World

Use Exaop in a module, then define some pointcuts to separate the cross-cutting
logic:

```elixir
defmodule Foo do
  use Exaop

  check :validity
  set :compute
end
```

When you compile the file, the following warnings would occur:

```
warning: function check_validity/3 required by behaviour Foo.ExaopBehaviour is not implemented (in module Foo)
  foo.exs:1: Foo (module)

warning: function set_compute/3 required by behaviour Foo.ExaopBehaviour is not implemented (in module Foo)
  foo.exs:1: Foo (module)
```

It reminds you to implement the corresponding callbacks required by your pointcut definitions:

```elixir
defmodule Foo do
  use Exaop

  check :validity
  set :compute

  @impl true
  def check_validity(%{b: b} = _params, _args, _acc) do
    if b == 0 do
      {:error, :divide_by_zero}
    else
      :ok
    end
  end

  @impl true
  def set_compute(%{a: a, b: b} = _params, _args, acc) do
    Map.put(acc, :result, a / b)
  end
end
```

A function `__inject__/2` is generated in the above module `Foo`. When it is
called, the callbacks are triggered in the order defined by your pointcut
definitions.

Throughout the execution of the pointcut callbacks, an accumulator is passed
and updated after running each callback. The execution process may be halted
by a return value of a callback.

If the execution is not halted by any callback, the final accumulator value is
returned by the `__inject__/2` function. Otherwise, the return value of the
callback that terminates the entire execution process is returned.

In the above example, the value of the accumulator is returned if the
`check_validity` is passed:

```elixir
iex> params = %{a: 1, b: 2}
iex> initial_acc = %{}
iex> Foo.__inject__(params, initial_acc)
%{result: 0.5}
```

The halted error is returned if the execution is aborted:

```elixir
iex> params = %{a: 1, b: 0}
iex> initial_acc = %{}
iex> Foo.__inject__(params, initial_acc)
{:error, :divide_by_zero}
```

### Pointcut definitions

```elixir
check :validity
set :compute
```

We've already seen the pointcut definitions in the example before.
`check_validity/3` and `set_compute/3` are the pointcut callback functions
required by these definitions.

Additional arguments can be set:

```elixir
check :validity, some_option: true
set :compute, {:a, :b}
```

### Pointcut callbacks

#### Naming and arguments

All types of pointcut callbacks have the same function signature. Each callback
function following the naming convention in the example, using an underscore
to connect the pointcut type and the following atom as the callback function
name.

Each callback has three arguments and each argument can be of any Elixir term.

The first argument of the callback function is passed from the first argument
of the caller `__inject__/2`. The argument remains unchanged in each callback
during the execution process.

The second argument of the callback function is passed from its pointcut
definition, for example, `set :compute, :my_arg` passes `:my_arg` as the
second argument of its callback function `set_compute/3`.

The third argument is the accumulator. It is initialized as the second
argument of the caller `__inject__/2`. The value of accumulator is updated or
remains the same after each callback execution, depending on the types and
the return values of the callback functions.

#### Types and behaviours

Each kind of pointcut has different impacts on the execution process and the
accumulator.

- `check`
  - does not change the value of the accumulator.
  - the execution of the generated function is halted if its callback
    return value matches the pattern `{:error, _}`.
  - the execution continues if its callback returns `:ok`.
- `set`
  - does not halt the execution process.
  - sets the accumulator to its callback return value.
- `preprocess`
  - allows to change the value of the accumulator or halt the execution process.
  - the execution of the generated function is halted if its callback return
    value matches the pattern `{:error, _}`.
  - the accumulator is updated to the wrapped `acc` if its callback return
    value matches the pattern `{:ok, acc}`.

View documentation of these macros for details.

### A more in-depth example

Exaop is ready for production and makes complex application workflows simple
and self-documenting. In practice, we combine it with some custom simple macros
as a method to separate cross-cutting concerns and decouple business logic.
Note that we do not recommend overusing it, it is only needed when the workflow
gets complicated, and the pointcuts should be strictly restricted to the domain
of cross-cutting logic, not the business logic body itself.

Here's a more complex example, a wallet balance transfer. The configuration
loading, context setting and transfer validations are separated, but the main
transfer logic remains untouched. The example also introduces an external
callback, which is defined in a module other than its pointcut definition.

```elixir
defmodule Wallet do
  @moduledoc false

  use Exaop
  alias Wallet.AML
  require Logger

  ## Definitions for cross-cutting concerns

  set :config, [:max_allowed_amount, :fee_rate]
  set :accounts

  check :amount, guard: :positive
  check :amount, guard: {:lt_or_eq, :max_allowed_amount}
  check :recipient, :not_equal_to_sender
  check AML

  set :fee
  check :balance

  @doc """
  A function injected by explicitly calling __inject__/2 generated by Exaop.
  """
  def transfer(%{from: _, to: _, amount: _} = info) do
    info
    |> __inject__(%{})
    |> handle_inject(info)
  end

  defp handle_inject({:error, _} = error, info) do
    Logger.error("transfer failed", error: error, info: info)
  end

  defp handle_inject(_acc, info) do
    # Put the actual transfer logic here:
    # Wallet.transfer!(acc, info)
    Logger.info("transfer validated and completed", info: info)
  end

  ## Setters required by the above concern definitions.

  @impl true
  def set_accounts(%{from: from, to: to}, _args, acc) do
    balances = %{"Alice" => 100, "Bob" => 30}

    acc
    |> Map.put(:sender_balance, balances[from])
    |> Map.put(:recipient_balance, balances[to])
  end

  @impl true
  def set_config(_params, keys, acc) do
    keys
    |> Enum.map(&{&1, Application.get_env(:my_app, &1, default_config(&1))})
    |> Enum.into(acc)
  end

  defp default_config(key) do
    Map.get(%{fee_rate: 0.01, max_allowed_amount: 1_000}, key)
  end

  @impl true
  def set_fee(%{amount: amount}, _args, %{fee_rate: fee_rate} = acc) do
    Map.put(acc, :fee, amount * fee_rate)
  end

  ## Checkers required by the above concern definitions.

  @impl true
  def check_amount(%{amount: amount}, args, acc) do
    args
    |> Keyword.fetch!(:guard)
    |> do_check_amount(amount, acc)
  end

  defp do_check_amount(:positive, amount, _acc) do
    if amount > 0 do
      :ok
    else
      {:error, :amount_not_positive}
    end
  end

  defp do_check_amount({:lt_or_eq, key}, amount, acc)
       when is_atom(key) do
    max = Map.fetch!(acc, key)

    if max && amount <= max do
      :ok
    else
      {:error, :amount_exceeded}
    end
  end

  @impl true
  def check_recipient(%{from: from, to: to}, :not_equal_to_sender, _acc) do
    if from == to do
      {:error, :invalid_recipient}
    else
      :ok
    end
  end

  @impl true
  def check_balance(%{amount: amount}, _args, %{fee: fee, sender_balance: balance}) do
    if balance >= amount + fee do
      :ok
    else
      {:error, :insufficient_balance}
    end
  end
end

defmodule Wallet.AML do
  @moduledoc """
  A module defining external Exaop callbacks.
  """

  @behaviour Exaop.Checker

  @aml_blacklist ~w(Trump)

  @impl true
  def check(%{from: from, to: to}, _args, _acc) do
    cond do
      from in @aml_blacklist ->
        {:error, {:aml_check_failed, from}}

      to in @aml_blacklist ->
        {:error, {:aml_check_failed, to}}

      true ->
        :ok
    end
  end
end
```

## License

[The MIT License](https://github.com/nobrick/exaop/blob/master/LICENSE)