LightOperations
When you want have slim controllers or some logic with several operations this gem could help you to have nice separated and clan 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
Basicly this is a Container for buissnes 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 action depend for execution result will be executed.
Actions could be a block (Proc) or you could delgate execution to method other object,
by binding operation with 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 is many possible usecases where and how you could use operations.
You can build csacade of opreations, 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 other object you could delegate actions to binded 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::ActiveRecordError, with: :on_ar_error
def execute(_params = nil)
dependency(:article_model).tap do |article|
article.vote = article.vote.next
article.save
end
{ success: true }
end
def on_ar_error(_exception)
fail!(vote: 'could not be updated!')
end
end
Controller
class ArticleVotesController < ApplicationController
def up
response = operation.run.success? ? response.subject : response.errors
render :up, json: response
end
private
def operation
@operation ||= ArticleVoteBumperOperation.new(article_model: article)
end
def article
Article.find(params.require(:id))
end
end
Basic recursive execution to collect newsfeeds 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 avialable 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 importent 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 controller (tested on rails).
How it works:
include module in 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 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