Project

opera

0.0
There's a lot of open issues
Use simple DSL language to keep your Operations clean and maintainable
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Development

 Project Readme

Opera

Gem Version Master

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.