README.md
# SteelWheel
[![Maintainability](https://api.codeclimate.com/v1/badges/a197758aa1cfde54f0e1/maintainability)](https://codeclimate.com/github/andriy-baran/steel_wheel/maintainability)
[![Test Coverage](https://api.codeclimate.com/v1/badges/a197758aa1cfde54f0e1/test_coverage)](https://codeclimate.com/github/andriy-baran/steel_wheel/test_coverage)
[![Gem Version](https://badge.fury.io/rb/steel_wheel.svg)](https://badge.fury.io/rb/steel_wheel)
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**
* Describe the structure of parameters
* Validate values, provide defaults
2. **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
3. **Performing Action (skipped on GET requests)**
* Updade database state
* Enqueue jobs
* Handle exceptions
* Validate intermediate states
4. **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
```ruby
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
```ruby
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
```ruby
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:
```ruby
gem 'steel_wheel'
```
And then execute:
$ bundle
Or install it yourself as:
$ gem install steel_wheel
## Usage
Add base handler
```bash
bin/rails g steel_wheel:application_handler
```
Add specific handler
```bash
bin/rails g steel_wheel:handler products/create
```
This will generate `app/handlers/products/create_handler.rb`. And we can customize it
```ruby
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.
```bash
bin/rails g steel_wheel:params products/create
```
Add relative code
```ruby
# 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
```bash
bin/rails g steel_wheel:query products/create
```
Add code...
```ruby
# 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
```bash
bin/rails g steel_wheel:command products/create
```
Move code
```ruby
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
```ruby
# 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.
```ruby
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`
```ruby
# 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
```ruby
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](https://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](http://contributor-covenant.org) code of conduct.
## License
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
## 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](https://github.com/andriy-baran/steel_wheel/blob/master/CODE_OF_CONDUCT.md).