0.0
Low commit activity in last 3 years
No release in over a year
A useful utility for transforming state that provides step sequencing, middleware, and validation.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies
 Project Readme

Build Status

Smash the State

A useful utility for transforming state that provides step sequencing, middleware, and validation.

Inspired by Arpeggiate, an Elixir operation library by @onyxrev.

Example

class CreateUserOperation < SmashTheState::Operation
  # the schema defines how state is initialized. state begins life as a light-weight
  # struct with the keys defined below and values type-cast using
  # https://github.com/Azdaroth/active_model_attributes
  schema do
    attribute :email, :string
    attribute :name,  :string
    attribute :age,   :integer
  end

  # if a validate block is provided, a validation step will be inserted at its position in
  # the class relative to the other steps. failing validation will cause the operation to
  # exit, returning the current state
  validate do
    validates_presence_of :name, :email
  end

  # steps are executed in the order in which they are defined when the operation is
  # run. each step is expected to return the new state of the operation, which is handed
  # to the subsequent step
  step :normalize_data do |state|
    state.tap do |state|
      state.email = state.email.downcase
    end
  end

  custom_validation do |state|
    state.errors.add(:email, "no funny bizness") if state.email.end_with? ".biz"
  end

  # this step receives the normalized state from :normalize_data and creates a user,
  # which, because it is returned from the block, becomes the new operation state
  step :create_user do |state|
    User.create(state.as_json)
  end

  # this step receives the created user as its state and sends the user an email
  step :send_email do |user|
    email_job = Email.send(user.email, "hi there!")
    error!(user) if email_job.failed?
    user
  end

  # this error handler is attached to the :send_email step. if `error!`` is called
  # in a step, the error handler attached to the step is executed with the state as its
  # argument. an optional, second error argument may be passed in when error! is called.
  # if an error handler is run, operation execution halts, subsequent steps are not
  # executed, and the return value of the error handler block is returned to the caller
  error :send_email do |user|
    Logger.error "sending an email to #{user.email} failed"
    # do some error handling here
    user
  end
end

Advanced stuff

Original state

If the state changes from step-to-step, don't I lose track of my original state? What if I change my state to something else but I need to access the original operation input data?

Each step not only receives the state of the previous step, but also a copy of the original, unmolested state object that was formed from the union of the input params and the schema. To access the original state data, access it as the second argument in your step block.

class CreateAnalyticsOperation < SmashTheState::Operation
  schema do
    attribute :action, :string
    attribute :request_ip, :string
    attribute :private_note, :string
  end

  step :create_analytics do |state|
    Analytics.create(action: state.action, request_ip: state.request_ip)
  end

  step :send_private_note do |state, original_state|
    # so state is an Analytics instance here. what if we want the original
    # state? 'state' is no longer our schema-generated state object, but that
    # data is available as 'original_state'. we can send our private note while
    # still forwarding along the analytics state to the next step
    PrivateNote.create(body: original_state.private_note)

    # pass the Analytics state down to the next step
    state
  end
end

Dynamic Schemas (built at runtime)

Maybe your operation needs a more flexible schema than a static state class can provide. Maybe you need to base your schema on some other data model that isn't available at the time the class is evaluated. If you need your state class to be evaluated at runtime, you can specify dynamic_schema with a block. The raw params hash will be passed in as the initial state. From there you can create whatever state class you desire at runtime. Be careful with this because this can quickly get out of hand. If you find yourself using dynamic schemas frequently, you may actually want distinct operations with static schemas.

class BillingStuff < SmashTheState::Operation
  dynamic_schema do |params|
    # let's say we want to base our schema on an external service.
    # we can call the service and build our schema off of the keys it returns
    # let's say it's something like {name: "...", id: "...", is_paid: "..."}
    BillingService.get_billing_things.keys do |key|
      attribute key, :string
    end
  end

  step :do_more_things do |state|
    # ... we receive a state with name, id, and is_paid attributes ...
  end
end

Middleware

You can define middleware classes to which the operation can delegate steps. The middleware class names can be arbitrarily composed by information pulled from the state.

Let's say you have two database types: WhiskeyDB and AbsintheDB, each of which have different behaviors in the :create_environment step. You can delegate those behaviors using middleware.

class WhiskeyDBCreateMiddleware
  def self.create_environment(state)
    # do whiskey things
  end
end
class AbsintheDBCreateMiddleware
  def self.create_environment(state)
    # do absinthe things
  end
end
class CreateDatabase < SmashTheState::Operation
  schema do
    attribute :name, :string
  end

  middleware_class do |state|
    "#{state.name}CreateMiddleware"
  end

  middleware_step :create_environment

  step :more_things do |state_from_middleware_step|
    # ... and so on
  end
end

Inheritance-like Behavior

Smash is a library that generally follows the functional approach and you should focus on using that approach when using it. However, there are times when sprinkling a dash of inheritance into the mix can make your life easier.

When using the inheritance pattern, all validation blocks are evaluated together and are run together as one big validation step. The parent class' schema is copied to the child class. All steps are copied to the child class, but individual steps may be overridden using override_step. Overridden steps are run in the same step index in which they were originally defined in the parent sequence.

class CreateOperation < SmashTheState::Operation
  schema do
    attribute :version, :string
    attribute :name,    :string
  end

  validate do
    validates_presence_of :name
  end

  step :download_image do |state|
    GenericImage.download(name)
  end

  step :create do |state|
    Deployment.create(state.to_hash)
  end

  step :set_up_billing do |deployment|
    Billing.charge_for_deployment(deployment)

    deployment
  end
end

class RestoreOperation < CreateOperation
  # we get the parent class' schema for free when we inherit. if you want to extend the
  # schema in child classes, I recommend breaking out your schema into modules

  # the validation of CreateOperation will be evaluated at the same time as this following
  # block. in other words, not only will :name have to be present, but for restoration,
  # the name has to be the name of a known source image
  validate do
    validate :source_image_exists

    def source_image_exists
      unless SourceImage.exist?(name)
        add_error(:name, "is not a restorable image")
      end
    end
  end

  # steps from the parent class are copied over in order. you can override specific steps
  # in the child class

  # ...download_image runs

  override_step :create do |state|
    # we're going to diverge from create by "restoring" an image rather than "creating" an image
    Deployment.restore(state.to_hash)
  end

  # ... set_up_billing runs
end

Representation

Let's say you want to represent the state of the operation, wrapped in a class that defines some as_* and to_* methods. You can do this with a represent step.

class DatabaseRepresenter
  def initialize(state)
    @state = state
  end

  def as_json
    {name: @state.name, foo: "bar"} # and so on
  end

  def as_xml
    XML.dump(@state)
  end
end
class CreateDatabaseOperation < SmashTheState::Operation
  schema do
    attribute :name, :string
  end

  # ... steps

  represent DatabaseRepresenter
end
> CreateDatabaseOperation.call(name: "AbsintheDB").as_json
=> {name: "AbsintheDB", foo: "bar"}

Policy

Pundit style policies are supported via a policy method. Failing policies raise a SmashTheState::Operation::NotAuthorized exception and run at the position in the sequence in which they are defined. Pass current_user into your operation alongside your params.

class DatabasePolicy
  attr_reader :current_user, :database

  def initialize(current_user, database)
    @current_user = current_user
    @database = database
  end

  def allowed?
    @current_user.age > 21
  end
end

class CreateDatabaseOperation < SmashTheState::Operation
  schema do
    attribute :type, :string
    # ...
  end

  step :get_database do |state|
    Database.find_by_type(type: state.type)
  end

  # state is now a database and is passed into DatabasePolicy as "database",
  # while current_user is passed in as "current_user"
  policy DatabasePolicy, :allowed?
end
CreateDatabaseOperation.call(
  current_user: some_user,
  type: "WhiskeyDB"
  # ... and so on
)

The NotAuthorized exception will also provide the failing policy instance via a policy_instance method so that you can reason about what exactly went wrong.

begin
  CreateDatabaseOperation.call(
    current_user: some_user,
    type: "WhiskeyDB"
    # ... and so on
  )
rescue SmashTheState::Operation::NotAuthorized => e
  e.policy_instance.current_user == some_user # true
  e.policy_instance.database.is_a? Database   # also true
end

Continuation

Smash the State operations can be chained. To simplify this, you may use the continues_from helper, which frontloads an existing operation in front of the operation being defined. It feeds the state result of the first operation into the first step of the second.

class SecondOperation < SmashTheState::Operation
  continues_from FirstOperation

  step :another_step do |state|
    # ... continue to smash the state
  end
end

Nested State Schemas

While it's best to keep things simple, sometimes things are complex. As such, you may nest state schemas like so:

class Database::Create < Compose::Operation
  schema do
    attribute :type, :string

    schema :host do
      attribute :name, :string

      schema :resources do
        attribute :ram_units, :integer
        attribute :cpu_units, :integer
      end

      # nest as many layers deep as you like
    end
  end

  step :allocate_cpu do |state|
    # access the nested state for whatever you need...
    host = Host.new(state.host.name)
    host.cpus = CPU.new(state.host.resources.cpu_units)

    host
  end
end

Calling as_json on a state will recurse through the nesting to produce a nested hash.

Type Definitions

Smash the State supports re-usable type definitions that can help to DRY up your operation states. In the above nested schema example, we can DRY up the host schema by turning it into a definition:

class HostDefinition < SmashTheState::Operation::Definition
  definition "Host"

  schema do
    attribute :name, :string

    schema :resources do
      attribute :ram_units, :integer
      attribute :cpu_units, :integer
    end
  end
end

Which can then be re-used in other schemas:

class Database::Create < Compose::Operation
  schema do
    attribute :type, :string
    schema :host, ref: HostDefinition
  end
end

Dry Runs

Operations support "dry run" execution. That is to say, dry runs should not produce side-effects but produce output that is similar to the output for a regular run. Because dry runs should not produce side-effects, steps that persist changes cannot be executed safely for dry runs.

To get around this, a specific sequence can be defined for use when running dry. The dry run sequence can refer to steps in the normal sequence by name. Alternatively, if the step produces side-effects, an alternate version of the step can be provided that superficially behaves similarly. Steps also may be skipped entirely simply by omission.

class CreateUserOperation < SmashTheState::Operation
  schema do
    attribute :email, :string
    attribute :name,  :string
    attribute :age,   :integer
    attribute :id,    :integer
  end

  step :downcase_email do |state|
    state.name ||= "Unnamed"
    state
  end

  validate do
    validates_presence_of :email
  end

  # because this step creates a resource, it produces side-effects and is not
  # safe for a dry run
  step :create do |state|
    result = SomeServer.post("/users")
    state.id = result.id
    state
  end

  step :add_phd do |state|
    state.name = state.name + " Ph.D"
    state
  end

  dry_run_sequence do
    step :downcase_email

    # this alternative implementation will instead be executed when run dry,
    # allowing the rest of the operation to function as if the :create step had run
    step :create do |state|
      state.id = (Random.rand * 1000).ceil
      state
    end

    step :add_phd
  end
end
> result = CreateUserOperation.dry_run(email: "jack@sparrow.com", age: 31)
> result.errors.empty?
=> true
> result.name
=> "Unnamed Ph.D"
> result.id
=> 145
> result = CreateUserOperation.dry_run(name: "Sam", age: 31)
> result.errors.empty?
=> false
> result.errors["email"]
=> ["can't be blank"]
> result.id
=> nil

Specs

Some helpful RSpec helpers are provided.

# spec_helper.rb
require 'smash_the_state/matchers'
# continues_from
expect(ContinuingOperation).to continue_from PreludeOperation

# represent
expect(RepresentingOperation).to represent_with SomeRepresenter
expect(RepresentingOperation).to represent_collection_with SomeRepresenter