Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/AaronLasseigne/active_interaction
:briefcase: Manage application specific business logic.
https://github.com/AaronLasseigne/active_interaction
activemodel command-pattern method-object ruby service-object
Last synced: about 1 month ago
JSON representation
:briefcase: Manage application specific business logic.
- Host: GitHub
- URL: https://github.com/AaronLasseigne/active_interaction
- Owner: AaronLasseigne
- License: mit
- Created: 2013-06-26T22:19:51.000Z (over 11 years ago)
- Default Branch: main
- Last Pushed: 2024-05-30T08:15:26.000Z (7 months ago)
- Last Synced: 2024-10-29T15:01:45.125Z (about 1 month ago)
- Topics: activemodel, command-pattern, method-object, ruby, service-object
- Language: Ruby
- Homepage:
- Size: 1.81 MB
- Stars: 2,081
- Watchers: 25
- Forks: 137
- Open Issues: 19
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- License: LICENSE.md
Awesome Lists containing this project
- awesome-ruby - ActiveInteraction - Manage application specific business logic. (Abstraction)
README
# [ActiveInteraction][]
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.[![Version](https://img.shields.io/gem/v/active_interaction.svg?style=flat-square)](https://rubygems.org/gems/active_interaction)
[![Test](https://img.shields.io/github/actions/workflow/status/AaronLasseigne/active_interaction/test.yml?label=Test&style=flat-square&branch=main)](https://github.com/AaronLasseigne/active_interaction/actions?query=workflow%3ATest)- [Installation](#installation)
- [Basic usage](#basic-usage)
- [Validations](#validations)
- [Filters](#filters)
- [Basic Filters](#basic-filters)
- [Array](#array)
- [Boolean](#boolean)
- [File](#file)
- [Hash](#hash)
- [String](#string)
- [Symbol](#symbol)
- [Dates and times](#dates-and-times)
- [Date](#date)
- [DateTime](#datetime)
- [Time](#time)
- [Numbers](#numbers)
- [Decimal](#decimal)
- [Float](#float)
- [Integer](#integer)
- [Advanced Filters](#advanced-filters)
- [Interface](#interface)
- [Object](#object)
- [Record](#record)
- [Rails](#rails)
- [Setup](#setup)
- [Controller](#controller)
- [Index](#index)
- [Show](#show)
- [New](#new)
- [Create](#create)
- [Destroy](#destroy)
- [Edit](#edit)
- [Update](#update)
- [Advanced usage](#advanced-usage)
- [Callbacks](#callbacks)
- [Composition](#composition)
- [Defaults](#defaults)
- [Descriptions](#descriptions)
- [Errors](#errors)
- [Forms](#forms)
- [Shared input options](#shared-input-options)
- [Optional inputs](#optional-inputs)
- [Translations](#translations)
- [Credits](#credits)[API Documentation][]
## Installation
Add it to your Gemfile:
``` rb
gem 'active_interaction', '~> 5.3'
```Or install it manually:
``` sh
$ gem install active_interaction --version '~> 5.3'
```This project uses [Semantic Versioning][]. Check out [GitHub releases][] for a
detailed list of changes.## Basic usage
To define an interaction, create a subclass of `ActiveInteraction::Base`. Then
you need to do two things:1. **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](#filters) for all the available options.2. **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](#validations) if you need more than that.That covers the basics. Let's put it all together into a simple example that
squares a number.``` rb
require 'active_interaction'class Square < ActiveInteraction::Base
float :xdef 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`.``` rb
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.``` rb
Square.run!(x: 'two point one')
# ActiveInteraction::InvalidInteractionError: X is not a valid float
Square.run!(x: 2.1)
# => 4.41
```### Validations
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.``` rb
class SayHello < ActiveInteraction::Base
string :namevalidates :name,
presence: truedef 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.``` rb
SayHello.run!(name: nil)
# ActiveInteraction::InvalidInteractionError: Name is requiredSayHello.run!(name: '')
# ActiveInteraction::InvalidInteractionError: Name can't be blankSayHello.run!(name: 'Taylor')
# => "Hello, Taylor!"
```## Filters
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](#descriptions).- An optional block of sub-filters. Only [array](#array) and [hash](#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").``` rb
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`.### Basic Filters
#### Array
In addition to accepting arrays, array inputs will convert
`ActiveRecord::Relation`s into arrays.``` rb
class ArrayInteraction < ActiveInteraction::Base
array :toppingsdef execute
toppings.size
end
endArrayInteraction.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.``` rb
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`.``` rb
array :cows do
object
end
```You can override this by passing the necessary information to the inner filter.
```ruby
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.```ruby
class ArrayInteraction < ActiveInteraction::Base
array :favorite_numbers, index_errors: true do
integer
enddef execute
favorite_numbers
end
endArrayInteraction.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:
```ruby
{:favorite_numbers=>[{:error=>:invalid_type, :type=>"array"}]}
```#### Boolean
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.``` rb
class BooleanInteraction < ActiveInteraction::Base
boolean :kool_aiddef execute
'Oh yeah!' if kool_aid? # could also use `kool_aid`
end
endBooleanInteraction.run!(kool_aid: 1)
# ActiveInteraction::InvalidInteractionError: Kool aid is not a valid boolean
BooleanInteraction.run!(kool_aid: true)
# => "Oh yeah!"
```#### File
File filters also accept `TempFile`s and anything that responds to `#rewind`.
That means that you can pass the `params` from uploading files via forms in
Rails.``` rb
class FileInteraction < ActiveInteraction::Base
file :readmedef execute
readme.size
end
endFileInteraction.run!(readme: 'README.md')
# ActiveInteraction::InvalidInteractionError: Readme is not a valid file
FileInteraction.run!(readme: File.open('README.md'))
# => 21563
```#### Hash
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.``` rb
class HashInteraction < ActiveInteraction::Base
hash :preferences do
boolean :newsletter
boolean :sweepstakes
enddef execute
puts 'Thanks for joining the newsletter!' if preferences[:newsletter]
puts 'Good luck in the sweepstakes!' if preferences[:sweepstakes]
end
endHashInteraction.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](#defaults) then the hash must also use a lazy default.``` rb
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.``` rb
hash :stuff,
strip: false
```#### String
String filters define inputs that only accept strings.
``` rb
class StringInteraction < ActiveInteraction::Base
string :namedef execute
"Hello, #{name}!"
end
endStringInteraction.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`.``` rb
string :comment,
strip: false
```#### Symbol
Symbol filters define inputs that accept symbols. Strings will be converted
into symbols.``` rb
class SymbolInteraction < ActiveInteraction::Base
symbol :methoddef execute
method.to_proc
end
endSymbolInteraction.run!(method: -> {})
# ActiveInteraction::InvalidInteractionError: Method is not a valid symbol
SymbolInteraction.run!(method: :object_id)
# => #
```#### Dates and times
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.##### Date
``` rb
class DateInteraction < ActiveInteraction::Base
date :birthdaydef execute
birthday + (18 * 365)
end
endDateInteraction.run!(birthday: 'yesterday')
# ActiveInteraction::InvalidInteractionError: Birthday is not a valid date
DateInteraction.run!(birthday: Date.new(1989, 9, 1))
# => #
`````` rb
date :birthday,
format: '%Y-%m-%d'
```##### DateTime
``` rb
class DateTimeInteraction < ActiveInteraction::Base
date_time :nowdef execute
now.iso8601
end
endDateTimeInteraction.run!(now: 'now')
# ActiveInteraction::InvalidInteractionError: Now is not a valid date time
DateTimeInteraction.run!(now: DateTime.now)
# => "2015-03-11T11:04:40-05:00"
`````` rb
date_time :start,
format: '%Y-%m-%dT%H:%M:%S'
```##### Time
In addition to converting strings with `.parse` (or `.strptime`), time filters
convert numbers with `.at`.``` rb
class TimeInteraction < ActiveInteraction::Base
time :epochdef execute
Time.now - epoch
end
endTimeInteraction.run!(epoch: 'a long, long time ago')
# ActiveInteraction::InvalidInteractionError: Epoch is not a valid time
TimeInteraction.run!(epoch: Time.new(1970))
# => 1426068362.5136619
`````` rb
time :start,
format: '%Y-%m-%dT%H:%M:%S'
```#### Numbers
All numeric filters accept numeric input. They will also convert strings using
the appropriate method from `Kernel` (like `.Float`). Blank strings will be
treated as `nil`.##### Decimal
``` rb
class DecimalInteraction < ActiveInteraction::Base
decimal :pricedef execute
price * 1.0825
end
endDecimalInteraction.run!(price: 'one ninety-nine')
# ActiveInteraction::InvalidInteractionError: Price is not a valid decimal
DecimalInteraction.run!(price: BigDecimal(1.99, 2))
# => #
```To specify the number of significant digits, use the `digits` option.
``` rb
decimal :dollars,
digits: 2
```##### Float
``` rb
class FloatInteraction < ActiveInteraction::Base
float :xdef execute
x**2
end
endFloatInteraction.run!(x: 'two point one')
# ActiveInteraction::InvalidInteractionError: X is not a valid float
FloatInteraction.run!(x: 2.1)
# => 4.41
```##### Integer
``` rb
class IntegerInteraction < ActiveInteraction::Base
integer :limitdef execute
limit.downto(0).to_a
end
endIntegerInteraction.run!(limit: 'ten')
# ActiveInteraction::InvalidInteractionError: Limit is not a valid integer
IntegerInteraction.run!(limit: 10)
# => [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
```When a `String` is passed into an `integer` input, the value will be coerced.
A default base of `10` is used though it may be overridden with the `base` option.
If a base of `0` is provided, the coercion will respect radix indicators present
in the string.``` rb
class IntegerInteraction < ActiveInteraction::Base
integer :limit1
integer :limit2, base: 8
integer :limit3, base: 0def execute
[limit1, limit2, limit3]
end
endIntegerInteraction.run!(limit1: 71, limit2: 71, limit3: 71)
# => [71, 71, 71]
IntegerInteraction.run!(limit1: "071", limit2: "071", limit3: "0x71")
# => [71, 57, 113]
IntegerInteraction.run!(limit1: "08", limit2: "08", limit3: "08")
ActiveInteraction::InvalidInteractionError: Limit2 is not a valid integer, Limit3 is not a valid integer
```### Advanced Filters
#### Interface
Interface filters allow you to specify an interface that the passed value must
meet in order to pass. The name of the interface is used to look for a constant
inside the ancestor listing for the passed value. This allows for a variety of
checks depending on what's passed. Class instances are checked for an included
module or an inherited ancestor class. Classes are checked for an extended
module or an inherited ancestor class. Modules are checked for an extended
module.``` rb
class InterfaceInteraction < ActiveInteraction::Base
interface :exceptiondef execute
exception
end
endInterfaceInteraction.run!(exception: Exception)
# ActiveInteraction::InvalidInteractionError: Exception is not a valid interface
InterfaceInteraction.run!(exception: NameError) # a subclass of Exception
# => NameError
```You can use `:from` to specify a class or module. This would be the equivalent
of what's above.```rb
class InterfaceInteraction < ActiveInteraction::Base
interface :error,
from: Exceptiondef execute
error
end
end
```You can also create an anonymous interface on the fly by passing the `methods`
option.``` rb
class InterfaceInteraction < ActiveInteraction::Base
interface :serializer,
methods: %i[dump load]def execute
input = '{ "is_json" : true }'
object = serializer.load(input)
output = serializer.dump(object)output
end
endrequire 'json'
InterfaceInteraction.run!(serializer: Object.new)
# ActiveInteraction::InvalidInteractionError: Serializer is not a valid interface
InterfaceInteraction.run!(serializer: JSON)
# => "{\"is_json\":true}"
```#### Object
Object filters allow you to require an instance of a particular class or one of
its subclasses.``` rb
class Cow
def moo
'Moo!'
end
endclass ObjectInteraction < ActiveInteraction::Base
object :cowdef execute
cow.moo
end
endObjectInteraction.run!(cow: Object.new)
# ActiveInteraction::InvalidInteractionError: Cow is not a valid object
ObjectInteraction.run!(cow: Cow.new)
# => "Moo!"
```The class name is automatically determined by the filter name. If your filter
name is different than your class name, use the `class` option. It can be
either the class, a string, or a symbol.``` rb
object :dolly1,
class: Sheep
object :dolly2,
class: 'Sheep'
object :dolly3,
class: :Sheep
```If you have value objects or you would like to build one object from another,
you can use the `converter` option. It is only called if the value provided is
not an instance of the class or one of its subclasses. The `converter` option
accepts a symbol that specifies a class method on the object class or a proc.
Both will be passed the value and any errors thrown inside the converter will
cause the value to be considered invalid. Any returned value that is not the
correct class will also be treated as invalid. Any `default` that is not an
instance of the class or subclass and is not `nil` will also be converted.``` rb
class ObjectInteraction < ActiveInteraction::Base
object :ip_address,
class: IPAddr,
converter: :newdef execute
ip_address
end
endObjectInteraction.run!(ip_address: '192.168.1.1')
# #ObjectInteraction.run!(ip_address: 1)
# ActiveInteraction::InvalidInteractionError: Ip address is not a valid object
```#### Record
Record filters allow you to require an instance of a particular class (or one
of its subclasses) or a value that can be used to locate an instance of the
object. If the value does not match, it will call `find` on the class of the
record. This is particularly useful when working with ActiveRecord objects.
Like an object filter, the class is derived from the name passed but can be
specified with the `class` option. Any `default` that is not an instance of the
class or subclass and is not `nil` will also be found. Blank strings passed in
will be treated as `nil`.``` rb
class RecordInteraction < ActiveInteraction::Base
record :encodingdef execute
encoding
end
end> RecordInteraction.run!(encoding: Encoding::US_ASCII)
=> #> RecordInteraction.run!(encoding: 'ascii')
=> #
```A different method can be specified by providing a symbol to the `finder` option.
## Rails
ActiveInteraction plays nicely with Rails. You can use interactions to handle
your business logic instead of models or controllers. To see how it all works,
let's take a look at a complete example of a controller with the typical
resourceful actions.### Setup
We recommend putting your interactions in `app/interactions`. It's also very
helpful to group them by model. That way you can look in
`app/interactions/accounts` for all the ways you can interact with accounts.```
- app/
- controllers/
- accounts_controller.rb
- interactions/
- accounts/
- create_account.rb
- destroy_account.rb
- find_account.rb
- list_accounts.rb
- update_account.rb
- models/
- account.rb
- views/
- account/
- edit.html.erb
- index.html.erb
- new.html.erb
- show.html.erb
```### Controller
#### Index
``` rb
# GET /accounts
def index
@accounts = ListAccounts.run!
end
```Since we're not passing any inputs to `ListAccounts`, it makes sense to use
`.run!` instead of `.run`. If it failed, that would mean we probably messed up
writing the interaction.``` rb
class ListAccounts < ActiveInteraction::Base
def execute
Account.not_deleted.order(last_name: :asc, first_name: :asc)
end
end
```#### Show
Up next is the show action. For this one we'll define a helper method to handle
raising the correct errors. We have to do this because calling `.run!` would
raise an `ActiveInteraction::InvalidInteractionError` instead of an
`ActiveRecord::RecordNotFound`. That means Rails would render a 500 instead of
a 404.``` rb
# GET /accounts/:id
def show
@account = find_account!
endprivate
def find_account!
outcome = FindAccount.run(params)if outcome.valid?
outcome.result
else
fail ActiveRecord::RecordNotFound, outcome.errors.full_messages.to_sentence
end
end
```This probably looks a little different than you're used to. Rails commonly
handles this with a `before_filter` that sets the `@account` instance variable.
Why is all this interaction code better? Two reasons: One, you can reuse the
`FindAccount` interaction in other places, like your API controller or a Resque
task. And two, if you want to change how accounts are found, you only have to
change one place.Inside the interaction, we could use `#find` instead of `#find_by_id`. That way
we wouldn't need the `#find_account!` helper method in the controller because
the error would bubble all the way up. However, you should try to avoid raising
errors from interactions. If you do, you'll have to deal with raised exceptions
as well as the validity of the outcome.``` rb
class FindAccount < ActiveInteraction::Base
integer :iddef execute
account = Account.not_deleted.find_by_id(id)if account
account
else
errors.add(:id, 'does not exist')
end
end
end
```Note that it's perfectly fine to add errors during execution. Not all errors
have to come from checking or validation.#### New
The new action will be a little different than the ones we've looked at so far.
Instead of calling `.run` or `.run!`, it's going to initialize a new
interaction. This is possible because interactions behave like ActiveModels.``` rb
# GET /accounts/new
def new
@account = CreateAccount.new
end
```Since interactions behave like ActiveModels, we can use ActiveModel validations
with them. We'll use validations here to make sure that the first and last
names are not blank. [The validations section](#validations) goes into more
detail about this.``` rb
class CreateAccount < ActiveInteraction::Base
string :first_name, :last_namevalidates :first_name, :last_name,
presence: truedef to_model
Account.new
enddef execute
account = Account.new(inputs)unless account.save
errors.merge!(account.errors)
endaccount
end
end
```We used a couple of advanced features here. The `#to_model` method helps
determine the correct form to use in the view. Check out [the section on
forms](#forms) for more about that. Inside `#execute`, we merge errors. This is
a convenient way to move errors from one object to another. Read more about it
in [the errors section](#errors).#### Create
The create action has a lot in common with the new action. Both of them use the
`CreateAccount` interaction. And if creating the account fails, this action
falls back to rendering the new action.``` rb
# POST /accounts
def create
outcome = CreateAccount.run(params.fetch(:account, {}))if outcome.valid?
redirect_to(outcome.result)
else
@account = outcome
render(:new)
end
end
```Note that we have to pass a hash to `.run`. Passing `nil` is an error.
Since we're using an interaction, we don't need strong parameters. The
interaction will ignore any inputs that weren't defined by filters. So you can
forget about `params.require` and `params.permit` because interactions handle
that for you.#### Destroy
The destroy action will reuse the `#find_account!` helper method we wrote
earlier.``` rb
# DELETE /accounts/:id
def destroy
DestroyAccount.run!(account: find_account!)
redirect_to(accounts_url)
end
```In this simple example, the destroy interaction doesn't do much. It's not clear
that you gain anything by putting it in an interaction. But in the future, when
you need to do more than `account.destroy`, you'll only have to update one
spot.``` rb
class DestroyAccount < ActiveInteraction::Base
object :accountdef execute
account.destroy
end
end
```#### Edit
Just like the destroy action, editing uses the `#find_account!` helper. Then it
creates a new interaction instance to use as a form object.``` rb
# GET /accounts/:id/edit
def edit
account = find_account!
@account = UpdateAccount.new(
account: account,
first_name: account.first_name,
last_name: account.last_name)
end
```The interaction that updates accounts is more complicated than the others. It
requires an account to update, but the other inputs are optional. If they're
missing, it'll ignore those attributes. If they're present, it'll update them.``` rb
class UpdateAccount < ActiveInteraction::Base
object :accountstring :first_name, :last_name,
default: nilvalidates :first_name,
presence: true,
unless: -> { first_name.nil? }
validates :last_name,
presence: true,
unless: -> { last_name.nil? }def execute
account.first_name = first_name if first_name.present?
account.last_name = last_name if last_name.present?unless account.save
errors.merge!(account.errors)
endaccount
end
end
```#### Update
Hopefully you've gotten the hang of this by now. We'll use `#find_account!` to
get the account. Then we'll build up the inputs for `UpdateAccount`. Then we'll
run the interaction and either redirect to the updated account or back to the
edit page.``` rb
# PUT /accounts/:id
def update
inputs = { account: find_account! }.reverse_merge(params[:account])
outcome = UpdateAccount.run(inputs)if outcome.valid?
redirect_to(outcome.result)
else
@account = outcome
render(:edit)
end
end
```## Advanced usage
### Callbacks
[ActiveSupport::Callbacks][] provides a powerful framework for defining callbacks.
ActiveInteraction uses that framework to allow hooking into various parts of an
interaction's lifecycle.``` rb
class Increment < ActiveInteraction::Base
set_callback :filter, :before, -> { puts 'before filter' }integer :x
set_callback :validate, :after, -> { puts 'after validate' }
validates :x,
numericality: { greater_than_or_equal_to: 0 }set_callback :execute, :around, lambda { |_interaction, block|
puts '>>>'
block.call
puts '<<<'
}def execute
puts 'executing'
x + 1
end
endIncrement.run!(x: 1)
# before filter
# after validate
# >>>
# executing
# <<<
# => 2
```In order, the available callbacks are `filter`, `validate`, and `execute`.
You can set `before`, `after`, or `around` on any of them.### Composition
You can run interactions from within other interactions with `#compose`. If the
interaction is successful, it'll return the result (just like if you had called
it with `.run!`). If something went wrong, execution will halt immediately and
the errors will be moved onto the caller.``` rb
class Add < ActiveInteraction::Base
integer :x, :ydef execute
x + y
end
endclass AddThree < ActiveInteraction::Base
integer :xdef execute
compose(Add, x: x, y: 3)
end
endAddThree.run!(x: 5)
# => 8
```To bring in filters from another interaction, use `.import_filters`. Combined
with `inputs`, delegating to another interaction is a piece of cake.``` rb
class AddAndDouble < ActiveInteraction::Base
import_filters Adddef execute
compose(Add, inputs) * 2
end
end
```Note that errors in composed interactions have a few tricky cases. See [the
errors section][] for more information about them.### Defaults
The default value for an input can take on many different forms. Setting the
default to `nil` makes the input optional. Setting it to some value makes that
the default value for that input. Setting it to a lambda will lazily set the
default value for that input. That means the value will be computed when the
interaction is run, as opposed to when it is defined.Lambda defaults are evaluated in the context of the interaction, so you can use
the values of other inputs in them.``` rb
# This input is optional.
time :a, default: nil
# This input defaults to `Time.at(123)`.
time :b, default: Time.at(123)
# This input lazily defaults to `Time.now`.
time :c, default: -> { Time.now }
# This input defaults to the value of `c` plus 10 seconds.
time :d, default: -> { c + 10 }
```### Descriptions
Use the `desc` option to provide human-readable descriptions of filters. You
should prefer these to comments because they can be used to generate
documentation. The interaction class has a `.filters` method that returns a
hash of filters. Each filter has a `#desc` method that returns the description.``` rb
class Descriptive < ActiveInteraction::Base
string :first_name,
desc: 'your first name'
string :last_name,
desc: 'your last name'
endDescriptive.filters.each do |name, filter|
puts "#{name}: #{filter.desc}"
end
# first_name: your first name
# last_name: your last name
```### Errors
ActiveInteraction provides detailed errors for easier introspection and testing
of errors. Detailed errors improve on regular errors by adding a symbol that
represents the type of error that has occurred. Let's look at an example where
an item is purchased using a credit card.``` rb
class BuyItem < ActiveInteraction::Base
object :credit_card, :item
hash :options do
boolean :gift_wrapped
enddef execute
order = credit_card.purchase(item)
notify(credit_card.account)
order
endprivate def notify(account)
# ...
end
end
```Having missing or invalid inputs causes the interaction to fail and return
errors.``` rb
outcome = BuyItem.run(item: 'Thing', options: { gift_wrapped: 'yes' })
outcome.errors.messages
# => {:credit_card=>["is required"], :item=>["is not a valid object"], :"options.gift_wrapped"=>["is not a valid boolean"]}
```Determining the type of error based on the string is difficult if not
impossible. Calling `#details` instead of `#messages` on `errors` gives you
the same list of errors with a testable label representing the error.``` rb
outcome.errors.details
# => {:credit_card=>[{:error=>:missing}], :item=>[{:error=>:invalid_type, :type=>"object"}], :"options.gift_wrapped"=>[{:error=>:invalid_type, :type=>"boolean"}]}
```Detailed errors can also be manually added during the execute call by passing a
symbol to `#add` instead of a string.``` rb
def execute
errors.add(:monster, :no_passage)
end
```ActiveInteraction also supports merging errors. This is useful if you want to
delegate validation to some other object. For example, if you have an
interaction that updates a record, you might want that record to validate
itself. By using the `#merge!` helper on `errors`, you can do exactly that.``` rb
class UpdateThing < ActiveInteraction::Base
object :thingdef execute
unless thing.save
errors.merge!(thing.errors)
endthing
end
end
```When a composed interaction fails, its errors are merged onto the caller. This
generally produces good error messages, but there are a few cases to look out
for.``` rb
class Inner < ActiveInteraction::Base
boolean :x, :y
endclass Outer < ActiveInteraction::Base
string :x
boolean :z, default: nildef execute
compose(Inner, x: x, y: z)
end
endoutcome = Outer.run(x: 'yes')
outcome.errors.details
# => { :x => [{ :error => :invalid_type, :type => "boolean" }],
# :base => [{ :error => "Y is required" }] }
outcome.errors.full_messages.join(' and ')
# => "X is not a valid boolean and Y is required"
```Since both interactions have an input called `x`, the inner error for that
input is moved to the `x` error on the outer interaction. This results in a
misleading error that claims the input `x` is not a valid boolean even though
it's a string on the outer interaction.Since only the inner interaction has an input called `y`, the inner error for
that input is moved to the `base` error on the outer interaction. This results
in a confusing error that claims the input `y` is required even though it's not
present on the outer interaction.### Forms
The outcome returned by `.run` can be used in forms as though it were an
ActiveModel object. You can also create a form object by calling `.new` on the
interaction.Given an application with an `Account` model we'll create a new `Account` using
the `CreateAccount` interaction.```rb
# GET /accounts/new
def new
@account = CreateAccount.new
end# POST /accounts
def create
outcome = CreateAccount.run(params.fetch(:account, {}))if outcome.valid?
redirect_to(outcome.result)
else
@account = outcome
render(:new)
end
end
```The form used to create a new `Account` has slightly more information on the
`form_for` call than you might expect.```erb
<%= form_for @account, as: :account, url: accounts_path do |f| %>
<%= f.text_field :first_name %>
<%= f.text_field :last_name %>
<%= f.submit 'Create' %>
<% end %>
```This is necessary because we want the form to act like it is creating a new
`Account`. Defining `to_model` on the `CreateAccount` interaction tells the
form to treat our interaction like an `Account`.```rb
class CreateAccount < ActiveInteraction::Base
# ...def to_model
Account.new
end
end
```Now our `form_for` call knows how to generate the correct URL and param name
(i.e. `params[:account]`).```erb
# app/views/accounts/new.html.erb
<%= form_for @account do |f| %>
<%# ... %>
<% end %>
```If you have an interaction that updates an `Account`, you can define `to_model`
to return the object you're updating.```rb
class UpdateAccount < ActiveInteraction::Base
# ...object :account
def to_model
account
end
end
```ActiveInteraction also supports [formtastic][] and [simple_form][]. The filters
used to define the inputs on your interaction will relay type information to
these gems. As a result, form fields will automatically use the appropriate
input type.### Shared input options
It can be convenient to apply the same options to a bunch of inputs. One common
use case is making many inputs optional. Instead of setting `default: nil` on
each one of them, you can use [`with_options`][] to reduce duplication.``` rb
with_options default: nil do
date :birthday
string :name
boolean :wants_cake
end
```### Optional inputs
Optional inputs can be defined by using the `:default` option as described in
[the filters section][]. Within the interaction, provided and default values
are merged to create `inputs`. There are times where it is useful to know
whether a value was passed to `run` or the result of a filter default. In
particular, it is useful when `nil` is an acceptable value. For example, you
may optionally track your users' birthdays. You can use the `inputs.given?` predicate
to see if an input was even passed to `run`. With `inputs.given?` you can also check
the input of a hash or array filter by passing a series of keys or indexes to
check.``` rb
class UpdateUser < ActiveInteraction::Base
object :user
date :birthday,
default: nildef execute
user.birthday = birthday if inputs.given?(:birthday)
errors.merge!(user.errors) unless user.save
user
end
end
```Now you have a few options. If you don't want to update their birthday, leave
it out of the hash. If you want to remove their birthday, set `birthday: nil`.
And if you want to update it, pass in the new value as usual.``` rb
user = User.find(...)# Don't update their birthday.
UpdateUser.run!(user: user)# Remove their birthday.
UpdateUser.run!(user: user, birthday: nil)# Update their birthday.
UpdateUser.run!(user: user, birthday: Date.new(2000, 1, 2))
```### Translations
ActiveInteraction is i18n aware out of the box! All you have to do is add
translations to your project. In Rails, these typically go into
`config/locales`. For example, let's say that for some reason you want to print
everything out backwards. Simply add translations for ActiveInteraction to your
`hsilgne` locale.``` yml
# config/locales/hsilgne.yml
hsilgne:
active_interaction:
types:
array: yarra
boolean: naeloob
date: etad
date_time: emit etad
decimal: lamiced
file: elif
float: taolf
hash: hsah
integer: regetni
interface: ecafretni
object: tcejbo
string: gnirts
symbol: lobmys
time: emit
errors:
messages:
invalid: dilavni si
invalid_type: '%{type} dilav a ton si'
missing: deriuqer si
```Then set your locale and run interactions like normal.
``` rb
class I18nInteraction < ActiveInteraction::Base
string :name
endI18nInteraction.run(name: false).errors.messages[:name]
# => ["is not a valid string"]I18n.locale = :hsilgne
I18nInteraction.run(name: false).errors.messages[:name]
# => ["gnirts dilav a ton si"]
```Everything else works like an `activerecord` entry. For example, to rename an
attribute you can use `attributes`.Here we'll rename the `num` attribute on an interaction named `product`:
``` yml
en:
active_interaction:
attributes:
product:
num: 'Number'
```## Credits
ActiveInteraction is brought to you by [Aaron Lasseigne][].
Along with Aaron, [Taylor Fausak][] helped create and maintain ActiveInteraction but has since moved on.If you want to contribute to ActiveInteraction, please read
[our contribution guidelines][]. A [complete list of contributors][] is
available on GitHub.ActiveInteraction is licensed under [the MIT License][].
[activeinteraction]: https://github.com/AaronLasseigne/active_interaction
[API Documentation]: http://rubydoc.info/github/AaronLasseigne/active_interaction
[Semantic Versioning]: http://semver.org/spec/v2.0.0.html
[GitHub releases]: https://github.com/AaronLasseigne/active_interaction/releases
[aaron lasseigne]: https://github.com/AaronLasseigne
[taylor fausak]: https://github.com/tfausak
[our contribution guidelines]: CONTRIBUTING.md
[complete list of contributors]: https://github.com/AaronLasseigne/active_interaction/graphs/contributors
[the MIT License]: LICENSE.md
[formtastic]: https://rubygems.org/gems/formtastic
[simple_form]: https://rubygems.org/gems/simple_form
[the filters section]: #filters
[the errors section]: #errors
[the optional inputs section]: #optional-inputs
[`with_options`]: http://api.rubyonrails.org/classes/Object.html#method-i-with_options
[ActiveSupport::Callbacks]: https://api.rubyonrails.org/classes/ActiveSupport/Callbacks.html