SteelWheel

Maintainability Test Coverage Gem Version

The library is a tool for building highly structured service objects.

Concepts

Stages

We may consider any controller action as a sequence of following stages:

  1. Input validations and preparations
  2. Describe the structure of parameters
  3. Validate values, provide defaults
  4. Querying data and preparing context
  5. Records lookups by IDs in parameters
  6. Validate permissions to perform an action
  7. Validate conditions (business logic requirements)
  8. Inject Dependencies
  9. Set up current user
  10. Performing Action (skipped on GET requests)
  11. Updade database state
  12. Enqueue jobs
  13. Handle exceptions
  14. Validate intermediate states
  15. Exposing Results/Errors
  16. Presenters
  17. Contextual information useful for the users

Implementation of stages

As you can see each step has specific tasks and can be implemented as a separate object.

SteelWheel::Params (gem https://github.com/andriy-baran/easy_params)

  • provides DSL for params structure definition
  • provides type coercion and default values for individual attributes
  • has ActionModel::Validation included
  • implements http_status method that returs HTTP error code

SteelWheel::Query

  • has Memery module included
  • has ActionModel::Validation included
  • implements http_status method that returs HTTP error code

SteelWheel::Command

  • has ActionModel::Validation included
  • implements http_status method that returs HTTP error code
  • implements call method that should do the stuff

SteelWheel::Response

  • has ActionModel::Validation included
  • implements status method that returs HTTP error code
  • implements success? method that checks if there are any errors

Process

Let's image the process that connects stages described above

  • Get an input and initialize object for params, trigger callbacks
  • Initialize object for preparing context and give it an access to previous object, trigger callbacks
  • Initialize object for performing action and give it an access to previous object, trigger callbacks
  • Initialize resulting object and give it an access to previous object,
  • Run validations, collect errros, trigger callbacks
  • If everything is ok run action and handle errors that appear during execution time.
  • If we have an error on any stage we stop validating following objects.

Callbacks

We have two types of callbacks explicit and implicit

Implicit callbacks

We define them via handler instance methods

def on_params_created(params)
  # NOOP
end

def on_query_created(query)
  # NOOP
end

def on_command_created(command)
  # NOOP
end

def on_response_created(command)
  # NOOP
end

# After validation callbacks

def on_failure(flow)
  # NOOP
end

def on_success(flow)
  # NOOP
end

Explicit callbacks

We define them during instantiation of hanler by providing a block parameter

handler = handler_class.new do |c|
            c.params { |o| puts o }
            c.query { |o| puts o }
            c.command { |o| puts o }
            c.response { |o| puts o }
          end
result =  handler.handle(input: { id: 1 })

In addition we can manipulate with objects directly via callback of handle mathod

result  = handler_class.handle(input: { id: 1 }) do |c|
            c.params.id = 12
            c.query.user = current_user
            c.command.request_headers = request.headers
            c.response.prepare_presenter
          end

Installation

Add this line to your application's Gemfile:

gem 'steel_wheel'

And then execute:

$ bundle

Or install it yourself as:

$ gem install steel_wheel

Usage

Add base handler

bin/rails g steel_wheel:application_handler

Add specific handler

bin/rails g steel_wheel:handler products/create

This will generate app/handlers/products/create_handler.rb. And we can customize it

class Products::CreateHandler < ApplicationHandler
  define do
    params do
      attribute :title, string
      attribute :weight, string
      attribute :price, string

      validates :title, :weight, :price, presence: true
      validates :weight, allow_blank: true, format: { with: /\A[0-9]+\s[g|kg]\z/ }
    end

    query do
      validate :product, :variant

      memoize def new_product
        Product.new(title: title)
      end

      memoize def new_variant
        new_product.build_variant(weight: weight, price: price)
      end

      private

      def product
        errors.add(:base, :unprocessable_entity, new_product.errors.full_messages.join("\n")) if new_product.invalid?
      end

      def variant
        errors.add(:base, :unprocessable_entity, new_variant.errors.full_messages.join("\n")) if new_variant.invalid?
      end
    end

    command do
      def add_to_stock!
        PointOfSale.find_each do |pos|
          PosProductStock.create!(pos_id: pos.id, product_id: new_product.id, on_hand: 0.0)
        end
      end

      def call(response)
        ::ApplicationRecord.transaction do
          new_product.save!
          new_variant.save!
          add_to_stock!
        rescue => e
          response.errors.add(:unprocessable_entity, e.message)
          raise ActiveRecord::Rollback
        end
      end
    end
  end

  def on_success(flow)
    flow.call
  end
end

Looks too long. Lets move code into separate files.

bin/rails g steel_wheel:params products/create

Add relative code

# Base class also can be refered via
# ApplicationHandler.main_builder.abstract_factory.params_factory.base_class
class Products::CreateHandler
  class Params < SteelWheel::Params
    attribute :title, string
    attribute :weight, string
    attribute :price, string

    validates :title, :weight, :price, presence: true
    validates :weight, allow_blank: true, format: { with: /\A[0-9]+\s[g|kg]\z/ }
  end
end

Than do the same for query

bin/rails g steel_wheel:query products/create

Add code...

# Base class also can be refered via
# ApplicationHandler.main_builder.abstract_factory.query_factory.base_class
class Products::CreateHandler
  class Query < SteelWheel::Query
    validate :product, :variant

    memoize def new_product
      Product.new(title: title)
    end

    memoize def new_variant
      new_product.build_variant(weight: weight, price: price)
    end

    private

    def product
      errors.add(:unprocessable_entity, new_product.errors.full_messages.join("\n")) if new_product.invalid?
    end

    def variant
      errors.add(:unprocessable_entity, new_variant.errors.full_messages.join("\n")) if new_variant.invalid?
    end
  end
end

And finally command

bin/rails g steel_wheel:command products/create

Move code

class Products::CreateHandler
  class Command < SteelWheel::Command
    def add_to_stock!
      ::PointOfSale.find_each do |pos|
        ::PosProductStock.create!(pos_id: pos.id, product_id: new_product.id, on_hand: 0.0)
      end
    end

    def call(response)
      ::ApplicationRecord.transaction do
        new_product.save!
        new_variant.save!
        add_to_stock!
      rescue => e
        response.errors.add(:unprocessable_entity, e.message)
        raise ActiveRecord::Rollback
      end
    end
  end
end

Than we can update handler

# app/handlers/manage/products/create_handler.rb
class Manage::Products::CreateHandler < ApplicationHandler
  define do
    params Params
    query Query
    command Command
  end

  def on_success(flow)
    flow.call(flow)
  end
end

HTTP status codes and errors handling

It's important to provide a correct HTTP status when we faced some problem(s) during request handling. The library encourages developers to add the status codes when they add errors.

errors.add(:unprocessable_entity, 'error')

As you know full_messages will produce ['Unprocessable Entity error'] to prevent this and get only error SteelWheel::Response has special method that makes some error keys to behave like :base

# Default setup
generic_validation_keys(:not_found, :forbidden, :unprocessable_entity, :bad_request, :unauthorized)
# To override it in your app
class SomeHandler
  define do
    response do
      generic_validation_keys(:not_found, :forbidden, :unprocessable_entity, :bad_request, :unauthorized, :payment_required)
    end
  end
end

In Rails 6.1 ActiveModel::Error was introdused and previous setup is not needed, second argument is used instead

errors.add(:base, :unprocessable_entity, 'error')

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/andriy-baran/steel_wheel. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the SteelWheel project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.