SteelWheel
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:
- Input validations and preparations
- Describe the structure of parameters
- Validate values, provide defaults
- Querying data and preparing context
- Records lookups by IDs in parameters
- Validate permissions to perform an action
- Validate conditions (business logic requirements)
- Inject Dependencies
- Set up current user
- Performing Action (skipped on GET requests)
- Updade database state
- Enqueue jobs
- Handle exceptions
- Validate intermediate states
- Exposing Results/Errors
- Presenters
- 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.