Opera
Simple DSL for services/interactions classes.
Opera was born to mimic some of the philosophy of the dry gems but keeping the DSL simple.
Our aim was and is to write as many Operations, Services and Interactions using this fun and intuitive DSL to help developers have consistent code, easy to understand and maintain.
Installation
Add this line to your application's Gemfile:
gem 'opera'
And then execute:
$ bundle install
Or install it yourself as:
$ gem install opera
Configuration
Opera is built to be used with or without Rails. Simply initialise the configuration and chose what method you want to use to report errors and what library you want to use to implement transactions
Opera::Operation::Config.configure do |config|
config.transaction_class = ActiveRecord::Base
config.transaction_method = :transaction
config.transaction_options = { requires_new: true }
config.reporter = defined?(Rollbar) ? Rollbar : Rails.logger
end
You can later override this configuration in each Operation to have more granularity
Usage
Once opera gem is in your project you can start to build Operations
class A < Opera::Operation::Base
configure do |config|
config.transaction_class = Profile
config.reporter = Rails.logger
end
success :populate
operation :inner_operation
validate :profile_schema
transaction do
step :create
step :update
step :destroy
end
validate do
step :validate_object
step :validate_relationships
end
benchmark do
success :hal_sync
end
success do
step :send_mail
step :report_to_audit_log
end
step :output
end
Start developing your business logic, services and interactions as Opera::Operations and benefit of code that is documented, self-explanatory, easy to maintain and debug.
Specs
When using Opera::Operation inside an engine add the following configuration to your spec_helper.rb or rails_helper.rb:
Opera::Operation::Config.configure do |config|
config.transaction_class = ActiveRecord::Base
end
Without this extra configuration you will receive:
NoMethodError:
undefined method `transaction' for nil:NilClass
Debugging
When you want to easily debug exceptions you can add this to your dummy.rb:
Rails.application.configure do
config.x.reporter = Logger.new(STDERR)
end
This should display exceptions captured inside operations.
You can also do it in Opera::Operation configuration block:
Opera::Operation::Config.configure do |config|
config.transaction_class = ActiveRecord::Base
config.reporter = Logger.new(STDERR)
end
Content
Basic operation
Example with sanitizing parameters
Example operation with old validations
Example with step that raises exception
Failing transaction
Passing transaction
Benchmark
Success
Finish if
Inner Operation
Inner Operations
Usage examples
Some cases and example how to use new operations
Basic operation
class Profile::Create < Opera::Operation::Base
context_accessor :profile
dependencies_reader :current_account, :mailer
validate :profile_schema
step :create
step :send_email
step :output
def profile_schema
Dry::Validation.Schema do
required(:first_name).filled
end.call(params)
end
def create
self.profile = current_account.profiles.create(params)
end
def send_email
mailer&.send_mail(profile: profile)
end
def output
result.output = { model: profile }
end
end
Call with valid parameters
Profile::Create.call(params: {
first_name: :foo,
last_name: :bar
}, dependencies: {
mailer: MyMailer,
current_account: Account.find(1)
})
#<Opera::Operation::Result:0x0000561636dced60 @errors={}, @exceptions={}, @information={}, @executions=[:profile_schema, :create, :send_email, :output], @output={:model=>#<Profile id: 30, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2020-08-14 16:04:08", updated_at: "2020-08-14 16:04:08", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
Call with INVALID parameters - missing first_name
Profile::Create.call(params: {
last_name: :bar
}, dependencies: {
mailer: MyMailer,
current_account: Account.find(1)
})
#<Opera::Operation::Result:0x0000562d3f635390 @errors={:first_name=>["is missing"]}, @exceptions={}, @information={}, @executions=[:profile_schema]>
Call with MISSING dependencies
Profile::Create.call(params: {
first_name: :foo,
last_name: :bar
}, dependencies: {
current_account: Account.find(1)
})
#<Opera::Operation::Result:0x007f87ba2c8f00 @errors={}, @exceptions={}, @information={}, @executions=[:profile_schema, :create, :send_email, :output], @output={:model=>#<Profile id: 33, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2019-01-03 12:04:25", updated_at: "2019-01-03 12:04:25", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
Example with sanitizing parameters
class Profile::Create < Opera::Operation::Base
context_accessor :profile
dependencies_reader :current_account, :mailer
validate :profile_schema
step :create
step :send_email
step :output
def profile_schema
Dry::Validation.Schema do
configure { config.input_processor = :sanitizer }
required(:first_name).filled
end.call(params)
end
def create
self.profile = current_account.profiles.create(context[:profile_schema_output])
end
def send_email
return true unless mailer
mailer.send_mail(profile: profile)
end
def output
result.output = { model: profile }
end
end
Profile::Create.call(params: {
first_name: :foo,
last_name: :bar
}, dependencies: {
mailer: MyMailer,
current_account: Account.find(1)
})
# NOTE: Last name is missing in output model
#<Opera::Operation::Result:0x000055e36a1fab78 @errors={}, @exceptions={}, @information={}, @executions=[:profile_schema, :create, :send_email, :output], @output={:model=>#<Profile id: 44, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: nil, created_at: "2020-08-17 11:07:08", updated_at: "2020-08-17 11:07:08", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
Example operation with old validations
class Profile::Create < Opera::Operation::Base
context_accessor :profile
dependencies_reader :current_account, :mailer
validate :profile_schema
step :build_record
step :old_validation
step :create
step :send_email
step :output
def profile_schema
Dry::Validation.Schema do
required(:first_name).filled
end.call(params)
end
def build_record
self.profile = current_account.profiles.build(params)
self.profile.force_name_validation = true
end
def old_validation
return true if profile.valid?
result.add_information(missing_validations: "Please check dry validations")
result.add_errors(profile.errors.messages)
false
end
def create
profile.save
end
def send_email
mailer.send_mail(profile: profile)
end
def output
result.output = { model: profile }
end
end
Call with valid parameters
Profile::Create.call(params: {
first_name: :foo,
last_name: :bar
}, dependencies: {
mailer: MyMailer,
current_account: Account.find(1)
})
#<Opera::Operation::Result:0x0000560ebc9e7a98 @errors={}, @exceptions={}, @information={}, @executions=[:profile_schema, :build_record, :old_validation, :create, :send_email, :output], @output={:model=>#<Profile id: 41, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2020-08-14 19:15:12", updated_at: "2020-08-14 19:15:12", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
Call with INVALID parameters
Profile::Create.call(params: {
first_name: :foo
}, dependencies: {
mailer: MyMailer,
current_account: Account.find(1)
})
#<Opera::Operation::Result:0x0000560ef76ba588 @errors={:last_name=>["can't be blank"]}, @exceptions={}, @information={:missing_validations=>"Please check dry validations"}, @executions=[:build_record, :old_validation]>
Example with step that raises exception
class Profile::Create < Opera::Operation::Base
context_accessor :profile
dependencies_reader :current_account, :mailer
validate :profile_schema
step :build_record
step :exception
step :create
step :send_email
step :output
def profile_schema
Dry::Validation.Schema do
required(:first_name).filled
end.call(params)
end
def build_record
self.profile = current_account.profiles.build(params)
self.profile.force_name_validation = true
end
def exception
raise StandardError, 'Example'
end
def create
self.profile = profile.save
end
def send_email
return true unless mailer
mailer.send_mail(profile: profile)
end
def output
result.output(model: profile)
end
end
Call with step throwing exception
result = Profile::Create.call(params: {
first_name: :foo,
last_name: :bar
}, dependencies: {
current_account: Account.find(1)
})
#<Opera::Operation::Result:0x0000562ad0f897c8 @errors={}, @exceptions={"Profile::Create#exception"=>["Example"]}, @information={}, @executions=[:profile_schema, :build_record, :exception]>
Example with step that finishes execution
class Profile::Create < Opera::Operation::Base
context_accessor :profile
dependencies_reader :current_account, :mailer
validate :profile_schema
step :build_record
step :create
step :send_email
step :output
def profile_schema
Dry::Validation.Schema do
required(:first_name).filled
end.call(params)
end
def build_record
self.profile = current_account.profiles.build(params)
self.profile.force_name_validation = true
end
def create
self.profile = profile.save
finish!
end
def send_email
return true unless mailer
mailer.send_mail(profile: profile)
end
def output
result.output(model: profile)
end
end
Call
result = Profile::Create.call(params: {
first_name: :foo,
last_name: :bar
}, dependencies: {
current_account: Account.find(1)
})
#<Opera::Operation::Result:0x007fc2c59a8460 @errors={}, @exceptions={}, @information={}, @executions=[:profile_schema, :build_record, :create]>
Failing transaction
class Profile::Create < Opera::Operation::Base
configure do |config|
config.transaction_class = Profile
end
context_accessor :profile
dependencies_reader :current_account, :mailer
validate :profile_schema
transaction do
step :create
step :update
end
step :send_email
step :output
def profile_schema
Dry::Validation.Schema do
required(:first_name).filled
end.call(params)
end
def create
self.profile = current_account.profiles.create(params)
end
def update
profile.update(example_attr: :Example)
end
def send_email
return true unless mailer
mailer.send_mail(profile: profile)
end
def output
result.output = { model: profile }
end
end
Example with non-existing attribute
Profile::Create.call(params: {
first_name: :foo,
last_name: :bar
}, dependencies: {
mailer: MyMailer,
current_account: Account.find(1)
})
D, [2020-08-14T16:13:30.946466 #2504] DEBUG -- : Account Load (0.5ms) SELECT "accounts".* FROM "accounts" WHERE "accounts"."deleted_at" IS NULL AND "accounts"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
D, [2020-08-14T16:13:30.960254 #2504] DEBUG -- : (0.2ms) BEGIN
D, [2020-08-14T16:13:30.983981 #2504] DEBUG -- : SQL (0.7ms) INSERT INTO "profiles" ("first_name", "last_name", "created_at", "updated_at", "account_id") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["first_name", "foo"], ["last_name", "bar"], ["created_at", "2020-08-14 16:13:30.982289"], ["updated_at", "2020-08-14 16:13:30.982289"], ["account_id", 1]]
D, [2020-08-14T16:13:30.986233 #2504] DEBUG -- : (0.2ms) ROLLBACK
#<Opera::Operation::Result:0x00005650e89b7708 @errors={}, @exceptions={"Profile::Create#update"=>["unknown attribute 'example_attr' for Profile."], "Profile::Create#transaction"=>["Opera::Operation::Base::RollbackTransactionError"]}, @information={}, @executions=[:profile_schema, :create, :update]>
Passing transaction
class Profile::Create < Opera::Operation::Base
configure do |config|
config.transaction_class = Profile
end
context_accessor :profile
dependencies_reader :current_account, :mailer
validate :profile_schema
transaction do
step :create
step :update
end
step :send_email
step :output
def profile_schema
Dry::Validation.Schema do
required(:first_name).filled
end.call(params)
end
def create
self.profile = current_account.profiles.create(params)
end
def update
profile.update(updated_at: 1.day.ago)
end
def send_email
return true unless mailer
mailer.send_mail(profile: profile)
end
def output
result.output = { model: profile }
end
end
Example with updating timestamp
Profile::Create.call(params: {
first_name: :foo,
last_name: :bar
}, dependencies: {
mailer: MyMailer,
current_account: Account.find(1)
})
D, [2020-08-17T12:10:44.842392 #2741] DEBUG -- : Account Load (0.7ms) SELECT "accounts".* FROM "accounts" WHERE "accounts"."deleted_at" IS NULL AND "accounts"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
D, [2020-08-17T12:10:44.856964 #2741] DEBUG -- : (0.2ms) BEGIN
D, [2020-08-17T12:10:44.881332 #2741] DEBUG -- : SQL (0.7ms) INSERT INTO "profiles" ("first_name", "last_name", "created_at", "updated_at", "account_id") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["first_name", "foo"], ["last_name", "bar"], ["created_at", "2020-08-17 12:10:44.879684"], ["updated_at", "2020-08-17 12:10:44.879684"], ["account_id", 1]]
D, [2020-08-17T12:10:44.886168 #2741] DEBUG -- : SQL (0.6ms) UPDATE "profiles" SET "updated_at" = $1 WHERE "profiles"."id" = $2 [["updated_at", "2020-08-16 12:10:44.883164"], ["id", 47]]
D, [2020-08-17T12:10:44.898132 #2741] DEBUG -- : (10.3ms) COMMIT
#<Opera::Operation::Result:0x0000556528f29058 @errors={}, @exceptions={}, @information={}, @executions=[:profile_schema, :create, :update, :send_email, :output], @output={:model=>#<Profile id: 47, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2020-08-17 12:10:44", updated_at: "2020-08-16 12:10:44", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
Benchmark
class Profile::Create < Opera::Operation::Base
context_accessor :profile
dependencies_reader :current_account, :mailer
validate :profile_schema
step :create
step :update
benchmark do
step :send_email
step :output
end
def profile_schema
Dry::Validation.Schema do
required(:first_name).filled
end.call(params)
end
def create
self.profile = current_account.profiles.create(params)
end
def update
profile.update(updated_at: 1.day.ago)
end
def send_email
return true unless mailer
mailer.send_mail(profile: profile)
end
def output
result.output = { model: profile }
end
end
Example with information (real and total) from benchmark
Profile::Create.call(params: {
first_name: :foo,
last_name: :bar
}, dependencies: {
current_account: Account.find(1)
})
#<Opera::Operation::Result:0x007ff414a01238 @errors={}, @exceptions={}, @information={:real=>1.800013706088066e-05, :total=>0.0}, @executions=[:profile_schema, :create, :update, :send_email, :output], @output={:model=>#<Profile id: 30, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2020-08-19 10:46:00", updated_at: "2020-08-18 10:46:00", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
Success
class Profile::Create < Opera::Operation::Base
context_accessor :profile
dependencies_reader :current_account, :mailer
validate :profile_schema
success :populate
step :create
step :update
success do
step :send_email
step :output
end
def profile_schema
Dry::Validation.Schema do
required(:first_name).filled
end.call(params)
end
def populate
context[:attributes] = {}
context[:valid] = false
end
def create
self.profile = current_account.profiles.create(params)
end
def update
profile.update(updated_at: 1.day.ago)
end
# NOTE: We can add an error in this step and it won't break the execution
def send_email
result.add_error('mailer', 'Missing dependency')
mailer&.send_mail(profile: profile)
end
def output
result.output = { model: context[:profile] }
end
end
Example output for success block
Profile::Create.call(params: {
first_name: :foo,
last_name: :bar
}, dependencies: {
current_account: Account.find(1)
})
#<Opera::Operation::Result:0x007fd0248e5638 @errors={"mailer"=>["Missing dependency"]}, @exceptions={}, @information={}, @executions=[:profile_schema, :populate, :create, :update, :send_email, :output], @output={:model=>#<Profile id: 40, user_id: nil, linkedin_uid: nil, picture: nil, headline: nil, summary: nil, first_name: "foo", last_name: "bar", created_at: "2019-01-03 12:21:35", updated_at: "2019-01-02 12:21:35", agree_to_terms_and_conditions: nil, registration_status: "", account_id: 1, start_date: nil, supervisor_id: nil, picture_processing: false, statistics: {}, data: {}, notification_timestamps: {}, suggestions: {}, notification_settings: {}, contact_information: []>}>
Finish If
class Profile::Create < Opera::Operation::Base
context_accessor :profile
dependencies_reader :current_account, :mailer
validate :profile_schema
step :create
finish_if :profile_create_only
step :update
success do
step :send_email
step :output
end
def profile_schema
Dry::Validation.Schema do
required(:first_name).filled
end.call(params)
end
def create
self.profile = current_account.profiles.create(params)
end
def profile_create_only
dependencies[:create_only].present?
end
def update
profile.update(updated_at: 1.day.ago)
end
# NOTE: We can add an error in this step and it won't break the execution
def send_email
result.add_error('mailer', 'Missing dependency')
mailer&.send_mail(profile: profile)
end
def output
result.output = { model: context[:profile] }
end
end
Example with information (real and total) from benchmark
Profile::Create.call(params: {
first_name: :foo,
last_name: :bar
}, dependencies: {
create_only: true,
current_account: Account.find(1)
})
#<Opera::Operation::Result:0x007fd0248e5638 @errors={}, @exceptions={}, @information={}, @executions=[:profile_schema, :create, :profile_create_only], @output={}>
Inner Operation
class Profile::Find < Opera::Operation::Base
step :find
def find
result.output = Profile.find(params[:id])
end
end
class Profile::Create < Opera::Operation::Base
validate :profile_schema
operation :find
step :create
step :output
def profile_schema
Dry::Validation.Schema do
optional(:id).filled
end.call(params)
end
def find
Profile::Find.call(params: params, dependencies: dependencies)
end
def create
return if context[:find_output]
puts 'not found'
end
def output
result.output = { model: context[:find_output] }
end
end
Example with inner operation doing the find
Profile::Create.call(params: {
id: 1
}, dependencies: {
current_account: Account.find(1)
})
#<Opera::Operation::Result:0x007f99b25f0f20 @errors={}, @exceptions={}, @information={}, @executions=[:profile_schema, :find, :create, :output], @output={:model=>{:id=>1, :user_id=>1, :linkedin_uid=>nil, ...}}>
Inner Operations
Expects that method returns array of Opera::Operation::Result
class Profile::Create < Opera::Operation::Base
step :validate
step :create
def validate; end
def create
result.output = { model: "Profile #{Kernel.rand(100)}" }
end
end
class Profile::CreateMultiple < Opera::Operation::Base
operations :create_multiple
step :output
def create_multiple
(0..params[:number]).map do
Profile::Create.call
end
end
def output
result.output = context[:create_multiple_output]
end
end
Profile::CreateMultiple.call(params: { number: 3 })
#<Opera::Operation::Result:0x0000564189f38c90 @errors={}, @exceptions={}, @information={}, @executions=[{:create_multiple=>[[:validate, :create], [:validate, :create], [:validate, :create], [:validate, :create]]}, :output], @output=[{:model=>"Profile 1"}, {:model=>"Profile 7"}, {:model=>"Profile 69"}, {:model=>"Profile 92"}]>
Opera::Operation::Result - Instance Methods
Sometimes it may be useful to be able to create an instance of the Result
with preset output
.
It can be handy especially in specs. Then just include it in the initializer:
Opera::Operation::Result.new(output: 'success')
- success? - [true, false] - Return true if no errors and no exceptions
- failure? - [true, false] - Return true if any error or exception
- output - [Anything] - Return Anything
- output=(Anything) - Sets content of operation output
- add_error(key, value) - Adds new error message
- add_errors(Hash) - Adds multiple error messages
- add_exception(method, message, classname: nil) - Adds new exception
- add_exceptions(Hash) - Adds multiple exceptions
- add_information(Hash) - Adss new information - Useful informations for developers
Opera::Operation::Base - Class Methods
- context_[reader|writer|accessor] - predefined methods for easy access
- [params|dependencies]_reader - predefined readers for immutable arguments
- step(Symbol) - single instruction
- return [Truthly] - continue operation execution
- return [False] - stops operation execution
- raise Exception - exception gets captured and stops operation execution
- operation(Symbol) - single instruction - requires to return Opera::Operation::Result object
- return [Opera::Operation::Result] - stops operation STEPS execution if any error, exception
- validate(Symbol) - single dry-validations - requires to return Dry::Validation::Result object
- return [Dry::Validation::Result] - stops operation STEPS execution if any error but continue with other validations
- transaction(*Symbols) - list of instructions to be wrapped in transaction
- return [Truthly] - continue operation execution
- return [False|Exception] - stops operation execution and breaks transaction/do rollback
- call(params: Hash, dependencies: Hash?)
- return [Opera::Operation::Result] - never raises an exception
Opera::Operation::Base - Instance Methods
- context [Hash] - used to pass information between steps - only for internal usage
- params [Hash] - immutable and received in call method
- dependencies [Hash] - immutable and received in call method
- finish! - this method interrupts the execution of steps after is invoked
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 tags, and push the .gem
file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/profinda/opera. 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 MIT License.
Code of Conduct
Everyone interacting in the Opera project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.