:briefcase: Manage application specific business logic.
:briefcase: Manage application specific business logic.
ActiveInteraction manages application-specific business logic. It's an implementation of service objects designed to blend seamlessly into Rails. It also helps you write safer code by validating that your inputs conform to your expectations. If ActiveModel deals with your nouns, then ActiveInteraction handles your verbs.
[API Documentation][]
Add it to your Gemfile:
gem 'active_interaction', '~> 5.5'
Or install it manually:
$ gem install active_interaction --version '~> 5.5'
This project uses [Semantic Versioning][]. Check out [GitHub releases][] for a detailed list of changes.
To define an interaction, create a subclass of ActiveInteraction::Base. Then
you need to do two things:
Define your inputs. Use class filter methods to define what you expect
your inputs to look like. For instance, if you need a boolean flag for
pepperoni, use boolean :pepperoni. Check out the filters
section for all the available options.
Define your business logic. Do this by implementing the #execute
method. Each input you defined will be available as the type you specified.
If any of the inputs are invalid, #execute won't be run. Filters are
responsible for checking your inputs. Check out the validations
section if you need more than that.
That covers the basics. Let's put it all together into a simple example that squares a number.
require 'active_interaction'
class Square < ActiveInteraction::Base
float :x
def execute
x**2
end
end
Call .run on your interaction to execute it. You must pass a single hash to
.run. It will return an instance of your interaction. By convention, we call
this an outcome. You can use the #valid? method to ask the outcome if it's
valid. If it's invalid, take a look at its errors with #errors. In either
case, the value returned from #execute will be stored in #result.
outcome = Square.run(x: 'two point one')
outcome.valid?
# => nil
outcome.errors.messages
# => {:x=>["is not a valid float"]}
outcome = Square.run(x: 2.1)
outcome.valid?
# => true
outcome.result
# => 4.41
You can also use .run! to execute interactions. It's like .run but more
dangerous. It doesn't return an outcome. If the outcome would be invalid, it
will instead raise an error. But if the outcome would be valid, it simply
returns the result.
Square.run!(x: 'two point one')
# ActiveInteraction::InvalidInteractionError: X is not a valid float
Square.run!(x: 2.1)
# => 4.41
ActiveInteraction checks your inputs. Often you'll want more than that. For instance, you may want an input to be a string with at least one non-whitespace character. Instead of writing your own validation for that, you can use validations from ActiveModel.
These validations aren't provided by ActiveInteraction. They're from ActiveModel. You can also use any custom validations you wrote yourself in your interactions.
class SayHello < ActiveInteraction::Base
string :name
validates :name,
presence: true
def execute
"Hello, #{name}!"
end
end
When you run this interaction, two things will happen. First ActiveInteraction will check your inputs. Then ActiveModel will validate them. If both of those are happy, it will be executed.
SayHello.run!(name: nil)
# ActiveInteraction::InvalidInteractionError: Name is required
SayHello.run!(name: '')
# ActiveInteraction::InvalidInteractionError: Name can't be blank
SayHello.run!(name: 'Taylor')
# => "Hello, Taylor!"
You can define filters inside an interaction using the appropriate class method. Each method has the same signature:
Some symbolic names. These are the attributes to create.
An optional hash of options. Each filter supports at least these two options:
default is the fallback value to use if nil is given. To make a filter
optional, set default: nil.
desc is a human-readable description of the input. This can be useful for
generating documentation. For more information about this, read the
descriptions section.
An optional block of sub-filters. Only array and hash filters support this. Other filters will ignore blocks when given to them.
Let's take a look at an example filter. It defines three inputs: x, y, and
z. Those inputs are optional and they all share the same description ("an
example filter").
array :x, :y, :z,
default: nil,
desc: 'an example filter' do
# Some filters support sub-filters here.
end
In general, filters accept values of the type they correspond to, plus a few
alternatives that can be reasonably coerced. Typically the coercions come from
Rails, so "1" can be interpreted as the boolean value true, the string
"1", or the number 1.
In addition to accepting arrays, array inputs will convert
ActiveRecord::Relations into arrays.
class ArrayInteraction < ActiveInteraction::Base
array :toppings
def execute
toppings.size
end
end
ArrayInteraction.run!(toppings: 'everything')
# ActiveInteraction::InvalidInteractionError: Toppings is not a valid array
ArrayInteraction.run!(toppings: [:cheese, 'pepperoni'])
# => 2
Use a block to constrain the types of elements an array can contain. Note that you can only have one filter inside an array block, and it must not have a name.
array :birthdays do
date
end
For interface, object, and record filters, the name of the array filter
will be singularized and used to determine the type of value passed. In the
example below, the objects passed would need to be of type Cow.
array :cows do
object
end
You can override this by passing the necessary information to the inner filter.
array :managers do
object class: People
end
Errors that occur will be indexed based on the Rails configuration setting
index_nested_attribute_errors. You can also manually override this setting
with the :index_errors option. In this state is is possible to get multiple
errors from a single filter.
class ArrayInteraction < ActiveInteraction::Base
array :favorite_numbers, index_errors: true do
integer
end
def execute
favorite_numbers
end
end
ArrayInteraction.run(favorite_numbers: [8, 'bazillion']).errors.details
=> {:"favorite_numbers[1]"=>[{:error=>:invalid_type, :type=>"array"}]}
With :index_errors set to false the error would have been:
{:favorite_numbers=>[{:error=>:invalid_type, :type=>"array"}]}
Boolean filters convert the strings "1", "true", and "on"
(case-insensitive) into true. They also convert "0", "false", and "off"
into false. Blank strings will be treated as nil.
Boolean values can be accessed using the filter name but can also be checked using a predicate method of the same name.
class BooleanInteraction < ActiveInteraction::Base
boolean :kool_aid
def execute
'Oh yeah!' if kool_aid? # could also use `kool_aid`
end
end
BooleanInteraction.run!(kool_aid: 1)
# ActiveInteraction::InvalidInteractionError: Kool aid is not a valid boolean
BooleanInteraction.run!(kool_aid: true)
# => "Oh yeah!"
File filters also accept TempFiles and anything that responds to #rewind.
That means that you can pass the params from uploading files via forms in
Rails.
class FileInteraction < ActiveInteraction::Base
file :readme
def execute
readme.size
end
end
FileInteraction.run!(readme: 'README.md')
# ActiveInteraction::InvalidInteractionError: Readme is not a valid file
FileInteraction.run!(readme: File.open('README.md'))
# => 21563
Hash filters accept hashes. The expected value types are given by passing a block and nesting other filters. You can have any number of filters inside a hash, including other hashes.
class HashInteraction < ActiveInteraction::Base
hash :preferences do
boolean :newsletter
boolean :sweepstakes
end
def execute
puts 'Thanks for joining the newsletter!' if preferences[:newsletter]
puts 'Good luck in the sweepstakes!' if preferences[:sweepstakes]
end
end
HashInteraction.run!(preferences: 'yes, no')
# ActiveInteraction::InvalidInteractionError: Preferences is not a valid hash
HashInteraction.run!(preferences: { newsletter: true, 'sweepstakes' => false })
# Thanks for joining the newsletter!
# => nil
Setting default hash values can be tricky. The default value has to be either
nil or {}. Use nil to make the hash optional. Use {} if you want to set
some defaults for values inside the hash. If any nested filter uses a
lazy default then the hash must also use a lazy default.
hash :optional,
default: nil
# => {:optional=>nil}
hash :with_defaults,
default: {} do
boolean :likes_cookies,
default: true
end
# => {:with_defaults=>{:likes_cookies=>true}}
By default, hashes remove any keys that aren't given as nested filters. To
allow all hash keys, set strip: false. In general we don't recommend doing
this, but it's sometimes necessary.
hash :stuff,
strip: false
String filters define inputs that only accept strings.
class StringInteraction < ActiveInteraction::Base
string :name
def execute
"Hello, #{name}!"
end
end
StringInteraction.run!(name: 0xDEADBEEF)
# ActiveInteraction::InvalidInteractionError: Name is not a valid string
StringInteraction.run!(name: 'Taylor')
# => "Hello, Taylor!"
String filter strips leading and trailing whitespace by default. To disable it, set the
strip option to false.
string :comment,
strip: false
Symbol filters define inputs that accept symbols. Strings will be converted into symbols.
class SymbolInteraction < ActiveInteraction::Base
symbol :method
def execute
method.to_proc
end
end
SymbolInteraction.run!(method: -> {})
# ActiveInteraction::InvalidInteractionError: Method is not a valid symbol
SymbolInteraction.run!(method: :object_id)
# => #<Proc:0x007fdc9ba94118>
Filters that work with dates and times behave similarly. By default, they all
convert strings into their expected data types using .parse. Blank strings
will be treated as nil. If you give the format option, they will instead
convert strings using .strptime. Note that formats won't work with DateTime
and Time filters if a time zone is set.
class DateInteraction < ActiveInteraction::Base
date :birthday
def execute
birthday + (18 * 365)
end
end
DateInteraction.run
No open issues yet, or sync has not completed.