State machine thin layer for structs (+ GUI for Phoenix apps)
Clone or download
Latest commit d25711c Sep 20, 2018

README.md

Machinery

Build Status Ebert

Machinery

Machinery is a thin State Machine library for Elixir that integrates with Phoenix out of the box.

It's just a small layer that provides a DSL for declaring states and having guard clauses + callbacks for structs in general. It also aims to have (when implemented with Phoenix) an optional build-in GUI that will represent each resource's state. You can also use it with pure elixir. (without the dashboard)

Do you always need a state machine to be a process?

Yes? This is not your library. You might be better off with another library or even gen_statem or gen_fsm from Erlang/OTP.

Don't forget to check the Machinery Docs

Installing

The package can be installed by adding machinery to your list of dependencies in mix.exs:

def deps do
  [
    {:machinery, "~> 0.16.0"}
  ]
end

Create a field state for the module you want to have a state machine, make sure you have declared it as part of you defstruct, or if it is a Phoenix model make sure you add it to the schema, as a string, and to the changeset/2:

defmodule YourProject.User do
  schema "users" do
    # ...
    field :state, :string
    # ...
  end

  def changeset(%User{} = user, attrs) do
    #...
    |> cast(attrs, [:state])
    #...
  end
end

Declaring States

Declare the states as an argument when importing Machinery on the module that will control your states transitions.

It's strongly recommended that you create a new module for your State Machine logic. So let's say you want to add it to your User model, you should create a UserStateMachine module to hold your State Machine logic.

Machinery expects a Keyword as argument with two keys states and transitions.

  • states: A List of Strings representing each state.
  • transitions: A Map for each state and it allowed next state(s).

Example

defmodule YourProject.UserStateMachine do
  use Machinery,
    # The first state declared will be considered
    # the initial state
    states: ["created", "partial", "complete", "canceled"],
    transitions: %{
      "created" =>  ["partial", "complete"],
      "partial" => "completed",
      "*" => "canceled
    }
end

As you might notice you can use wildcards "*" to declare a transition that can happen from any state to a specific one.

Changing States

To transit a struct into another state, you just need to call Machinery.transition_to/3.

Machinery.transition_to/3

It takes three arguments:

  • struct: The struct you want to transit to another state.
  • state_machine_module: The module that holds the state machine logic, where Machinery as imported.
  • next_event: string of the next state you want the struct to transition to.

Guard functions, before and after callbacks will be checked automatically.

Machinery.transition_to(your_struct, YourStateMachine, "next_state")
# {:ok, updated_struct}

Example:

user = Accounts.get_user!(1)
Machinery.transition_to(user, UserStateMachine, "complete")

Persist State

To persist the struct and the state transition automatically, instead of having Machinery changing the struct itself, you can declare a persist/2 function on the state machine module.

It will receive the unchanged struct as the first argument and a string of the next state as the second one, after every state transition. That will be called between the before and after transition callbacks.

persist/2 should always return the updated struct.

Example:

defmodule YourProject.UserStateMachine do
  alias YourProject.Accounts

  use Machinery,
    states: ["created", "complete"],
    transitions: %{"created" => "complete"}

  def persist(struct, next_state) do
    # Updating a user on the database with the new state.
    {:ok, user} = Accounts.update_user(struct, %{state: next_state})
    user
  end
end

Logging Transitions

To log/persist the transitions itself Machinery provides a callback log_transitions/2 that will be called on every transition.

It will receive the unchanged struct as the first argument and a string of the next state as the second one, after every state transition. This function will be called between the before and after transition callbacks and after the persist function.

log_transition/2 should always return the updated struct.

Example:

defmodule YourProject.UserStateMachine do
  alias YourProject.Accounts

  use Machinery,
    states: ["created", "complete"],
    transitions: %{"created" => "complete"}

  def log_transition(struct, _next_state) do
    # Log transition here, save on the DB or whatever.
    # ...
    # Return the struct.
    struct
  end
end

Enable Dashboard with Phoenix

In case you're using Phoenix and want visual dashboard representing your state machine (its states and each resource), you can easily have it. It will also enable you to change states by tragging item from one state to another. This is how it looks like:

Mahcinery Dashboard Example

To enbale the Dashboard you will need to add the Machinery Plug to you application Endpoint module.

defmodule YourApp.Endpoint do
  # ...

  # It accepts the path you want to mount the dashboard at as an argument,
  # it will mount it under `/machinery` as default.
  plug Machinery.Plug
  # plug Machinery.Plug, '/my-custom-route'

  # ...
end

You will also need to add some config to your config.exs:

  • interface: a flag to enable the dashbord.
  • repo: your app's repo module.
  • model: the model that will hold the state.
  • module: the machinery module where you have the declared states.
  • (Optional) dashboard_states: A list of the states you want on the dashboard.
  • (Optional) authorization: Keyword list.
    • username: username for basic auth.
    • password: password for basic auth.
    • realm: real for the basic auth.
config :machinery,
  interface: true,
  repo: YourApp.Repo,
  model: YourApp.User,
  module: YourApp.UserStateMachine,
  # Optional: dashboard_states: ["created", "partial"],
  # Optional: authorization: [
  #  username: "admin",
  #  password: "simple_password",
  #  realm: "machinery"
  # ]

That's it, now you can start you Phoenix app and navigates to http://localhost:4000/machinery, or whatever custou routes you have mounted the dashboard at.

Guard functions

Create guard conditions by adding signatures of the guard_transition/2 function, it will receive two arguments, the struct and an string of the state it will transit to, use this second argument to pattern matching the desired state you want to guard.

# The second argument is used to pattern match into the state
# and guard the transition to it.
def guard_transition(struct, "guarded_state") do
 # Your guard logic here
end

Guard conditions will allow the transition if it returns anything other than a tuple with {:error, "cause"}:

  • {:error, "cause"}: Transition won't be allowed.
  • _ (anything else): Guard clause will allow the transition.

Example:

defmodule YourProject.UserStateMachine do
  use Machinery,
    states: ["created", "complete"],
    transitions: %{"created" => "complete"}

  # Guard the transition to the "complete" state.
  def guard_transition(struct, "complete") do
    if Map.get(struct, :missing_fields) == true do
      {:error, "There are missing fields"}
    end
  end
end

When trying to transition an struct that is blocked by its guard clause you will have the following return:

blocked_struct = %TestStruct{state: "created", missing_fields: true}
Machinery.transition_to(blocked_struct, TestStateMachineWithGuard, "completed")

# {:error, "There are missing fields"}

Before and After callbacks

You can also use before and after callbacks to handle desired side effects and reactions to a specific state transition.

You can just declare before_transition/2 and after_transition/2, pattern matching the desired state you want to.

Make sure Before and After callbacks should return the struct.

# callbacks should always return the struct.
def before_transition(struct, "state"), do: struct
def after_transition(struct, "state"), do: struct

Example:

defmodule YourProject.UserStateMachine do
  use Machinery,
    states: ["created", "partial", "complete"],
    transitions: %{
      "created" =>  ["partial", "complete"],
      "partial" => "completed"
    }

    def before_transition(struct, "partial") do
      # ... overall desired side effects
      struct
    end

    def after_transition(struct, "completed") do
      # ... overall desired side effects
      struct
    end
end