SnFoil::Context
SnFoil Contexts are a simple way to ensure a workflow pipeline can be easily established end extended. It helps by creating a workflow, allowing additional steps at specific intervals, and reacting to success or failure, you should find your code being more maintainable and testable.
Installation
Add this line to your application's Gemfile:
gem 'snfoil-context'
Usage
While contexts are powerful, they aren't a magic bullet. Each function should strive to only contain a single purpose. This also has the added benefit of outlining some basic tests - if it is in a function it should have a related test.
Quickstart Example
require 'snfoil/context'
class TokenContext
include SnFoil::Context
action(:create) { |options| options[:object].save }
action(:expire) { |options| options[:object].update(expired_at: Time.current) }
# inject created_by
setup_create { |options| options[:params][:created_by] = entity }
# initialize token
before_create do |options|
options[:object] = Token.create(options[:params])
options
end
# send token email
after_create_success { |options| TokenMailer.new(token: option[:object]) }
# log expiration error
after_expire_failure { |options| ErrorLogger.notify(error: options[:object].errors) }
end
Initialize
When you new
up a SnFoil Context you should provide the entity running the actions. This will usually be a user but you can pass in anything. This will be accessible from within the context as entity
.
TokenContext.new(entity: current_user)
Actions
Actions are a group of hookable intervals that create a workflow around a single primary function.
To start you will need to define an action.
Arguments:
-
name
- The name of this action will also set the name of all the hooks and methods later generated. -
with
- Keyword Param - The method name of the primary action. Either this or a block is required -
block
- Block - The block of the primary action. Either this or with is required
# lib/contexts/token_context
require 'snfoil/context'
class TokenContext
include SnFoil::Context
...
action(:expire) { |options| options[:object].update(expired_at: Time.current) }
end
This will generate the intervals of the pipeline. In this example the following get made:
- setup_expire
- before_expire
- after_expire_success
- after_expire_failure
- after_expire
Now you can trigger the workflow by calling the action as a method on an instance of the context.
class TokenContext
include SnFoil::Context
action(:expire) { |options| options[:object].update(expired_at: Time.current) }
end
TokenContext.new(entity: current_user).expire(object: current_token)
If you want to reuse the primary action or just prefer methods, you can pass in the method name you would like to call, rather than providing a block. If a method name and a block are provided, the block is ignored.
# lib/contexts/token_context
require 'snfoil/context'
class TokenContext
include SnFoil::Context
action :expire, with: :expire_token
def expire_token(options)
options[:object].update(expired_at: Time.current)
end
end
Primary Function
The primary function is the function that determines whether or not the action is successful. To do this, the primary function must always return a truthy value if the action was successful, or a falsey one if it failed.
The primary function is passed one argument which is the return value of the closest preceding interval function.
# lib/contexts/token_context
require 'snfoil/context'
class TokenContext
include SnFoil::Context
action :expire, with: :expire_token
before_expire do |options|
options[:foo] = bar
options
end
def expire_token(options)
puts options[:foo] # => logs 'bar' to the console
...
end
end
Action Intervals
The following are the intervals SnFoil Contexts set up in the order they occur. The suggested uses are just very simple examples. You can chain contexts to setup very complex interactions in a very easy-to-manage workflow.
Name | Suggested Use |
---|---|
setup_<action> |
* find or create a model
* setup params needed later in the action
* set scoping
|
before_<action> |
* alter model or set attributes
|
primary action |
* persist database changes
* make primary network call
|
after_<action>_success |
* setup additional relationships
* success specific logging
|
after_<action>_failure |
* cleanup failed remenants
* call bug tracker
* failure specific logging
|
after_<action> |
* perform necessary required cleanup
* log outcome
|
Hook and Method Design
SnFoil Contexts try hard to not store variables longer than necessary. To facilitate this we have chosen to pass an object (we normally use a hash called options) to each hook and method, and the return from the hook or method is passed down the chain to the next hook or method.
The only method or block that does not get its value passed down the chain is the primary action - which must always return a truthy value of whether or not the action was successful.
Hooks
Hooks make it very easy to compose multiple actions that need to occur in a specific order. You can have as many repeated hooks as you would like. This makes defining single responsibility hooks very simple, and they will get called in the order they are defined.
Important Note Hooks always need to return the options hash at the end.
Example
# Call the webhooks for third party integrations
after_expire_success do |options|
call_webhook_for_model(options[:object])
options
end
# Commit business logic to internal process
after_expire_success do |options|
finalize_business_logic(options[:object])
options
end
# notify error tracker
after_expire_error do |options|
notify_errors(options[:object].errors)
options
end
Methods
Methods allow users to create inheritable actions that occur in a specific order. Methods will always run after their hook counterpart. Since these are inheritable, you can chain needed actions through the parent hierarchy by using the super
keyword. These are very useful when you need to have something always happen at the end of an Interval.
Important Note Methods always need to return the options hash at the end.
Author's opinion: While simpler than hooks, they do not allow for as clean of a composition as hooks.
Example
# Call the webhooks for third party integrations
# Commit business logic to internal process
def after_expire_success(**options)
options = super
call_webhook_for_model(options[:object])
finalize_business_logic(options[:object])
options
end
# notify error tracker
def after_expire_error(**options)
options = super
notify_errors(options[:object].errors)
options
end
Authorize
The original purpose of all of SnFoil was to ensure there was a good consistent way to authenticate and authorize entities. As such authorize hooks were built directly into the workflow.
These authorization hooks are always called twice. Once after setup_<action>
and once after before_<action>
The authorize
method functions much like primary action except the first argument is usually the name of the action you are authorizing.
Arguments:
-
name
- The name of this action to be authorized. If omitted, all actions without a specific associated authorize will use this -
with
- Keyword Param - The method name of the primary action. Either this or a block is required -
block
- Block - The block of the primary action. Either this or with is required
# lib/contexts/token_context
require 'snfoil/context'
class TokenContext
include SnFoil::Context
action :expire, with: :expire_token
authorize(:expire) { |_options| :entity.is_admin? }
...
end
You can also call authorize without an action name. This will have all action authorize with the provided method or block unless there is a more specific authorize action configured. It's probably easier explained with an example
# lib/contexts/token_context
require 'snfoil/context'
class TokenContext
include SnFoil::Context
action :expire, with: :expire_token #=> will authorize by checking the entity is an admin
action :search, with: :query_tokens #=> will authorize by checking the entity is a user
action :show, with: :find_token #=> will authorize by checking the entity is a user
authorize(:expire) { |_options| entity.is_admin? }
authorize { |_options| entity.is_user? }
...
end
Why before and after?
Simply to make sure the entity is allowed access to the primary target and is allowed to make the requested alterations/interactions.
Did Authorize Run?
If there is a valid policy to call SnFoil-Context will update the :authorize
key in the options.
Values are:
-
false
- The authorize method never ran -
:setup
- The authorize method ran after the setup interval (the first run) -
:before
- The authorize method ran after the before interval (the second run)
Intervals
There might be a situation where you don't need a before, after, success or failure, and just need a single name pipeline you can hook into. interval
allows you to create a single action-like segment.
class TokenContext
include SnFoil::Context
interval :demo
demo do |options|
... # Logic Here
options
end
demo do |options|
... # Additional Steps here
options
end
end
Just like for an action SnFoil allows you to define both hooks and a method. To run this interval you call it using the run_interval
method.
TokenContext.new(entity: entity).run_interval(:demo, **options)
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run rake spec
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
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and the created tag, and push the .gem
file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/limited-effort/snfoil-context. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
License
The gem is available as open source under the terms of the Apache 2 License.
Code of Conduct
Everyone interacting in the Snfoil::Context project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the code of conduct.