# Opera [![Gem Version](https://badge.fury.io/rb/opera.svg)](https://badge.fury.io/rb/opera) ![Master](https://github.com/Profinda/opera/actions/workflows/release.yml/badge.svg?branch=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: ```ruby gem 'opera' ``` And then execute: $ bundle install Or install it yourself as: $ gem install opera Note. If you are using Ruby 2.x please use Opera 0.2.x ## 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 ```ruby Opera::Operation::Config.configure do |config| config.transaction_class = ActiveRecord::Base config.transaction_method = :transaction config.transaction_options = { requires_new: true } config.mode = :development # Can be set to production too 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 ```ruby 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](#user-content-basic-operation) [Example with sanitizing parameters](#user-content-example-with-sanitizing-parameters) [Example operation with old validations](#user-content-example-operation-with-old-validations) [Example with step that raises exception](#user-content-example-with-step-that-raises-exception) [Failing transaction](#user-content-failing-transaction) [Passing transaction](#user-content-passing-transaction) [Benchmark](#user-content-benchmark) [Success](#user-content-success) [Finish if](#user-content-finish-if) [Inner Operation](#user-content-inner-operation) [Inner Operations](#user-content-inner-operations) ## Usage examples Some cases and example how to use new operations ### Basic operation ```ruby class Profile::Create < Opera::Operation::Base # DEPRECATED # context_accessor :profile context do attr_accessor :profile end # DEPRECATED # dependencies_reader :current_account, :mailer dependencies do attr_reader :current_account, :mailer end 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 ```ruby Profile::Create.call(params: { first_name: :foo, last_name: :bar }, dependencies: { mailer: MyMailer, current_account: Account.find(1) }) ##}> ``` #### Call with INVALID parameters - missing first_name ```ruby Profile::Create.call(params: { last_name: :bar }, dependencies: { mailer: MyMailer, current_account: Account.find(1) }) #["is missing"]}, @exceptions={}, @information={}, @executions=[:profile_schema]> ``` #### Call with MISSING dependencies ```ruby Profile::Create.call(params: { first_name: :foo, last_name: :bar }, dependencies: { current_account: Account.find(1) }) ##}> ``` ### Example with sanitizing parameters ```ruby class Profile::Create < Opera::Operation::Base # DEPRECATED # context_accessor :profile context do attr_accessor :profile end # DEPRECATED # dependencies_reader :current_account, :mailer dependencies do attr_reader :current_account, :mailer end 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 ``` ```ruby 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 ##}> ``` ### Example operation with old validations ```ruby class Profile::Create < Opera::Operation::Base # DEPRECATED # context_accessor :profile context do attr_accessor :profile end # DEPRECATED # dependencies_reader :current_account, :mailer dependencies do attr_reader :current_account, :mailer end 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 ```ruby Profile::Create.call(params: { first_name: :foo, last_name: :bar }, dependencies: { mailer: MyMailer, current_account: Account.find(1) }) ##}> ``` #### Call with INVALID parameters ```ruby Profile::Create.call(params: { first_name: :foo }, dependencies: { mailer: MyMailer, current_account: Account.find(1) }) #["can't be blank"]}, @exceptions={}, @information={:missing_validations=>"Please check dry validations"}, @executions=[:build_record, :old_validation]> ``` ### Example with step that raises exception ```ruby class Profile::Create < Opera::Operation::Base # DEPRECATED # context_accessor :profile context do attr_accessor :profile end # DEPRECATED # dependencies_reader :current_account, :mailer dependencies do attr_reader :current_account, :mailer end 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 ```ruby result = Profile::Create.call(params: { first_name: :foo, last_name: :bar }, dependencies: { current_account: Account.find(1) }) #["Example"]}, @information={}, @executions=[:profile_schema, :build_record, :exception]> ``` ### Example with step that finishes execution ```ruby class Profile::Create < Opera::Operation::Base # DEPRECATED # context_accessor :profile context do attr_accessor :profile end # DEPRECATED # dependencies_reader :current_account, :mailer dependencies do attr_reader :current_account, :mailer end 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 ```ruby result = Profile::Create.call(params: { first_name: :foo, last_name: :bar }, dependencies: { current_account: Account.find(1) }) # ``` ### Failing transaction ```ruby class Profile::Create < Opera::Operation::Base configure do |config| config.transaction_class = Profile end # DEPRECATED # context_accessor :profile context do attr_accessor :profile end # DEPRECATED # dependencies_reader :current_account, :mailer dependencies do attr_reader :current_account, :mailer end 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 ```ruby 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 #["unknown attribute 'example_attr' for Profile."], "Profile::Create#transaction"=>["Opera::Operation::Base::RollbackTransactionError"]}, @information={}, @executions=[:profile_schema, :create, :update]> ``` ### Passing transaction ```ruby class Profile::Create < Opera::Operation::Base configure do |config| config.transaction_class = Profile end # DEPRECATED # context_accessor :profile context do attr_accessor :profile end # DEPRECATED # dependencies_reader :current_account, :mailer dependencies do attr_reader :current_account, :mailer end 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 ```ruby 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 ##}> ``` ### Benchmark ```ruby class Profile::Create < Opera::Operation::Base # DEPRECATED # context_accessor :profile context do attr_accessor :profile end # DEPRECATED # dependencies_reader :current_account, :mailer dependencies do attr_reader :current_account, :mailer end 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 ```ruby Profile::Create.call(params: { first_name: :foo, last_name: :bar }, dependencies: { current_account: Account.find(1) }) #1.800013706088066e-05, :total=>0.0}, @executions=[:profile_schema, :create, :update, :send_email, :output], @output={:model=>#}> ``` ### Success ```ruby class Profile::Create < Opera::Operation::Base # DEPRECATED # context_accessor :profile context do attr_accessor :profile end # DEPRECATED # dependencies_reader :current_account, :mailer dependencies do attr_reader :current_account, :mailer end 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 ```ruby Profile::Create.call(params: { first_name: :foo, last_name: :bar }, dependencies: { current_account: Account.find(1) }) #["Missing dependency"]}, @exceptions={}, @information={}, @executions=[:profile_schema, :populate, :create, :update, :send_email, :output], @output={:model=>#}> ``` ### Finish If ```ruby class Profile::Create < Opera::Operation::Base # DEPRECATED # context_accessor :profile context do attr_accessor :profile end # DEPRECATED # dependencies_reader :current_account, :mailer dependencies do attr_reader :current_account, :mailer end 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 ```ruby Profile::Create.call(params: { first_name: :foo, last_name: :bar }, dependencies: { create_only: true, current_account: Account.find(1) }) # ``` ### Inner Operation ```ruby 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 ```ruby Profile::Create.call(params: { id: 1 }, dependencies: { current_account: Account.find(1) }) #{:id=>1, :user_id=>1, :linkedin_uid=>nil, ...}}> ``` ### Inner Operations Expects that method returns array of `Opera::Operation::Result` ```ruby 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 ``` ```ruby Profile::CreateMultiple.call(params: { number: 3 }) #[[: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 - output! - Return Anything if Success, raise exception if Failure - 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 - 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 ## Opera::Operation::Base - Class Methods #### `context_reader` The `context_reader` helper method is designed to facilitate easy access to specified keys within a `context` hash. It dynamically defines a method that acts as a getter for the value associated with a specified key, simplifying data retrieval. #### Parameters **key (Symbol):** The key(s) for which the getter and setter methods are to be created. These symbols should correspond to keys in the context hash. **default (Proc, optional):** A lambda or proc that returns a default value for the key if it is not present in the context hash. This proc is lazily evaluated only when the getter is invoked and the key is not present in the hash. #### Usage **GOOD** ```ruby # USE context_reader to read steps outputs from the context hash context_reader :schema_output validate :schema # context = { schema_output: { id: 1 } } step :do_something def do_something puts schema_output # outputs: { id: 1 } end ``` ```ruby # USE context_reader with 'default' option to provide default value when key is missing in the context hash context_reader :profile, default: -> { Profile.new } step :fetch_profile step :do_something def fetch_profile return if App.http_disabled? context[:profile] = ProfileFetcher.call end def update_profile profile.name = 'John' profile.save! end ``` **BAD** ```ruby # Using `context_reader` to create read-only methods that instantiate objects, # especially when these objects are not stored or updated in the `context` hash, is not recommended. # This approach can lead to confusion and misuse of the context hash, # as it suggests that the object might be part of the persistent state. context_reader :serializer, default: -> { ProfileSerializer.new } step :output def output self.result = serializer.to_json({...}) end # A better practice is to use private methods to define read-only access to resources # that are instantiated on the fly and not intended for storage in any state context. step :output def output self.result = serializer.to_json({...}) end private def serializer ProfileSerializer.new end ``` **Conclusion** For creating instance methods that are meant to be read-only and not stored within a context hash, defining these methods as private is a more suitable and clear approach compared to using context_reader with a default. This method ensures that transient dependencies remain well-encapsulated and are not confused with persistent application state. ### `context|params|depenencies` The `context|params|depenencies` helper method is designed to enable easy access to and modification of values for specified keys within a `context` hash. This method dynamically defines both getter and setter methods for the designated keys, facilitating straightforward retrieval and update of values. #### attr_reader, attr_accessor Parameters **key (Symbol):** The key(s) for which the getter and setter methods are to be created. These symbols will correspond to keys in the context hash. **default (Proc, optional):** A lambda or proc that returns a default value for the key if it is not present in the context hash. This proc is lazily evaluated only when the getter is invoked and the key is not present in the hash. #### Usage ```ruby context do attr_accessor :profile end step :fetch_profile step :update_profile def fetch_profile self.profile = ProfileFetcher.call # sets context[:profile] end def update_profile profile.update!(name: 'John') # reads profile from context[:profile] end ``` ```ruby context do attr_accessor :profile, default: -> { Profile.new } end ``` ```ruby context do attr_accessor :profile, :account end ``` #### Other methods > - 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 ## 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](https://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](https://github.com/[USERNAME]/opera/blob/master/CODE_OF_CONDUCT.md). ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). ## 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](https://github.com/profinda/opera/blob/master/CODE_OF_CONDUCT.md).