Commandex (Commandex v0.5.2)

View Source

Defines a command struct.

Commandex is a loose implementation of the command pattern, making it easy to wrap parameters, data, and errors into a well-defined struct.

Example

A fully implemented command module might look like this:

defmodule RegisterUser do
  import Commandex

  command do
    param :email
    param :password

    data :password_hash
    data :user

    pipeline :hash_password
    pipeline :create_user
    pipeline :send_welcome_email
  end

  def hash_password(command, %{password: nil} = _params, _data) do
    command
    |> put_error(:password, :not_given)
    |> halt()
  end

  def hash_password(command, %{password: password} = _params, _data) do
    put_data(command, :password_hash, Base.encode64(password))
  end

  def create_user(command, %{email: email} = _params, %{password_hash: phash} = _data) do
    %User{}
    |> User.changeset(%{email: email, password_hash: phash})
    |> Repo.insert()
    |> case do
      {:ok, user} -> put_data(command, :user, user)
      {:error, changeset} -> command |> put_error(:repo, changeset) |> halt()
    end
  end

  def send_welcome_email(command, _params, %{user: user}) do
    Mailer.send_welcome_email(user)
    command
  end
end

The command/1 macro will define a struct that looks like:

%RegisterUser{
  success: false,
  halted: false,
  errors: %{},
  params: %{email: nil, password: nil},
  data: %{password_hash: nil, user: nil},
  pipelines: [:hash_password, :create_user, :send_welcome_email]
}

As well as two functions:

&RegisterUser.new/1
&RegisterUser.run/1

&new/1 parses parameters into a new struct. These can be either a keyword list or map with atom/string keys.

&run/1 takes a command struct and runs it through the pipeline functions defined in the command. Functions are executed in the order in which they are defined. If a command passes through all pipelines without calling halt/1, :success will be set to true. Otherwise, subsequent pipelines after the halt/1 will be ignored and :success will be set to false.

%{email: "example@example.com", password: "asdf1234"}
|> RegisterUser.new()
|> RegisterUser.run()
|> case do
  %{success: true, data: %{user: user}} ->
    # Success! We've got a user now

  %{success: false, errors: %{password: :not_given}} ->
    # Respond with a 400 or something

  %{success: false, errors: _error} ->
    # I'm a lazy programmer that writes catch-all error handling
end

Parameter-less Commands

If a command does not have any parameters defined, a run/0 will be generated automatically. Useful for diagnostic jobs and internal tasks.

iex> GenerateReport.run()
%GenerateReport{
  pipelines: [:fetch_data, :calculate_results],
  data: %{total_valid: 183220, total_invalid: 781215},
  params: %{},
  halted: false,
  errors: %{},
  success: true
}

Summary

Types

Command struct.

Command pipeline stage.

Functions

Defines a command struct with params, data, and pipelines.

Defines a command data field.

Halts a command pipeline.

Defines a command parameter field.

Defines a command pipeline.

Sets a data field with given value.

Sets error for given key and value.

Types

command()

@type command() :: %{
  __struct__: atom(),
  data: map(),
  errors: map(),
  halted: boolean(),
  params: map(),
  pipelines: [pipeline()],
  success: boolean()
}

Command struct.

Attributes

  • data - Data generated during the pipeline, defined by Commandex.data/1.
  • errors - Errors generated during the pipeline with Commandex.put_error/3
  • halted - Whether or not the pipeline was halted.
  • params - Parameters given to the command, defined by Commandex.param/1.
  • pipelines - A list of pipeline functions to execute, defined by Commandex.pipeline/1.
  • success - Whether or not the command was successful. This is only set to true if the command was not halted after running all of the pipelines.

pipeline()

@type pipeline() ::
  atom()
  | {module(), atom()}
  | {module(), atom(), [any()]}
  | (command :: struct() -> command :: struct())
  | (command :: struct(), params :: map(), data :: map() -> command :: struct())

Command pipeline stage.

A pipeline function can be defined multiple ways:

  • pipeline :do_work - Name of a function inside the command's module, arity three.
  • pipeline {YourModule, :do_work} - Arity three.
  • pipeline {YourModule, :do_work, [:additonal, "args"]} - Arity three plus the number of additional args given.
  • pipeline &YourModule.do_work/1 - Or any anonymous function of arity one.
  • pipeline &YourModule.do_work/3 - Or any anonymous function of arity three.

Functions

command(list)

(macro)
@spec command([{:do, any()}]) :: no_return()

Defines a command struct with params, data, and pipelines.

data(name)

(macro)
@spec data(atom()) :: no_return()

Defines a command data field.

Data field values are created and set as pipelines are run. Set one with put_data/3.

command do
  # ...params

  data :password_hash
  data :user

  # ...pipelines
end

halt(command, opts \\ [])

@spec halt(command(), Keyword.t()) :: command()

Halts a command pipeline.

Any pipelines defined after the halt will be ignored. By default, if a command finishes running through all pipelines, :success will be set to true.

def hash_password(command, %{password: nil} = _params, _data) do
  command
  |> put_error(:password, :not_supplied)
  |> halt()
end

Halting Early Successfully

Pass success: true as an optional argument if you would like to mark the command as successful, even if there are still pipelines left to be run. Useful for returning early from no-op commands.

def needs_update?(command, _params, _data) do
  command
  |> put_error(:update, :no_op)
  |> halt(success: true)
end

param(name, opts \\ [])

(macro)
@spec param(atom(), Keyword.t()) :: no_return()

Defines a command parameter field.

Parameters are supplied at struct creation, before any pipelines are run.

command do
  param :email
  param :password

  # ...data
  # ...pipelines
end

pipeline(name)

(macro)
@spec pipeline(atom()) :: no_return()

Defines a command pipeline.

Pipelines are functions executed against the command, in the order in which they are defined.

For example, two pipelines could be defined:

pipeline :check_valid_email
pipeline :create_user

Which could be mentally interpreted as:

command
|> check_valid_email()
|> create_user()

A pipeline function can be defined multiple ways:

  • pipeline :do_work - Name of a function inside the command's module, arity three.
  • pipeline {YourModule, :do_work} - Arity three.
  • pipeline {YourModule, :do_work, [:additonal, "args"]} - Arity three plus the number of additional args given.
  • pipeline &YourModule.do_work/1 - Or any anonymous function of arity one.
  • pipeline &YourModule.do_work/3 - Or any anonymous function of arity three.

put_data(command, key, val)

@spec put_data(command(), atom(), any()) :: command()

Sets a data field with given value.

Define a data field first:

data :password_hash

Set the password pash in one of your pipeline functions:

def hash_password(command, %{password: password} = _params, _data) do
  # Better than plaintext, I guess
  put_data(command, :password_hash, Base.encode64(password))
end

put_error(command, key, val)

@spec put_error(command(), any(), any()) :: command()

Sets error for given key and value.

:errors is a map. Putting an error on the same key will overwrite the previous value.

def hash_password(command, %{password: nil} = _params, _data) do
  command
  |> put_error(:password, :not_supplied)
  |> halt()
end