Commandex (Commandex v0.5.2)
View SourceDefines 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
endThe 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
endParameter-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
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
@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 byCommandex.data/1.errors- Errors generated during the pipeline withCommandex.put_error/3halted- Whether or not the pipeline was halted.params- Parameters given to the command, defined byCommandex.param/1.pipelines- A list of pipeline functions to execute, defined byCommandex.pipeline/1.success- Whether or not the command was successful. This is only set totrueif the command was not halted after running all of the pipelines.
@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
Defines a command struct with params, data, and pipelines.
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
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()
endHalting 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
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
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_userWhich 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.
Sets a data field with given value.
Define a data field first:
data :password_hashSet 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
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