RungerActions
Organize and validate the business logic of your Rails application with this combined form object / command object.
Table of Contents
- RungerActions
- Table of Contents
- Installation
- Usage in general
- Setup
- Generate your actions
- Define your actions
- Invoke your actions
- Available methods
- Usage in specific
- An #execute instance method is required!
- Action class methods
- ::requires
- Specifying the expected shape of a Hash input
- Specifying ActiveModel-style validations
- Specifying arbitrary input "shapes" by providing a callable object
- Specifying validations for ActiveRecord inputs
- ::returns
- The result object
- All promised values must be returned
- Validating the "shape" of returned values
- ::fails_with
- Setting an error_message
- ::requires
- Alternatives
- Status / Context
- Development
- License
Installation
Add the gem to your application's Gemfile.
gem 'runger_actions'And then execute:
$ bundle install
Usage in general
Setup
Create a new subdirectory within the app/ directory in your Rails app: app/actions/.
Create an app/actions/application_action.rb file with this content:
# app/actions/application_action.rb
class ApplicationAction < RungerActions::Base
endGenerate your actions
This gem provides a Rails generator. For example, running:
bin/rails g runger_actions:action Users::Create
will create an empty action in app/actions/users/create.rb.
Define your actions
Then, you can start defining actions. Here's an example:
# app/actions/send_text_message.rb
class SendTextMessage < ApplicationAction
requires :message_body, String, length: { minimum: 3 } # don't send any super short messages
requires :user, User do
validates :phone, presence: true, format: { with: /[[:digit:]]{11}/ }
end
returns :cost, Float, numericality: { greater_than_or_equal_to: 0 }
returns :nexmo_id, String, presence: true
fails_with :nexmo_request_failed
def execute
nexmo_response = NexmoClient.send_text!(number: user.phone, message: message_body)
if nexmo_response.success?
nexmo_response_data = nexmo_response.parsed_response
result.cost = nexmo_response_data['cost']
result.nexmo_id = nexmo_response_data['message-id']
else
result.nexmo_request_failed!
end
end
endInvoke your actions
Once you have defined one or more actions, you can invoke the action(s) anywhere in your code, such as in a controller, as illustrated below.
# app/controllers/api/text_messages_controller.rb
class Api::TextMessagesController < ApplicationController
def create
send_message_action =
SendTextMessage.new(
user: current_user,
message_body: "Hello! This message was generated at #{Time.current}.",
)
if !send_message_action.valid?
# We'll enter this block if one of the ActiveRecord inputs (`user`, in this case) for the
# action doesn't meet the required validations, e.g. if the user's `phone` is blank.
render json: { error: send_message_action.errors.full_messages.join(', ') }, status: 400
return
end
result = send_message_action.run
if result.success?
Rails.logger.info("Sent message with Nexmo id #{result.nexmo_id} at a cost of #{result.cost}")
head :created
elsif result.nexmo_request_failed?
render json: { error: 'An error occurred when sending the text message' }, status: 500
end
end
endYou aren't limited to invoking actions from a controller action, though; you can invoke an action from anywhere in your code.
One good place to invoke an action is from within another action. For a complex or multi-step
process, you might want to break that process down into several "sub actions" that can be invoked
from the #execute method of a coordinating "parent action".
Available methods
There are a few different methods that can be used to instantiate and/or run an action:
-
::run!class method -
::new!class method -
::newclass method -
#run!instance method -
#runinstance method
::run! class method
This will attempt to instantiate an action (via ::new!) and then attempt to run the action (via
#run!). If there are any validation errors and/or if any fails_with conditions are invoked
during execution, then an error will be raised.
Example:
SendTextMessage.run!(user: current_user, message_body: 'Hello!')
::new! class method
This will attempt to instantiate an action. If there are any validation errors, then an error will be raised.
Example:
action = SendTextMessage.new!(user: current_user, message_body: 'Hi!')
::new class method
This will instantiate an action. Even if there are ActiveModel validation errors, an error will not be raised.
Example:
action = SendTextMessage.new(user: current_user, message_body: 'Hi!')
#run! instance method
This will attempt to run an action. If any fails_with conditions are invoked during execution,
then an error will be raised.
Example:
action = SendTextMessage.new!(user: current_user, message_body: 'Hi!')
action.run!
#run instance method
This will run an action. If any fails_with conditions are invoked during execution, then an error
will not be raised. The errors will be registered on the result object.
Example:
action = SendTextMessage.new!(user: current_user, message_body: 'Hi!')
result = action.run
result.nexmo_request_failed? # check if a `fails_with` condition was invokedUsage in specific
An #execute instance method is required!
The only real requirement for an action is that it implements an #execute instance method.
class DoSomething < ApplicationAction
def execute
# you MUST write an #execute instance method for your action
end
endAlthough all actions must implement an #execute instance method, you should generally not invoke
that method directly in your application code. Instead, call #run on an instance of the class:
# this will run the DoSomething#execute instance method
DoSomething.new.runAction class methods
When defining an action class, these three class methods are available:
requiresreturnsfails_with
Those class methods are all optional, though. We'll detail/illustrate their usage below.
::requires
The ::requires class method declares the necessary, expected inputs that are needed in order to
execute an action.
An action can have zero, one, or more requires statements.
An action that requires no input values will have no requires statements:
class PrintCurrentTime < ApplicationAction
def execute
puts("The current time is #{Time.now}.")
end
end
PrintCurrentTime.new.run
# => prints "The current time is 2020-06-20 03:25:14 -0700."Most actions probably will take one or more inputs, though. Here's an example of an action with one
requires statement:
class PrintDoubledNumber < ApplicationAction
requires :number, Numeric
def execute
puts("#{number} doubled is #{number * 2}")
end
end
PrintDoubledNumber.new(number: 8).run
# => prints "8 doubled is 16"In the example above, because the PrintDoubledNumber action class declares requires :number, a
#number instance method is available for all instances of that action class. This #number
instance method is used within the PrintDoubledNumber#execute action.
All subsequent arguments given to requires are used to define a "shape" via the shaped
gem.
The simplest way to define the expected "shape" of a required action parameter is probably to
declare its expected class, as illustrated above (where we specified that the number input
parameter must be an instance of Numeric). However, the shaped gem supports a wide variety of
ways to specify the expected "shape" of an input. A few additional examples are shown below; see the
shaped documentation for more possibilities.
Specifying the expected shape of a Hash input
class PrintNameAndEmail < ApplicationAction
# The `{ email: String, phone: String }` argument specifies the expected shape of `user_data`.
requires :user_data, { name: String, email: String }
def execute
puts("The email of #{user_data[:name]} is #{user_data[:email]}.")
end
end
PrintNameAndEmail.new(user_data: { name: 'Tom', email: 'tommy@example.com' }).run
# => prints "The email of Tom is tommy@example.com."
# The name and email keys are strings; they are supposed to be symbols.
PrintNameAndEmail.new(user_data: { 'name' => 'Thomas', 'email' => 'tommy@example.com' })
# => raises RungerActions::TypeMismatch
# The `:name` key is missing in the `user_data` hash.
PrintNameAndEmail.new(user_data: { email: 'tommy@example.com' })
# => raises RungerActions::TypeMismatchSpecifying ActiveModel-style validations
class PrintEmail < ApplicationAction
requires :email, String, format: { with: /.+@.+\..+/ }, length: { minimum: 6 }
def execute
puts("The email is '#{email}'.")
end
end
PrintEmail.new(email: 'jefferson@example.com').run
# => prints "The email is 'jefferson@example.com'."
# This email doesn't match the specified regex
PrintEmail.new(email: 'Thomas Jefferson')
# => raises RungerActions::TypeMismatch
# This email is too short
PrintEmail.new(email: 'a@b.c')
# => raises RungerActions::TypeMismatchSpecifying arbitrary input "shapes" by providing a callable object
You can leverage shaped's Callable shape
type by providing any object that
responds to #call (such as a lambda). This allows you unlimited flexibility to define requirements
for the action's input(s).
class PrintSmallEvenNumber < ApplicationAction
requires :small_even_number, ->(number) { (0..6).cover?(number) && number.even? }
def execute
puts("#{small_even_number} is a small, even number.")
end
end
PrintSmallEvenNumber.new(small_even_number: 2).run
# => prints "2 is a small, even number."
# This number is not even
PrintSmallEvenNumber.new(small_even_number: 3).run
# => raises RungerActions::TypeMismatch
# This number is not small
PrintSmallEvenNumber.new(small_even_number: 200).run
# => raises RungerActions::TypeMismatchSpecifying validations for ActiveRecord inputs
When declaring a requires where the input is specified (via the second argument to requires) to
be a class that inherits from ActiveRecord::Base, there are a few special things that happen:
- You can provide a validation block for the ActiveRecord object. Within this block, you can specify validations on attributes of that ActiveRecord model.
- You can check, by calling
valid?on an instance of the action, whether the ActiveRecord object(s) that are inputs for the action meet the validation block validations. - You can access any validation errors (from the validation block) via the
#errorsmethod of the action instance. - You can execute the action instance via
run!rather thanrun; this will raise an exception (and not run the#executemethod) if any of the validations from a validation block are not met.
class PrintFirstAndLastName < ApplicationAction
requires :user, User do
validates :name, format: { with: /.+ .+/ }
end
def execute
name_parts = user.name.split(' ')
puts("First name: #{name_parts.first}. Last name: #{name_parts.last}")
end
end
user = User.find(1)
user.is_a?(ActiveRecord::Base)
# => true
user.name
# => "David Runger"
action = PrintFirstAndLastName.new(user: user)
action.valid?
# => true
action.errors.to_hash
# => {}
action.run!
# => prints "First name: David. Last name: Runger"
user = User.find(2)
user.name
# => "Cher"
action = PrintFirstAndLastName.new(user: user)
action.valid?
# => false
action.errors.to_hash
# => {:name=>["is invalid"]}
action.run!
# => raises RungerActions::InvalidParam::returns
The ::returns class method describes the value(s) that an action promises to return (if any).
As with requires, an action can have zero, one, or more returns statements.
An action that is used for its "side effects," such as most of the examples above that use puts to
print output, will probably not have any returns statements.
However, if you want the action to return object(s)/data to other parts of your code, then you'll
need to declare those return values using the returns class method.
Here's an example:
class MultiplyNumber < ApplicationAction
requires :input_number, Numeric
returns :doubled_number, Numeric
returns :tripled_number, Numeric
def execute
result.doubled_number = input_number * 2
result.tripled_number = input_number * 3
end
end
multiply_result = MultiplyNumber.new(input_number: 1.5).run
multiply_result.class
# => MultiplyNumber::Result
puts("The number doubled is #{multiply_result.doubled_number}")
# => prints "The number doubled is 3.0"
puts("The number tripled is #{multiply_result.tripled_number}")
# => prints "The number tripled is 4.5"The result object
We can see in the example above that MultiplyNumber#execute references result, which is an
object provided automatically to action instances. Because the MultiplyNumber action declares
returns :doubled_number and returns :tripled_number, the result object automatically has
#doubled_number= and #tripled_number= writer methods, which can (and should) be invoked by the
action instance in order to set those values on the result object.
When we call MultiplyNumber.new(input_number: 1.5).run, the return value of #run is the action's
result object. Outside of the action, we can then access the return values that were set within
the action's #execute method; we do this via the #doubled_number and #tripled_number reader
methods that are defined on the result object (which we captured in a local variable called
multiply_result).
All promised values must be returned
If an action fails to set any promised return values on the result object, then an error will be
raised when #run is called:
class MultiplyNumber < ApplicationAction
requires :input_number, Numeric
returns :doubled_number, Numeric
returns :tripled_number, Numeric
def execute
# PROBLEM BELOW! An error will be raised when this action is executed,
# because we fail to set a `doubled_number` return value.
# result.doubled_number = input_number * 2
result.tripled_number = input_number * 3
end
end
multiply_result = MultiplyNumber.new(input_number: 10).run
# => raises RungerActions::MissingResultValueValidating the "shape" of returned values
As with the requires action class method, the "shape" of the promised return values declared via
returns can be described via the arguments to returns, which are passed to the shaped
gem. Leveraging this functionality allows you to ensure
that your action is providing the expected type of return values.
class UppercaseEmail < ApplicationAction
requires :email, String, format: { with: /.+@.+/ }
returns :uppercased_email, String, format: { with: /[A-Z]+@[A-Z.]+/ }
def execute
result.uppercased_email = email.upcase
end
end
UppercaseEmail.new(email: 'david@protonmail.com').run.uppercased_email
# => "DAVID@PROTONMAIL.COM"If an action attempts to set a return value that doesn't match the specified "shape" for that return
value, then an RungerActions::TypeMismatch error will be raised:
class UppercaseEmail < ApplicationAction
requires :email, String, format: { with: /.+@.+/ }
returns :uppercased_email, String, format: { with: /[A-Z]+@[A-Z.]+/ }
def execute
# PROBLEM BELOW! This action is supposed to _upcase_ the email, not downcase it!
result.uppercased_email = email.downcase
end
end
UppercaseEmail.new(email: 'david@protonmail.com').run
# => raises RungerActions::TypeMismatch::fails_with
The ::fails_with class method can be used to enumerate possible "failure modes" for the action.
As with requires and returns, an action can have zero, one, or more fails_with statements.
Generally, it's best to try to write actions in a way such that we don't expect any failures, but
sometimes there are things outside of our control; in such cases, using fails_with to list these
possible points of failure is a good idea. For example, a call to an external API might time out or
receive a 500 error response.
Here's a (contrived) example with one fails_with declaration:
class PrintRandomNumberAboveFive < ApplicationAction
fails_with :number_was_too_small
def execute
random_number = rand(10)
if random_number > 5
puts(random_number)
else
result.number_was_too_small!
end
end
end
result = PrintRandomNumberAboveFive.new.run
# => prints "9" (sometimes)
result.success?
# => true
result.number_was_too_small?
# => falseIn the case above, we didn't encounter the error condition, which we can verify via the #success?
and #number_was_too_small? methods on the result. #success? is available on all action results,
and #number_was_too_small? is available for this particular action result because the action class
declares fails_with :number_was_too_small.
And here's what a failure case would look like:
result = PrintRandomNumberAboveFive.new.run
# => [doesn't print anything, if the random number is <= 5]
result.success?
# => false
result.number_was_too_small?
# => trueIn this case, we entered the else branch of the action's #execute method and called the
result.number_was_too_small! method (made available automatically because of the class's
fails_with :number_was_too_small declaration). Since we called the result.number_was_too_small!
method, indicating that that failure mode occurred when executing the action, #success? returns
false and #number_was_too_small? returns true.
Setting an error_message
When invoking a fails_with error case, the bang method can optionally take an error message as an
argument, which will then be made available via a special error_message reader on the result
object:
class SellAlcohol < ApplicationAction
requires :age, Numeric
fails_with :too_young
def execute
if age < 21
result.too_young!("Age #{age} is too young to buy alcohol.")
else
puts('Enjoy your alcohol responsibly!')
end
end
end
result = SellAlcohol.new!(age: 17).run
result.success?
# => false
result.too_young?
# => true
result.error_message
# => "Age 17 is too young to buy alcohol."Alternatives
This project is not the first of its kind!
Here are a few similar projects:
Status / Context
I wouldn't recommend using this gem in production. It's very new (i.e. probably rough around the edges, subject to significant changes at a relatively rapid rate, and arguably somewhat feature incomplete) and I am not committed to maintaing the gem.
I mostly built this gem because I wasn't quite satisfied with any of the above alternatives that I knew about at the time that I decided to start building it. I built this gem mostly to scratch my own itch and for the sake of exploring this problem space a little bit.
I am actively using this gem in the small Rails application that hosts my personal website and apps;
you can check out its app/actions/
directory if you are
interested in seeing some real-world use cases.
Development
After checking out the repo, run bin/setup to install dependencies. Then, run bin/rspec 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.
License
The gem is available as open source under the terms of the MIT License.