LightOperations
When you want to have slim controllers or some logic with several operations this gem could help you to have nice separated and clean code. CAN HELP YOU! :D
Installation
Add this line to your application's Gemfile:
gem 'light_operations'
And then execute:
$ bundle
Or install it yourself as:
$ gem install light_operations
Important latest version of gem > 1.2.x works only with ruby 2.x
How it works
Basically, this is a Container for business logic.
You can define dependencies during initialization and run with custom parameters.
When you define deferred actions on success
and fail
before operation execution is finished,
after execution one of those actions depend on for execution result will be executed.
Actions could be a block (Proc) or you could delegate execution to method another object,
by binding operation with the specific object with those methods.
You also could use operation as simple execution and check status by success?
or fail?
method
and then by using subject
and errors
method build your own logic to finish your result.
There are many possible use-cases where and how you could use operations.
You can build cascade of operations, use them one after the other,
use them recursively and a lot more.
Examples:
Simple
require 'light_operations'
class CorrectNumber < LightOperations::Core
def execute(params)
params[:number] > 0 || fail!(:wrong_number)
end
end
op = CorrectNumber.new
p op.run(number: 0).success? # return false
p op.run(number: 0).false? # return true
p op.run(number: 1).success? # return true
p op.run(number: 1).false? # return false
With active_model
require 'light_operations'
require 'active_model'
class Person
include ActiveModel::Model
attr_accessor :name, :age
validates_presence_of :name
end
class CreatePerson < LightOperations::Core
subject_name :person
def execute(params = {})
dependency(:repository).new(params).tap do |person|
person.valid?
end
end
end
class FakeController
def create(params = {})
create_operation.run(params)
end
def create_operation
@create_operation ||= CreatePerson.new(repository: Person).bind_with(self).on(success: :render_success, fail: :render_fail)
end
def render_success(operation)
person = operation.person
puts "name: #{person.name}"
end
def render_fail(operation)
person, errors = operation.subject, operation.errors
puts errors.as_json
puts "name: #{person.name}"
end
end
Class
class MyOperation < LightOperations::Core
def execute(_params = nil)
dependency(:my_service) # when missing MissingDependency error will be raised
end
end
Initialization
MyOperation.new(my_service: MyService.new)
You can add deferred actions for success and fail
# 1
MyOperation.new.on_success { |operation| render :done, locals: { model: operation.subject } }
# 2
MyOperation.new.on(success: -> () { |operation| render :done, locals: { model: operation.subject } )
When you bind operation with another object you could delegate actions to bound object methods
# 1
MyOperation.new.bind_with(self).on_success(:done)
# 2
MyOperation.new.bind_with(self).on(success: :done)
Execution method #run
finalize actions execution
MyOperation.new.bind_with(self).on(success: :done).run(params)
After execution operation hold execution state you could get back all info you need
-
#success?
=>true/false
-
#fail?
=>true/false
-
#subject?
=>success or fail object
-
#errors
=>errors by default array but you can return any objec tou want
Default usage
operation.new(dependencies)
.on(success: :done, fail: :show_error)
.bind_with(self)
.run(params)
or
operation.new(dependencies).tap do |op|
return op.run(params).success? ? op.subject : op.errors
end
success block or method receive operation as argument
operation.subject hold success object. You can use subject_name to create alias_method for subject
(operation) -> { }
or
def success_method(operation)
...
end
fail block or method receive operation as argument
operation.subject, operation.errors hold failure object and errors. You can use subject_name to create alias_method for subject
(operation) -> { }
or
def fail_method(operation)
...
end
Usage
Uses cases
Basic vote logic
Operation
class ArticleVoteBumperOperation < LightOperations::Core
rescue_from ActiveRecord::RecordInvalid, with: :on_ar_error
def execute(_params = nil)
article = dependency(:article_model)
article.vote = article.vote.next
article.save!
{ success: true }
end
def on_ar_error(_exception)
fail!(vote: 'could not be updated!')
end
end
Controller
class ArticleVotesController < ApplicationController
def up
operation = ArticleVoteBumperOperation.new(article_model: article)
response = operation.run.success? ? response.subject : response.errors
render :up, json: response
end
private
def article
Article.find(params.require(:id))
end
end
Basic recursive execution to collect news feeds from 2 sources
Operation
class CollectFeedsOperation < LightOperations::Core
rescue_from Timeout::Error, with: :on_timeout
subject_name :news
def execute(params = {})
dependency(:http_client).get(params.fetch(:url)).body
end
def on_timeout
fail!
end
end
Controller
class NewsFeedsController < ApplicationController
DEFAULT_NEWS_URL = 'http://rss.best_news.pl'
BACKUP_NEWS_URL = 'http://rss.not_so_bad_news.pl'
def news
collect_feeds_op
.bind_with(self)
.on(success: :display_news, fail: :second_attempt)
.run(url: DEFAULT_NEWS_URL)
end
private
def second_attempt(operation)
operation
.on_fail(:display_old_news)
.run(url: BACKUP_NEWS_URL)
end
def display_news(operation)
render :display_news, locals: { news: operation.news }
end
def display_old_news
end
def collect_feeds_op
@collect_feeds_op ||= CollectFeedsOperation.new(http_client: http_client)
end
def http_client
MyAwesomeHttpClient
end
end
Basic with active_model/active_record object
Operation
class AddBookOperation < LightOperations::Core
subject_name :book
def execute(params = {})
dependency(:book_model).new(params).tap do |model|
model.valid? # this method automatically provide errors from model.errors
end
end
end
Controller
class BooksController < ApplicationController
def index
render :index, locals: { collection: Book.all }
end
def new
render_book_form
end
def create
add_book_op
.bind_with(self)
.on(success: :book_created, fail: :render_book_form)
.run(permit_book_params)
end
private
def book_created(operation)
redirect_to :index, notice: "book #{operation.book.name} created"
end
def render_book_form(operation=nil)
book = operation ? operation.book : Book.new
render :new, locals: { book: book }
end
def add_book_op
@add_book_op ||= AddBookOperation.new(book_model: Book)
end
def permit_book_params
params.requre(:book)
end
end
Simple case when you want have user authorization
Operation
class AuthOperation < LightOperations::Core
rescue_from AuthFail, with: :on_auth_error
subject_name :account
def execute(params = {})
dependency(:auth_service).login(login: login(params), password: password(params))
end
def on_auth_error(_exception)
fail!([login: 'unknown']) # or subject.errors.add(login: 'unknown')
end
def login(params)
params.fetch(:login)
end
def password(params)
params.fetch(:password)
end
end
Controller way #1
class AuthController < ApplicationController
def new
render :new, locals: { account: Account.new }
end
def create
auth_op
.bind_with(self)
.on_success(:create_session_with_dashbord_redirection)
.on_fail(:render_account_with_errors)
.run(params)
end
private
def create_session_with_dashbord_redirection(operation)
session_create_for(operation.account)
redirect_to :dashboard
end
def render_account_with_errors(operation)
render :new, locals: { account: operation.account }
end
def auth_op
@auth_op ||= AuthOperation.new(auth_service: auth_service)
end
def auth_service
@auth_service ||= AuthService.new
end
end
Controller way #2
class AuthController < ApplicationController
def new
render :new, locals: { account: Account.new }
end
def create
auth_op
.on_success{ |op| create_session_with_dashbord_redirection(op.account) }
.on_fail { |op| render :new, locals: { account: op.account } }
.run(params)
end
private
def create_session_with_dashbord_redirection(account)
session_create_for(account)
redirect_to :dashboard
end
def auth_op
@auth_op ||= AuthOperation.new(auth_service: auth_service)
end
def auth_service
@auth_service ||= AuthService.new
end
end
Controller way #3
class AuthController < ApplicationController
def new
render :new, locals: { account: Account.new }
end
def create
auth_op.on_success(&go_to_dashboard).on_fail(&go_to_login).run(params)
end
private
def go_to_dashboard
-> (op) do
session_create_for(op.account)
redirect_to :dashboard
end
end
def go_to_login
-> (op) { render :new, locals: { account: op.account } }
end
def auth_op
@auth_op ||= AuthOperation.new(auth_service: auth_service)
end
def auth_service
@auth_service ||= AuthService.new
end
end
Register success and fails action is available by #on
like:
def create
auth_op.bind_with(self).on(success: :dashboard, fail: :show_error).run(params)
end
Operation have some helper methods (to improve recursive execution)
-
#clear!
=> return operation to init state -
#unbind!
=> unbind binded object -
#clear_subject_with_errors!
=> clear subject and errors
When operation status is most important we can simply use #success?
or #fail?
on the executed operation
Errors are available by #errors
after operation is executed
Whats new in 1.2.x
New module LightOperations::Flow which gives very simple and easy way to create operation per action in the controller (tested on rails).
How it works:
include the module in a controller like this
class AccountsController < VersionController
include LightOperations::Flow
operation :accounts, namespace: Operations, actions: [:create, :show]
def render_create(op)
render text: op.subject
end
def render_fail_create(op)
render text: op.errors # or if you want to show form use 'op.subject'
end
end
Now create operation class for account creation (components/operations/accounts/create.rb):
module Operations
module Accounts
class Create < LightOperations::Core
rescue_from ActiveRecord::RecordInvalid, with: :invalid_record_handler
def execute(params:)
Account.create!(params.require(:account))
end
private
def invalid_record_handler(ex)
fail!(ex.record.errors)
end
end
end
end
add into application.rb
config.autoload_paths += %W(
#{config.root}/app/components
)
But it is not all :D (operation params gives you a lot more)
class AccountsController < VersionController
include LightOperations::Flow
operation(
:accounts, # top-level namespace
namespace: Operations, # Base namespace by default is Kernel
actions: [:create, :show], # those are operations executed by router
default_view: nil, # By changing this option you can have one method for render all successful operations for all actions.
view_prefix: 'render_', # By changing this you can have #view_create instead of #render_create
default_fail_view: nil, # By changing this option you can have one method for render all failed operations for all actions.
fail_view_prefix: 'render_fail_' # By changing this you can have #view_fail_create instead of #render_fail_create
end
This simple module should give you the power to create something like this:
module Api
module V1
class AccountsController < VersionController
include LightOperations::Flow
skip_before_action :authorize, only: [:create, :password_reset]
operation :accounts,
namespace: Operations,
actions: [:create, :update, :show, :destroy, :password_reset],
default_fail_view: :render_error
private
def render_operation_error(op)
render json: op.errors, status: 422 # you can have status in operation if you want
end
def render_account(op)
render json: AccountOwnerSerializer.new(op.account), status: op.status
end
def render_no_content(_op)
render nothing: true, status: :no_content
end
alias_method :render_update, :render_account
alias_method :render_create, :render_account
alias_method :render_password_reset, :render_no_content
alias_method :render_destroy, :render_no_content
end
end
end
Contributing
- Fork it ( https://github.com/[my-github-username]/light_operations/fork )
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create a new Pull Request