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.
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
#executemethod. Each input you defined will be available as the type you specified. If any of the inputs are invalid,#executewon'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'classSquare < ActiveInteraction::Basefloat:xdefexecutex**2endendCall .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?# => niloutcome.errors.messages# => {:x=>["is not a valid float"]}outcome=Square.run(x: 2.1)outcome.valid?# => trueoutcome.result# => 4.41You 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 floatSquare.run!(x: 2.1)# => 4.41ActiveInteraction 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.
classSayHello < ActiveInteraction::Basestring:namevalidates:name,presence: truedefexecute"Hello, #{name}!"endendWhen 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 requiredSayHello.run!(name: '')# ActiveInteraction::InvalidInteractionError: Name can't be blankSayHello.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:
defaultis the fallback value to use ifnilis given. To make a filter optional, setdefault: nil.descis 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.endIn 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.
classArrayInteraction < ActiveInteraction::Basearray:toppingsdefexecutetoppings.sizeendendArrayInteraction.run!(toppings: 'everything')# ActiveInteraction::InvalidInteractionError: Toppings is not a valid arrayArrayInteraction.run!(toppings: [:cheese,'pepperoni'])# => 2Use 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:birthdaysdodateendFor 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:cowsdoobjectendYou can override this by passing the necessary information to the inner filter.
array:managersdoobjectclass: PeopleendErrors 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.
classArrayInteraction < ActiveInteraction::Basearray:favorite_numbers,index_errors: truedointegerenddefexecutefavorite_numbersendendArrayInteraction.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.
classBooleanInteraction < ActiveInteraction::Baseboolean:kool_aiddefexecute'Oh yeah!'ifkool_aid?# could also use `kool_aid`endendBooleanInteraction.run!(kool_aid: 1)# ActiveInteraction::InvalidInteractionError: Kool aid is not a valid booleanBooleanInteraction.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.
classFileInteraction < ActiveInteraction::Basefile:readmedefexecutereadme.sizeendendFileInteraction.run!(readme: 'README.md')# ActiveInteraction::InvalidInteractionError: Readme is not a valid fileFileInteraction.run!(readme: File.open('README.md'))# => 21563Hash 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.
classHashInteraction < ActiveInteraction::Basehash:preferencesdoboolean:newsletterboolean:sweepstakesenddefexecuteputs'Thanks for joining the newsletter!'ifpreferences[:newsletter]puts'Good luck in the sweepstakes!'ifpreferences[:sweepstakes]endendHashInteraction.run!(preferences: 'yes, no')# ActiveInteraction::InvalidInteractionError: Preferences is not a valid hashHashInteraction.run!(preferences: {newsletter: true,'sweepstakes'=>false})# Thanks for joining the newsletter!# => nilSetting 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: {}doboolean:likes_cookies,default: trueend# => {: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: falseString filters define inputs that only accept strings.
classStringInteraction < ActiveInteraction::Basestring:namedefexecute"Hello, #{name}!"endendStringInteraction.run!(name: 0xDEADBEEF)# ActiveInteraction::InvalidInteractionError: Name is not a valid stringStringInteraction.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: falseSymbol filters define inputs that accept symbols. Strings will be converted into symbols.
classSymbolInteraction < ActiveInteraction::Basesymbol:methoddefexecutemethod.to_procendendSymbolInteraction.run!(method: ->{})# ActiveInteraction::InvalidInteractionError: Method is not a valid symbolSymbolInteraction.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.
classDateInteraction < ActiveInteraction::Basedate:birthdaydefexecutebirthday + (18 * 365)endendDateInteraction.run!(birthday: 'yesterday')# ActiveInteraction::InvalidInteractionError: Birthday is not a valid dateDateInteraction.run!(birthday: Date.new(1989,9,1))# => #<Date: 2007-08-28 ((2454341j,0s,0n),+0s,2299161j)>date:birthday,format: '%Y-%m-%d'classDateTimeInteraction < ActiveInteraction::Basedate_time:nowdefexecutenow.iso8601endendDateTimeInteraction.run!(now: 'now')# ActiveInteraction::InvalidInteractionError: Now is not a valid date timeDateTimeInteraction.run!(now: DateTime.now)# => "2015-03-11T11:04:40-05:00"date_time:start,format: '%Y-%m-%dT%H:%M:%S'In addition to converting strings with .parse (or .strptime), time filters
convert numbers with .at.
classTimeInteraction < ActiveInteraction::Basetime:epochdefexecuteTime.now - epochendendTimeInteraction.run!(epoch: 'a long, long time ago')# ActiveInteraction::InvalidInteractionError: Epoch is not a valid timeTimeInteraction.run!(epoch: Time.new(1970))# => 1426068362.5136619time:start,format: '%Y-%m-%dT%H:%M:%S'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.
classDecimalInteraction < ActiveInteraction::Basedecimal:pricedefexecuteprice * 1.0825endendDecimalInteraction.run!(price: 'one ninety-nine')# ActiveInteraction::InvalidInteractionError: Price is not a valid decimalDecimalInteraction.run!(price: BigDecimal(1.99,2))# => #<BigDecimal:7fe792a42028,'0.2165E1',18(45)>To specify the number of significant digits, use the digits option.
decimal:dollars,digits: 2classFloatInteraction < ActiveInteraction::Basefloat:xdefexecutex**2endendFloatInteraction.run!(x: 'two point one')# ActiveInteraction::InvalidInteractionError: X is not a valid floatFloatInteraction.run!(x: 2.1)# => 4.41classIntegerInteraction < ActiveInteraction::Baseinteger:limitdefexecutelimit.downto(0).to_aendendIntegerInteraction.run!(limit: 'ten')# ActiveInteraction::InvalidInteractionError: Limit is not a valid integerIntegerInteraction.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.
classIntegerInteraction < ActiveInteraction::Baseinteger:limit1integer:limit2,base: 8integer:limit3,base: 0defexecute[limit1,limit2,limit3]endendIntegerInteraction.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: Limit2is not avalidinteger,Limit3is not avalidintegerInterface 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.
classInterfaceInteraction < ActiveInteraction::Baseinterface:exceptiondefexecuteexceptionendendInterfaceInteraction.run!(exception: Exception)# ActiveInteraction::InvalidInteractionError: Exception is not a valid interfaceInterfaceInteraction.run!(exception: NameError)# a subclass of Exception# => NameErrorYou can use :from to specify a class or module. This would be the equivalent
of what's above.
classInterfaceInteraction < ActiveInteraction::Baseinterface:error,from: ExceptiondefexecuteerrorendendYou can also create an anonymous interface on the fly by passing the methods
option.
classInterfaceInteraction < ActiveInteraction::Baseinterface:serializer,methods: %i[dumpload]defexecuteinput='{ "is_json" : true }'object=serializer.load(input)output=serializer.dump(object)outputendendrequire'json'InterfaceInteraction.run!(serializer: Object.new)# ActiveInteraction::InvalidInteractionError: Serializer is not a valid interfaceInterfaceInteraction.run!(serializer: JSON)# => "{\"is_json\":true}"Object filters allow you to require an instance of a particular class or one of its subclasses.
classCowdefmoo'Moo!'endendclassObjectInteraction < ActiveInteraction::Baseobject:cowdefexecutecow.mooendendObjectInteraction.run!(cow: Object.new)# ActiveInteraction::InvalidInteractionError: Cow is not a valid objectObjectInteraction.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.
object:dolly1,class: Sheepobject:dolly2,class: 'Sheep'object:dolly3,class: :SheepIf 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.
classObjectInteraction < ActiveInteraction::Baseobject:ip_address,class: IPAddr,converter: :newdefexecuteip_addressendendObjectInteraction.run!(ip_address: '192.168.1.1')# #<IPAddr: IPv4:192.168.1.1/255.255.255.255>ObjectInteraction.run!(ip_address: 1)# ActiveInteraction::InvalidInteractionError: Ip address is not a valid objectRecord 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.
classRecordInteraction < ActiveInteraction::Baserecord:encodingdefexecuteencodingendend
> RecordInteraction.run!(encoding: Encoding::US_ASCII)=>#<Encoding:US-ASCII>
> RecordInteraction.run!(encoding: 'ascii')=>#<Encoding:US-ASCII>A different method can be specified by providing a symbol to the finder option.
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.
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
# GET /accountsdefindex@accounts=ListAccounts.run!endSince 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.
classListAccounts < ActiveInteraction::BasedefexecuteAccount.not_deleted.order(last_name: :asc,first_name: :asc)endendUp 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.
# GET /accounts/:iddefshow@account=find_account!endprivatedeffind_account!outcome=FindAccount.run(params)ifoutcome.valid?outcome.resultelsefailActiveRecord::RecordNotFound,outcome.errors.full_messages.to_sentenceendendThis 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.
classFindAccount < ActiveInteraction::Baseinteger:iddefexecuteaccount=Account.not_deleted.find_by_id(id)ifaccountaccountelseerrors.add(:id,'does not exist')endendendNote that it's perfectly fine to add errors during execution. Not all errors have to come from checking or validation.
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.
# GET /accounts/newdefnew@account=CreateAccount.newendSince 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 goes into more detail about this.
classCreateAccount < ActiveInteraction::Basestring:first_name,:last_namevalidates:first_name,:last_name,presence: truedefto_modelAccount.newenddefexecuteaccount=Account.new(inputs)unlessaccount.saveerrors.merge!(account.errors)endaccountendendWe 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 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.
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.
# POST /accountsdefcreateoutcome=CreateAccount.run(params.fetch(:account,{}))ifoutcome.valid?redirect_to(outcome.result)else@account=outcomerender(:new)endendNote 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.
The destroy action will reuse the #find_account! helper method we wrote
earlier.
# DELETE /accounts/:iddefdestroyDestroyAccount.run!(account: find_account!)redirect_to(accounts_url)endIn 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.
classDestroyAccount < ActiveInteraction::Baseobject:accountdefexecuteaccount.destroyendendJust like the destroy action, editing uses the #find_account! helper. Then it
creates a new interaction instance to use as a form object.
# GET /accounts/:id/editdefeditaccount=find_account!@account=UpdateAccount.new(account: account,first_name: account.first_name,last_name: account.last_name)endThe 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.
classUpdateAccount < ActiveInteraction::Baseobject:accountstring:first_name,:last_name,default: nilvalidates:first_name,presence: true,unless: ->{first_name.nil?}validates:last_name,presence: true,unless: ->{last_name.nil?}defexecuteaccount.first_name=first_nameiffirst_name.present?account.last_name=last_nameiflast_name.present?unlessaccount.saveerrors.merge!(account.errors)endaccountendendHopefully 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.
# PUT /accounts/:iddefupdateinputs={account: find_account!}.reverse_merge(params[:account])outcome=UpdateAccount.run(inputs)ifoutcome.valid?redirect_to(outcome.result)else@account=outcomerender(:edit)endendActiveSupport::Callbacks provides a powerful framework for defining callbacks. ActiveInteraction uses that framework to allow hooking into various parts of an interaction's lifecycle.
classIncrement < ActiveInteraction::Baseset_callback:filter,:before,->{puts'before filter'}integer:xset_callback:validate,:after,->{puts'after validate'}validates:x,numericality: {greater_than_or_equal_to: 0}set_callback:execute,:around,lambda{ |_interaction,block|
puts'>>>'block.callputs'<<<'}defexecuteputs'executing'x + 1endendIncrement.run!(x: 1)# before filter# after validate# >>># executing# <<<# => 2In order, the available callbacks are filter, validate, and execute.
You can set before, after, or around on any of them.
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.
classAdd < ActiveInteraction::Baseinteger:x,:ydefexecutex + yendendclassAddThree < ActiveInteraction::Baseinteger:xdefexecutecompose(Add,x: x,y: 3)endendAddThree.run!(x: 5)# => 8To bring in filters from another interaction, use .import_filters. Combined
with inputs, delegating to another interaction is a piece of cake.
classAddAndDouble < ActiveInteraction::Baseimport_filtersAdddefexecutecompose(Add,inputs) * 2endendNote that errors in composed interactions have a few tricky cases. See the errors section for more information about them.
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.
# 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}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.
classDescriptive < ActiveInteraction::Basestring:first_name,desc: 'your first name'string:last_name,desc: 'your last name'endDescriptive.filters.eachdo |name,filter|
puts"#{name}: #{filter.desc}"end# first_name: your first name# last_name: your last nameActiveInteraction 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.
classBuyItem < ActiveInteraction::Baseobject:credit_card,:itemhash:optionsdoboolean:gift_wrappedenddefexecuteorder=credit_card.purchase(item)notify(credit_card.account)orderendprivatedefnotify(account)# ...endendHaving missing or invalid inputs causes the interaction to fail and return errors.
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.
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.
Adding an error at any point during #execute will flag the interaction as invalid. The #execute method will, however, run to completion. Interaction started with .run! will raise ActiveInteraction::InvalidInteractionError.
defexecuteerrors.add(:monster,:no_passage)endActiveInteraction 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.
classUpdateThing < ActiveInteraction::Baseobject:thingdefexecuteunlessthing.saveerrors.merge!(thing.errors)endthingendendWhen 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.
classInner < ActiveInteraction::Baseboolean:x,:yendclassOuter < ActiveInteraction::Basestring:xboolean:z,default: nildefexecutecompose(Inner,x: x,y: z)endendoutcome=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.
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.
# GET /accounts/newdefnew@account=CreateAccount.newend# POST /accountsdefcreateoutcome=CreateAccount.run(params.fetch(:account,{}))ifoutcome.valid?redirect_to(outcome.result)else@account=outcomerender(:new)endendThe form used to create a new Account has slightly more information on the
form_for call than you might expect.
<%= 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.
classCreateAccount < ActiveInteraction::Base# ...defto_modelAccount.newendendNow our form_for call knows how to generate the correct URL and param name
(i.e. params[:account]).
# 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.
classUpdateAccount < ActiveInteraction::Base# ...object:accountdefto_modelaccountendendActiveInteraction 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.
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.
with_optionsdefault: nildodate:birthdaystring:nameboolean:wants_cakeendOptional 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.
classUpdateUser < ActiveInteraction::Baseobject:userdate:birthday,default: nildefexecuteuser.birthday=birthdayifinputs.given?(:birthday)errors.merge!(user.errors)unlessuser.saveuserendendNow 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.
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))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.
# config/locales/hsilgne.ymlhsilgne:
active_interaction:
types:
array: yarraboolean: naeloobdate: etaddate_time: emit etaddecimal: lamicedfile: eliffloat: taolfhash: hsahinteger: regetniinterface: ecafretniobject: tcejbostring: gnirtssymbol: lobmystime: emiterrors:
messages:
invalid: dilavni siinvalid_type: '%{type} dilav a ton si'missing: deriuqer siThen set your locale and run interactions like normal.
classI18nInteraction < ActiveInteraction::Basestring:nameendI18nInteraction.run(name: false).errors.messages[:name]# => ["is not a valid string"]I18n.locale=:hsilgneI18nInteraction.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:
en:
active_interaction:
attributes:
product:
num: 'Number'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.