A gem that provides an interface for creating feature-driven operations. You've probably heard at least one of these terms: "service objects", "form objects", or maybe even "commands". Subroutine calls these "ops" and really it's just about enabling clear, concise, testable, and meaningful code.
So you need to sign up a user? or maybe update one's account? or change a password? or maybe you need to sign up a business along with a user, associate them, send an email, and queue a worker in a single request? Not a problem, create an op for any of these use cases. Here's the signup example.
class SignupOp < ::Subroutine::Op
field :name
field :email
field :password
validates :name, presence: true
validates :email, presence: true
validates :password, presence: true
outputs :signed_up_user
protected
def perform
u = create_user!
deliver_welcome_email!(u)
output :signed_up_user, u
end
def create_user
User.new(params)
end
def deliver_welcome_email!(u)
UserMailer.welcome(u.id).deliver_later
end
end
So why is this needed?
- No insane cluttering of controllers with strong parameters, etc.
- No insane cluttering of models with validations, callbacks, and random methods that don't relate to integrity or access of model data.
- Insanely testable.
- Insanely easy to read and maintain.
- Multi-model operations become insanely easy.
- Your sanity.
app/
|
|- controllers/
| |- users_controller.rb
|
|- models/
| |- user.rb
|
|- ops/
|- signup_op.rb
resources :users, only: [] do
collection do
post :signup
end
end
When ops are around, the point of the model is to ensure data validity. That's essentially it. So most of your models are a series of validations, common accessors, queries, etc.
class User
validates :name, presence: true
validates :email, email: true
has_secure_password
end
I've found that a great way to handle errors with ops is to allow you top level controller to appropriately render errors in a consisent way. This is exceptionally easy for api-driven apps.
class Api::Controller < ApplicationController
rescue_from ::Subroutine::Failure, with: :render_op_failure
def render_op_failure(e)
# however you want to do this, `e` will be similar to an ActiveRecord::RecordInvalid error
# e.record.errors, etc
end
end
With ops, your controllers are essentially just connections between routes, operations, and whatever you use to build responses.
class UsersController < ::Api::Controller
def sign_up
# If the op fails, a ::Subroutine::Failure will be raised.
op = SignupOp.submit!(params)
# If the op succeeds, it will be returned so you can access it's information.
render json: op.signed_up_user
end
end
Ops have some fluff, but not much. The Subroutine::Op
class' entire purpose in life is to validate user input and execute
a series of operations. To enable this we filter input params, type cast params (if desired), and execute validations. Only
after these things are complete will the Op
perform it's operation.
Inputs are declared via the field
method and have just a couple of options:
class MyOp < ::Subroutine::Op
field :first_name
field :age, type: :integer
field :subscribed, type: :boolean, default: false
# ...
end
- type - declares the type which the input should be cast to. Available types are declared in
Subroutine::TypeCaster::TYPES
- default - the default value of the input if not otherwise provided. If the provided default responds to
call
(ie. proc, lambda) the result of thatcall
will be used at runtime. - aka - an alias (or aliases) that is checked when errors are inherited from other objects.
Since we like a clean & simple dsl, you can also declare inputs via the values
of Subroutine::TypeCaster::TYPES
. When declared
this way, the :type
option is assumed.
class MyOp < ::Subroutine::Op
string :first_name
date :dob
boolean :tos, :default => false
end
Since ops can use other ops, sometimes it's nice to explicitly state the inputs are valid. To "inherit" all the inputs from another op, simply use inputs_from
.
class MyOp < ::Subroutine::Op
string :token
inputs_from MyOtherOp
protected
def perform
verify_token!
MyOtherOp.submit! params.except(:token)
end
end
Since Ops include ActiveModel::Model, validations can be used just like any other ActiveModel object.
class MyOp < ::Subroutine::Op
field :first_name
validates :first_name, presence: true
end
Inputs are accessible within the op via public accessors. You can see if an input was provided via the field_provided?
method.
class MyOp < ::Subroutine::Op
field :first_name
validate :validate_first_name_is_not_bob
protected
def perform
# whatever this op does
true
end
def validate_first_name_is_not_bob
if field_provided?(:first_name) && first_name.downcase == 'bob'
errors.add(:first_name, 'should not be bob')
end
end
end
All provided params are accessible via the params
accessor. All default values are accessible via the defaults
accessor. The combination of the two is available via params_with_defaults
.
class MyOp < ::Subroutine::Op
string :name
string :status, default: "browsing"
def perform
puts params.inspect
puts defaults.inspect
puts params_with_defaults.inspect
end
end
MyOp.submit(name: "foobar", status: nil)
# => { name: "foobar" }
# => { status: "browsing" }
# => { name: "foobar", status: nil }
MyOp.submit(name: "foobar")
# => { name: "foobar" }
# => { status: "browsing" }
# => { name: "foobar", status: "browsing" }
Every op must implement a perform
method. This is the method which will be executed if all validations pass.
When the the perform
method is complete, the Op determins success based on whether errors
is empty.
class MyFailingOp < ::Subroutine::Op
field :first_name
validates :first_name, presence: true
protected
def perform
errors.add(:base, "This will never succeed")
end
end
Notice we do not declare perform
as a public method. This is to ensure the "public" api of the op remains as submit
or submit!
.
Reporting errors is very important in Subroutine Ops since these can be used as form objects. Errors can be reported a couple different ways:
errors.add(:key, :error)
That is, the way you add errors to an ActiveModel object.inherit_errors(error_object_or_activemodel_object)
Same aserrors.add
, but it iterates an existing error hash and inherits the errors. As part of this iteration, it checks whether the key in the provided error_object matches a field (or alias of a field) in our op. If there is a match, the error will be placed on that field, but if there is not, the error will be placed on:base
.
class MyOp < ::Subroutine::Op
string :first_name, aka: :firstname
string :last_name, aka: [:lastname, :surname]
protected
def perform
if first_name == 'bill'
errors.add(:first_name, 'cannot be bill')
return
end
if first_name == 'john'
errors.add(:first_name, 'cannot be john')
return
end
unless _user.valid?
# if there are :first_name or :firstname errors on _user, they will be added to our :first_name
# if there are :last_name, :lastname, or :surname errors on _user, they will be added to our :last_name
inherit_errors(_user)
end
end
def _user
@_user ||= User.new(params)
end
end
The Subroutine::Op
class' submit
and submit!
methods have identical signatures to the class' constructor, enabling a few different ways to utilize an op:
op = MyOp.submit({foo: 'bar'})
# if the op succeeds it will be returned, otherwise false will be returned.
op = MyOp.submit!({foo: 'bar'})
# if the op succeeds it will be returned, otherwise a ::Subroutine::Failure will be raised.
op = MyOp.new({foo: 'bar'})
val = op.submit
# if the op succeeds, val will be true, otherwise false
op = MyOp.new({foo: 'bar'})
op.submit!
# if the op succeeds nothing will be raised, otherwise a ::Subroutine::Failure will be raised.
The Subroutine::Association
module provides an interface for loading ActiveRecord instances easily.
class UserUpdateOp < ::Subroutine::Op
include ::Subroutine::Association
association :user
string :first_name, :last_name
protected
def perform
user.update_attributes(
first_name: first_name,
last_name: last_name
)
end
end
class RecordTouchOp < ::Subroutine::Op
include ::Subroutine::Association
association :record, polymorphic: true
protected
def perform
record.touch
end
end
The Subroutine::Auth
module provides basic bindings for application authorization. It assumes that, optionally, a User
will be provided as the first argument to an Op. It forces authorization to be declared on each class it's included in.
class SayHiOp < ::Subroutine::Op
include ::Subroutine::Auth
require_user!
string :say_what, default: "hi"
protected
def perform
puts "#{current_user.name} says: #{say_what}"
end
end
user = User.find("john")
SayHiOp.submit!(user)
# => John says: hi
SayHiOp.submit!(user, say_what: "hello")
# => John says: hello
SayHiOp.submit!
# => raises Subroutine::Auth::NotAuthorizedError
There are a handful of authorization configurations:
require_user!
- ensures that a user is providedrequire_no_user!
- ensures that a user is not presentno_user_requirements!
- explicitly doesn't matter
In addition to these top-level authorization declarations you can provide custom authorizations like so:
class AccountSetSecretOp < ::Subroutine::Op
include ::Subroutine::Auth
require_user!
authorize :authorize_first_name_is_john
# If you use a policy-based authorization framework like pundit:
# `policy` is a shortcut for the following:
# authorize -> { unauthorized! unless policy.can_set_secret? }
policy :can_set_secret?
string :secret
belongs_to :account
protected
def perform
account.secret = secret
current_user.save!
end
def authorize_first_name_is_john
unless current_user.first_name == "john"
unauthorized!
end
end
def policy
::UserPolicy.new(current_user, current_user)
end
end
There is a separate gem subroutine-factory which enables you to easily utilize factories and operations to produce test data. It's a great replacement to FactoryGirl, as it ensures the data entering your DB is getting there via a real world operation.
# support/factories/signups.rb
Subroutine::Factory.define :signup do
op ::SignupOp
inputs :email, sequence{|n| "foo{n}@example.com" }
inputs :password, "password123"
# by default, the op will be returned when the factory is used.
# this `output` returns the value of the `user` output on the resulting op
output :user
end
# signup_test.rb
user = Subroutine::Factory.create :signup
user = Subroutine::Factory.create :signup, email: "foo@bar.com"