├── .mdlrc ├── FUNDING.yml ├── .rubocop ├── layout.yml ├── performance.yml ├── metrics.yml ├── base.yml ├── style.yml └── rspec.yml ├── bin ├── rubocop ├── setup └── console ├── lib ├── active_interactor │ ├── rails.rb │ ├── rails │ │ ├── orm │ │ │ ├── active_record.rb │ │ │ ├── dynamoid.rb │ │ │ └── mongoid.rb │ │ └── railtie.rb │ ├── organizer │ │ ├── base.rb │ │ ├── interactor_interface_collection.rb │ │ ├── organize.rb │ │ ├── perform.rb │ │ ├── interactor_interface.rb │ │ └── callbacks.rb │ ├── base.rb │ ├── version.rb │ ├── error.rb │ ├── config.rb │ ├── context │ │ ├── errors.rb │ │ ├── loader.rb │ │ ├── status.rb │ │ └── attributes.rb │ ├── models.rb │ ├── configurable.rb │ └── interactor │ │ └── worker.rb ├── rails │ └── generators │ │ ├── templates │ │ ├── application_context.rb │ │ ├── application_interactor.rb │ │ ├── application_organizer.rb │ │ ├── context_spec.erb │ │ ├── interactor_spec.erb │ │ ├── interactor_test_unit.erb │ │ ├── context_test_unit.erb │ │ ├── context.erb │ │ ├── organizer.erb │ │ ├── interactor.erb │ │ └── active_interactor.erb │ │ ├── active_interactor.rb │ │ ├── interactor │ │ ├── rspec_generator.rb │ │ ├── test_unit_generator.rb │ │ ├── context │ │ │ ├── rspec_generator.rb │ │ │ └── test_unit_generator.rb │ │ ├── context_generator.rb │ │ ├── interactor_generator.rb │ │ ├── generates_context.rb │ │ └── organizer_generator.rb │ │ └── active_interactor │ │ ├── install_generator.rb │ │ ├── generator.rb │ │ ├── application_context_generator.rb │ │ ├── application_organizer_generator.rb │ │ ├── application_interactor_generator.rb │ │ └── base.rb └── active_interactor.rb ├── .rubocop-codeclimate.yml ├── .rubocop.yml ├── spec ├── support │ ├── spec_helpers.rb │ ├── shared_examples │ │ ├── a_class_with_interactor_methods_example.rb │ │ ├── a_class_with_organizer_callback_methods_example.rb │ │ ├── a_class_with_interactor_context_methods_example.rb │ │ ├── a_class_that_extends_active_interactor_models_example.rb │ │ └── a_class_with_interactor_callback_methods_example.rb │ ├── coverage.rb │ └── helpers │ │ └── factories.rb ├── active_interactor │ ├── config_spec.rb │ ├── interactor │ │ ├── perform │ │ │ └── options_spec.rb │ │ └── worker_spec.rb │ ├── error_spec.rb │ ├── organizer │ │ ├── interactor_interface_collection_spec.rb │ │ ├── interactor_interface_spec.rb │ │ └── base_spec.rb │ ├── base_spec.rb │ └── version_spec.rb ├── active_interactor_spec.rb ├── spec_helper.rb └── integration │ ├── an_interactor_with_after_perform_callbacks_spec.rb │ ├── an_interactor_with_before_perform_callbacks_spec.rb │ ├── an_interactor_with_after_rollback_callbacks_spec.rb │ ├── an_interactor_with_before_rollback_callbacks_spec.rb │ ├── an_interactor_with_around_perform_callbacks_spec.rb │ ├── an_interactor_with_deferred_after_callbacks.rb │ ├── an_interactor_with_around_rollback_callbacks_spec.rb │ ├── active_record_integration_spec.rb │ ├── an_organizer_with_after_each_callbacks_spec.rb │ ├── an_organizer_with_before_each_callbacks_spec.rb │ ├── an_interactor_with_validations_on_calling_spec.rb │ ├── a_failing_interactor_spec.rb │ ├── an_organizer_with_around_each_callbacks_spec.rb │ ├── an_interactor_with_validations_on_called_spec.rb │ ├── an_organizer_performing_in_parallel_spec.rb │ ├── an_interactor_with_an_existing_context_class_spec.rb │ ├── an_organizer_with_options_callbacks_spec.rb │ ├── an_organizer_with_failing_nested_organizer_spec.rb │ ├── an_interactor_with_after_context_validation_callbacks_spec.rb │ ├── an_interactor_with_validations_spec.rb │ ├── an_organizer_with_all_perform_callbacks.rb │ ├── an_organizer_containing_organizer_with_after_callbacks_deferred_spec.rb │ ├── an_organizer_with_after_callbacks_deferred_spec.rb │ └── a_basic_interactor_spec.rb ├── .yardopts ├── mdl_style.rb ├── .github ├── ISSUE_TEMPLATE │ ├── question.md │ ├── security_report.md │ ├── bug_report.md │ └── feature_request.md ├── workflows │ ├── lint.yml │ ├── build_gem.yml │ ├── ci.yml │ ├── upload_sarif.yml │ ├── deploy.yml │ └── test.yml ├── dependabot.yml └── PULL_REQUEST_TEMPLATE.md ├── Gemfile ├── HUMANS.md ├── Rakefile ├── SECURITY.md ├── LICENSE ├── .codeclimate.yml ├── .gitignore ├── activeinteractor.gemspec ├── CODE_OF_CONDUCT.md ├── README.md └── CONTRIBUTING.md /.mdlrc: -------------------------------------------------------------------------------- 1 | style "./mdl_style.rb" 2 | -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: 2 | - aaronmallen 3 | -------------------------------------------------------------------------------- /.rubocop/layout.yml: -------------------------------------------------------------------------------- 1 | Layout/LineLength: 2 | Max: 120 3 | -------------------------------------------------------------------------------- /.rubocop/performance.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-performance 3 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | bundle exec rubocop --require rubocop-performance --require code_scanning --display-cop-names $@ 4 | -------------------------------------------------------------------------------- /.rubocop/metrics.yml: -------------------------------------------------------------------------------- 1 | Metrics/BlockLength: 2 | Exclude: 3 | - '*.gemspec' 4 | - spec/**/*_spec.rb 5 | - spec/support/**/*.rb 6 | -------------------------------------------------------------------------------- /lib/active_interactor/rails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_interactor' 4 | require 'active_interactor/rails/railtie' 5 | -------------------------------------------------------------------------------- /lib/rails/generators/templates/application_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationContext < ActiveInteractor::Context::Base 4 | end 5 | -------------------------------------------------------------------------------- /lib/rails/generators/templates/application_interactor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationInteractor < ActiveInteractor::Base 4 | end 5 | -------------------------------------------------------------------------------- /.rubocop/base.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - bin/**/* 4 | - vendor/bundle/**/* 5 | - spec/**/* 6 | NewCops: enable 7 | TargetRubyVersion: 2.5 8 | -------------------------------------------------------------------------------- /.rubocop/style.yml: -------------------------------------------------------------------------------- 1 | Style/Documentation: 2 | Enabled: false 3 | Style/OptionalBooleanParameter: 4 | Enabled: false 5 | Layout/BlockAlignment: 6 | Enabled: false 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/rails/generators/templates/application_organizer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationOrganizer < ActiveInteractor::Organizer::Base 4 | end 5 | -------------------------------------------------------------------------------- /lib/active_interactor/rails/orm/active_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveSupport.on_load(:active_record) do 4 | extend ActiveInteractor::Models 5 | end 6 | -------------------------------------------------------------------------------- /.rubocop/rspec.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rspec 3 | 4 | RSpec/DescribeClass: 5 | Exclude: 6 | - spec/integration/**/*_spec.rb 7 | RSpec/MultipleExpectations: 8 | Max: 3 9 | -------------------------------------------------------------------------------- /.rubocop-codeclimate.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - .rubocop/base.yml 3 | - .rubocop/layout.yml 4 | - .rubocop/metrics.yml 5 | - .rubocop/performance.yml 6 | - .rubocop/style.yml 7 | -------------------------------------------------------------------------------- /lib/active_interactor/rails/orm/dynamoid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dynamoid/document' 4 | 5 | Dynamoid::Document::ClassMethods.include ActiveInteractor::Models 6 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - .rubocop/base.yml 3 | - .rubocop/layout.yml 4 | - .rubocop/metrics.yml 5 | - .rubocop/performance.yml 6 | - .rubocop/rspec.yml 7 | - .rubocop/style.yml 8 | -------------------------------------------------------------------------------- /lib/active_interactor/rails/orm/mongoid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveSupport.on_load(:mongoid) do 4 | Mongoid::Document::ClassMethods.include ActiveInteractor::Models 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/spec_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Dir[File.join(__dir__, 'helpers', '**', '*.rb')].sort.each { |f| require f } 4 | 5 | RSpec.configure do |config| 6 | config.include Spec::Helpers::Factories 7 | end 8 | -------------------------------------------------------------------------------- /lib/rails/generators/templates/context_spec.erb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe <%= class_name %>Context, type: :interactor do 6 | pending "add some examples to (or delete) #{__FILE__}" 7 | end 8 | -------------------------------------------------------------------------------- /lib/rails/generators/templates/interactor_spec.erb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe <%= class_name %>, type: :interactor do 6 | pending "add some examples to (or delete) #{__FILE__}" 7 | end 8 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --title ActiveInteractor 2 | --readme README.md 3 | --markup-provider redcarpet 4 | --markup markdown 5 | --exclude lib/rails 6 | --protected 7 | --no-private 8 | --embed-mixins 9 | lib/**/*.rb 10 | - 11 | CHANGELOG.md 12 | LICENSE 13 | -------------------------------------------------------------------------------- /lib/rails/generators/templates/interactor_test_unit.erb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class <%= class_name %>Test < ActiveSupport::TestCase 6 | # test "the truth" do 7 | # assert true 8 | # end 9 | end 10 | -------------------------------------------------------------------------------- /lib/rails/generators/templates/context_test_unit.erb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test_helper' 4 | 5 | class <%= class_name %>ContextTest < ActiveSupport::TestCase 6 | # test "the truth" do 7 | # assert true 8 | # end 9 | end 10 | -------------------------------------------------------------------------------- /lib/rails/generators/templates/context.erb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= class_name %>Context < ApplicationContext 4 | <%- unless context_attributes.empty? -%> 5 | attributes <%= context_attributes.map { |a| ":#{a}" }.join(', ') %> 6 | <%- end -%> 7 | end 8 | -------------------------------------------------------------------------------- /mdl_style.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | all 4 | rule 'MD013', line_length: 120 5 | 6 | # Allow CHANGELOG.md like nesting 7 | rule 'MD024', allow_different_nesting: true 8 | 9 | # Disable rule because github wiki uses filename 10 | # as the initial H1 11 | exclude_rule 'MD041' 12 | -------------------------------------------------------------------------------- /lib/rails/generators/active_interactor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Dir[File.expand_path('active_interactor/*.rb', __dir__)].sort.each { |file| require file } 4 | Dir[File.expand_path('interactor/context/*.rb', __dir__)].sort.each { |file| require file } 5 | Dir[File.expand_path('interactor/*.rb', __dir__)].sort.each { |file| require file } 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ❓ Question 3 | about: Ask a question that isn't a feature request a bug report. 4 | title: '[Question]' 5 | labels: question 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | #### Question 12 | 13 | 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'active_interactor' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /lib/rails/generators/templates/organizer.erb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= class_name %> < ApplicationOrganizer 4 | <%- if skip_context? && !context_attributes.empty? -%> 5 | context_attributes <%= context_attributes.map { |a| ":#{a}" }.join(', ') %> 6 | 7 | <%- end -%> 8 | <%- if interactors.any? -%> 9 | organize <%= interactors.join(", ") %> 10 | <%- else -%> 11 | # organize :interactor_1, :interactor_2 12 | <%- end -%> 13 | end 14 | -------------------------------------------------------------------------------- /lib/rails/generators/templates/interactor.erb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= class_name %> < ApplicationInteractor 4 | <%- if skip_context? && !context_attributes.empty? -%> 5 | context_attributes <%= context_attributes.map { |a| ":#{a}" }.join(', ') %> 6 | 7 | <%- end -%> 8 | def perform 9 | # TODO implement <%= class_name %> call 10 | end 11 | 12 | def rollback 13 | # TODO implement <%= class_name %> rollback 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | rubocop: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Setup Branch 11 | uses: actions/checkout@v4 12 | 13 | - name: Setup Ruby 14 | uses: ruby/setup-ruby@v1 15 | with: 16 | ruby-version: '2.7' 17 | bundler-cache: true 18 | 19 | - name: Run Rubocop 20 | run: bundle exec rubocop --display-cop-names 21 | -------------------------------------------------------------------------------- /spec/active_interactor/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActiveInteractor::Config do 6 | subject { described_class.new } 7 | 8 | it { is_expected.to respond_to :logger } 9 | 10 | describe '.defaults' do 11 | subject { described_class.defaults } 12 | 13 | it 'is expected to have attributes :logger => Logger.new' do 14 | expect(subject[:logger]).to be_a Logger 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/rails/generators/interactor/rspec_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/active_interactor/base' 4 | 5 | module Interactor 6 | module Generators 7 | class RspecGenerator < ActiveInteractor::Generators::NamedBase 8 | desc 'Generate an interactor spec' 9 | 10 | def create_spec 11 | template 'interactor_spec.erb', active_interactor_file(parent_dir: 'spec', suffix: 'spec') 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/rails/generators/active_interactor/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/active_interactor/base' 4 | 5 | module ActiveInteractor 6 | module Generators 7 | class InstallGenerator < Base 8 | desc 'Install ActiveInteractor' 9 | class_option :orm 10 | 11 | def create_initializer 12 | template 'active_interactor.erb', File.join('config', 'initializers', 'active_interactor.rb') 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/rails/generators/interactor/test_unit_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/active_interactor/base' 4 | 5 | module Interactor 6 | module Generators 7 | class TestUnitGenerator < ActiveInteractor::Generators::NamedBase 8 | desc 'Generate an interactor unit test' 9 | 10 | def create_spec 11 | template 'interactor_test_unit.erb', active_interactor_file(parent_dir: 'test', suffix: 'test') 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/rails/generators/interactor/context/rspec_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/active_interactor/base' 4 | 5 | module Interactor 6 | module Context 7 | module Generators 8 | class RspecGenerator < ActiveInteractor::Generators::NamedBase 9 | desc 'Generate an interactor context spec' 10 | 11 | def create_spec 12 | template 'context_spec.erb', active_interactor_file(parent_dir: 'spec', suffix: 'context_spec') 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/rails/generators/interactor/context/test_unit_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/active_interactor/base' 4 | 5 | module Interactor 6 | module Context 7 | module Generators 8 | class TestUnitGenerator < ActiveInteractor::Generators::NamedBase 9 | desc 'Generate an interactor context unit test' 10 | 11 | def create_spec 12 | template 'context_test_unit.erb', active_interactor_file(parent_dir: 'test', suffix: 'context_test') 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/rails/generators/active_interactor/generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | module Generators 5 | module Generator 6 | def source_root 7 | File.expand_path('../templates', __dir__) 8 | end 9 | 10 | private 11 | 12 | def active_interactor_directory 13 | active_interactor_options.fetch(:dir, 'interactors') 14 | end 15 | 16 | def active_interactor_options 17 | ::Rails.application.config.generators.options.fetch(:active_interactor, {}) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/rails/generators/templates/active_interactor.erb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_interactor' 4 | 5 | ActiveInteractor.configure do |config| 6 | # ORM configuration 7 | # Load and configure the ORM. Supports :active_record (default) 8 | # Other ORMs may be available as additional gems. 9 | <%- if options[:orm] -%> 10 | require 'active_interactor/rails/orm/<%= options[:orm] %>' 11 | <%- else -%> 12 | # require 'active_interactor/rails/orm/' 13 | <%- end -%> 14 | 15 | # Set the ActiveInteractor.logger 16 | config.logger = ::Rails.logger 17 | end 18 | -------------------------------------------------------------------------------- /lib/active_interactor/rails/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails' 4 | 5 | module ActiveInteractor 6 | # Rails classes and modules. 7 | # 8 | # @author Aaron Allen 9 | # @since 1.0.0 10 | module Rails 11 | # The ActiveInteractor Railtie 12 | # 13 | # @author Aaron Allen 14 | # @since 1.0.0 15 | # 16 | # @see https://api.rubyonrails.org/classes/Rails/Railtie.html 17 | class Railtie < ::Rails::Railtie 18 | config.eager_load_namespaces << ActiveInteractor 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/rails/generators/active_interactor/application_context_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/active_interactor/base' 4 | 5 | module ActiveInteractor 6 | module Generators 7 | class ApplicationContextGenerator < Base 8 | def create_application_organizer 9 | return if File.exist?(file_path) 10 | 11 | template 'application_context.rb', file_path 12 | end 13 | 14 | private 15 | 16 | def file_path 17 | "app/#{active_interactor_directory}/application_context.rb" 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/active_interactor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActiveInteractor do 6 | describe '.config' do 7 | subject { described_class.config } 8 | 9 | it { is_expected.to be_a ActiveInteractor::Config } 10 | end 11 | 12 | describe '.configure' do 13 | it 'is expected to yield config' do 14 | expect { |b| described_class.configure(&b) }.to yield_control 15 | end 16 | end 17 | 18 | describe '.logger' do 19 | subject { described_class.logger } 20 | 21 | it { is_expected.to be_a Logger } 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/rails/generators/active_interactor/application_organizer_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/active_interactor/base' 4 | 5 | module ActiveInteractor 6 | module Generators 7 | class ApplicationOrganizerGenerator < Base 8 | def create_application_organizer 9 | return if File.exist?(file_path) 10 | 11 | template 'application_organizer.rb', file_path 12 | end 13 | 14 | private 15 | 16 | def file_path 17 | "app/#{active_interactor_directory}/application_organizer.rb" 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/rails/generators/active_interactor/application_interactor_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/active_interactor/base' 4 | 5 | module ActiveInteractor 6 | module Generators 7 | class ApplicationInteractorGenerator < Base 8 | def create_application_interactor 9 | return if File.exist?(file_path) 10 | 11 | template 'application_interactor.rb', file_path 12 | end 13 | 14 | private 15 | 16 | def file_path 17 | "app/#{active_interactor_directory}/application_interactor.rb" 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /.github/workflows/build_gem.yml: -------------------------------------------------------------------------------- 1 | name: Build Gem 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | build: 8 | name: Build Gem for Ruby ${{ matrix.ruby-version }} 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Setup Branch 12 | uses: actions/checkout@v4 13 | 14 | - name: Setup Ruby ${{ matrix.ruby-version }} 15 | uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: ${{ matrix.ruby-version }} 18 | bundler-cache: true 19 | 20 | - name: Build Gem 21 | run: bundle exec rake build 22 | strategy: 23 | matrix: 24 | ruby-version: 25 | - '2.7' 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - '*-alpha' 8 | - '*-beta' 9 | - '*-stable' 10 | - '*-dev' 11 | push: 12 | branches: 13 | - main 14 | - '*-alpha' 15 | - '*-beta' 16 | - '*-stable' 17 | - '*-dev' 18 | 19 | jobs: 20 | lint: 21 | uses: aaronmallen/activeinteractor/.github/workflows/lint.yml@main 22 | test: 23 | uses: aaronmallen/activeinteractor/.github/workflows/test.yml@main 24 | secrets: 25 | cc-test-reporter-id: ${{ secrets.CC_TEST_REPORTER_ID }} 26 | build: 27 | uses: aaronmallen/activeinteractor/.github/workflows/build_gem.yml@main 28 | -------------------------------------------------------------------------------- /lib/active_interactor/organizer/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | module Organizer 5 | # The base {Base organizer} class all {Base organizers} should inherit from. 6 | # 7 | # @author Aaron Allen 8 | # @since 1.0.0 9 | class Base < ActiveInteractor::Base 10 | extend ActiveInteractor::Organizer::Callbacks::ClassMethods 11 | extend ActiveInteractor::Organizer::Organize::ClassMethods 12 | extend ActiveInteractor::Organizer::Perform::ClassMethods 13 | 14 | include ActiveInteractor::Organizer::Callbacks 15 | include ActiveInteractor::Organizer::Perform 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | group :development, :test do 8 | gem 'code-scanning-rubocop' 9 | gem 'mdl' 10 | gem 'rails' 11 | gem 'rake', '~> 13.0' 12 | gem 'rspec', '~> 3.11' 13 | gem 'rubocop', '0.92.0' 14 | gem 'rubocop-performance' 15 | gem 'rubocop-rspec' 16 | gem 'solargraph' 17 | end 18 | 19 | group :test do 20 | gem 'simplecov' 21 | gem 'simplecov-lcov' 22 | end 23 | 24 | group :doc do 25 | gem 'github-markup' 26 | gem 'redcarpet' 27 | gem 'yard' 28 | end 29 | 30 | # Specify your gem's dependencies in activeinteractor.gemspec 31 | gemspec 32 | -------------------------------------------------------------------------------- /HUMANS.md: -------------------------------------------------------------------------------- 1 | # ActiveInteractor brought to you by 2 | 3 | ## Team 4 | 5 | - [Aaron Allen](https://github.com/aaronmallen) 6 | 7 | ## Contributors 8 | 9 | Other contributors can be viewed [here](https://github.com/aaronmallen/activeinteractor/graphs/contributors) 10 | 11 | ## Thanks 12 | 13 | - Special thanks to [@collectiveidea](https://github.com/collectiveidea) for their amazing foundational work on 14 | the [interactor](https://github.com/collectiveidea/interactor) gem. 15 | - Special thanks to the [@rails](https://github.com/rails) team for their work on 16 | [ActiveModel](https://github.com/rails/rails/tree/master/activemodel) and 17 | [ActiveSupport](https://github.com/rails/rails/tree/master/activesupport) gems. 18 | -------------------------------------------------------------------------------- /lib/rails/generators/interactor/context_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/active_interactor/base' 4 | 5 | module Interactor 6 | module Generators 7 | class ContextGenerator < ActiveInteractor::Generators::NamedBase 8 | desc 'Generate an interactor context' 9 | argument :context_attributes, type: :array, default: [], banner: 'attribute attribute' 10 | 11 | def generate_application_context 12 | generate :'active_interactor:application_context' 13 | end 14 | 15 | def create_context 16 | template 'context.erb', active_interactor_file(suffix: 'context') 17 | end 18 | 19 | hook_for :test_framework, in: :'interactor:context' 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /.github/workflows/upload_sarif.yml: -------------------------------------------------------------------------------- 1 | name: Upload Sarif 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | jobs: 8 | rubocop: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Setup Branch 12 | uses: actions/checkout@v4 13 | 14 | - name: Set Up Ruby 15 | uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: '2.7' 18 | bundler-cache: true 19 | 20 | - name: Generate Sarif Output 21 | run: bundle exec rubocop --require code_scanning --display-cop-names --format CodeScanning::SarifFormatter -o rubocop.sarif 22 | 23 | - name: Upload Sarif Output 24 | uses: github/codeql-action/upload-sarif@v2 25 | with: 26 | sarif_file: rubocop.sarif 27 | 28 | 29 | -------------------------------------------------------------------------------- /lib/rails/generators/interactor/interactor_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/active_interactor/base' 4 | require 'rails/generators/interactor/generates_context' 5 | 6 | class InteractorGenerator < ActiveInteractor::Generators::NamedBase 7 | include Interactor::Generators::GeneratesContext::WithArguments 8 | desc 'Generate an interactor' 9 | 10 | def generate_application_interactor 11 | generate :'active_interactor:application_interactor' 12 | end 13 | 14 | def create_interactor 15 | template 'interactor.erb', active_interactor_file 16 | end 17 | 18 | def create_context 19 | return if skip_context? 20 | 21 | generate :'interactor:context', class_name, *context_attributes 22 | end 23 | 24 | hook_for :test_framework, in: :interactor 25 | end 26 | -------------------------------------------------------------------------------- /lib/rails/generators/active_interactor/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators' 4 | 5 | require 'rails/generators/active_interactor/generator' 6 | 7 | module ActiveInteractor 8 | module Generators 9 | class Base < ::Rails::Generators::Base 10 | extend Generator 11 | include Generator 12 | end 13 | 14 | class NamedBase < ::Rails::Generators::NamedBase 15 | extend Generator 16 | include Generator 17 | 18 | private 19 | 20 | def active_interactor_file(parent_dir: 'app', suffix: nil) 21 | File.join(parent_dir, active_interactor_directory, class_path, "#{file_name_with_suffix(suffix)}.rb") 22 | end 23 | 24 | def file_name_with_suffix(suffix) 25 | suffix ? "#{file_name}_#{suffix}" : file_name 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/support/shared_examples/a_class_with_interactor_methods_example.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'a class with interactor methods' do 4 | describe '.perform' do 5 | subject { interactor_class.perform } 6 | 7 | it 'is expected to receive #execute_perform on a new instance of ActiveInteractor::Interactor::Worker' do 8 | expect_any_instance_of(ActiveInteractor::Interactor::Worker).to receive(:execute_perform) 9 | subject 10 | end 11 | end 12 | 13 | describe '.perform!' do 14 | subject { interactor_class.perform! } 15 | 16 | it 'is expected to receive #execute_perform! on a new instance of ActiveInteractor::Interactor::Worker' do 17 | expect_any_instance_of(ActiveInteractor::Interactor::Worker).to receive(:execute_perform!) 18 | subject 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | require 'active_interactor/version' 6 | 7 | RSpec::Core::RakeTask.new(:rspec) 8 | 9 | begin 10 | require 'mdl' 11 | require 'rubocop/rake_task' 12 | require 'yard' 13 | 14 | RuboCop::RakeTask.new do |task| 15 | task.options << '-D' 16 | end 17 | 18 | task :mdl do 19 | puts 'Linting markdown documents...' 20 | MarkdownLint.run(Dir['*.md']) 21 | # MarkdownLint#run calls system exit regardless of status. 22 | rescue SystemExit # rubocop:disable Lint/SuppressedException 23 | end 24 | 25 | YARD::Rake::YardocTask.new(:doc) 26 | 27 | Rake::Task[:spec].clear.enhance %i[mdl rubocop rspec] 28 | task default: %i[spec doc build] 29 | rescue LoadError 30 | Rake::Task[:spec].clear.enhance %i[rspec] 31 | task default: %i[spec build] 32 | end 33 | -------------------------------------------------------------------------------- /lib/rails/generators/interactor/generates_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Interactor 4 | module Generators 5 | module GeneratesContext 6 | module WithArguments 7 | def self.included(base) 8 | base.class_eval do 9 | include GeneratesContext 10 | argument :context_attributes, type: :array, default: [], banner: 'attribute attribute' 11 | end 12 | end 13 | end 14 | 15 | def self.included(base) 16 | base.class_eval do 17 | class_option :skip_context, type: :boolean, desc: 'Whether or not to generate a context class' 18 | end 19 | end 20 | 21 | private 22 | 23 | def skip_context? 24 | options[:skip_context] == true || active_interactor_options.fetch(:generate_context, true) == false 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/security_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🔒 Security Report 3 | about: Report a security vulnerability. 4 | title: '[Security Report]' 5 | labels: unverified, security 6 | assignees: 'aaronmallen' 7 | --- 8 | 9 | 10 | 11 | 12 | #### Affected Versions 13 | 14 | 15 | 16 | - ... 17 | 18 | #### Impact 19 | 20 | 21 | 22 | #### Patches 23 | 24 | 25 | 26 | #### Workarounds 27 | 28 | 29 | 30 | #### References 31 | 32 | 33 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: github-actions 9 | directory: / 10 | schedule: 11 | interval: weekly 12 | day: monday 13 | labels: 14 | - dependabot 15 | - github-actions 16 | - dependencies 17 | reviewers: 18 | - aaronmallen 19 | - package-ecosystem: bundler 20 | directory: / 21 | schedule: 22 | interval: weekly 23 | day: monday 24 | labels: 25 | - dependabot 26 | - bundler 27 | - dependencies 28 | reviewers: 29 | - aaronmallen 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐞 Bug Report 3 | about: Report a bug to help us improve. 4 | title: '[Bug Report]' 5 | labels: bug, unverified 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | 12 | #### Current Behavior 13 | 14 | 15 | #### Expected Behavior 16 | 17 | 18 | #### Steps to Reproduce 19 | 20 | 21 | 1. 22 | 23 | 24 | #### Additional Comments 25 | 26 | 27 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'support/coverage' 4 | ActiveInteractor::Spec::Coverage.start 5 | 6 | require 'bundler/setup' 7 | require 'active_interactor' 8 | 9 | RSpec.configure do |config| 10 | # Enable flags like --only-failures and --next-failure 11 | config.example_status_persistence_file_path = 'spec/.rspec_status' 12 | 13 | config.before do 14 | # suppress logs in test 15 | allow(ActiveInteractor.logger).to receive(:error).and_return(true) 16 | end 17 | 18 | config.after do 19 | clean_factories! 20 | end 21 | 22 | # Disable RSpec exposing methods globally on `Module` and `main` 23 | config.disable_monkey_patching! 24 | 25 | config.expect_with :rspec do |c| 26 | c.syntax = :expect 27 | end 28 | 29 | config.order = :random 30 | Kernel.srand config.seed 31 | end 32 | 33 | Dir[File.join(__dir__, 'support', '**', '*.rb')].sort.each { |f| require f } 34 | -------------------------------------------------------------------------------- /spec/integration/an_interactor_with_after_perform_callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An interactor with .after_perform callbacks', type: :integration do 6 | let(:interactor_class) do 7 | build_interactor do 8 | after_perform :test_after_perform 9 | 10 | private 11 | 12 | def test_after_perform; end 13 | end 14 | end 15 | 16 | include_examples 'a class with interactor methods' 17 | include_examples 'a class with interactor callback methods' 18 | include_examples 'a class with interactor context methods' 19 | 20 | describe '.perform' do 21 | subject { interactor_class.perform } 22 | 23 | it { is_expected.to be_a interactor_class.context_class } 24 | it { is_expected.to be_successful } 25 | 26 | it 'is expected to receive #test_after_perform' do 27 | expect_any_instance_of(interactor_class).to receive(:test_after_perform) 28 | subject 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/integration/an_interactor_with_before_perform_callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An interactor with .before_perform callbacks', type: :integration do 6 | let(:interactor_class) do 7 | build_interactor do 8 | before_perform :test_before_perform 9 | 10 | private 11 | 12 | def test_before_perform; end 13 | end 14 | end 15 | 16 | include_examples 'a class with interactor methods' 17 | include_examples 'a class with interactor callback methods' 18 | include_examples 'a class with interactor context methods' 19 | 20 | describe '.perform' do 21 | subject { interactor_class.perform } 22 | 23 | it { is_expected.to be_a interactor_class.context_class } 24 | it { is_expected.to be_successful } 25 | 26 | it 'is expected to receive #test_before_perform' do 27 | expect_any_instance_of(interactor_class).to receive(:test_before_perform) 28 | subject 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/integration/an_interactor_with_after_rollback_callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An interactor with .after_rollback callbacks', type: :integration do 6 | let(:interactor_class) do 7 | build_interactor do 8 | after_rollback :test_after_rollback 9 | 10 | def perform 11 | context.fail! 12 | end 13 | 14 | private 15 | 16 | def test_after_rollback; end 17 | end 18 | end 19 | 20 | include_examples 'a class with interactor methods' 21 | include_examples 'a class with interactor callback methods' 22 | include_examples 'a class with interactor context methods' 23 | 24 | describe '.perform' do 25 | subject { interactor_class.perform } 26 | 27 | it { is_expected.to be_a interactor_class.context_class } 28 | 29 | it 'is expected to receive #test_after_rollback' do 30 | expect_any_instance_of(interactor_class).to receive(:test_after_rollback) 31 | subject 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | My PR does this awesome thing! 5 | 6 | ## Information 7 | 8 | - [ ] Contains Documentation 9 | - [ ] Contains Tests 10 | - [ ] Contains Breaking Changes 11 | 12 | ## Related Issues 13 | 14 | 15 | 16 | 17 | 18 | - ... 19 | 20 | ## TODO 21 | 22 | 23 | - [ ] ... 24 | 25 | ## Changelog 26 | 27 | 28 | 29 | 30 | 31 | 32 | ### Added 33 | 34 | - ... 35 | 36 | ### Changed 37 | 38 | - ... 39 | 40 | ### Deprecated 41 | 42 | - ... 43 | 44 | ### Removed 45 | 46 | - ... 47 | 48 | ### Fixed 49 | 50 | - ... 51 | 52 | ### Security 53 | 54 | - ... 55 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------------ | 7 | | 1.2.x | :beetle: :lock: | 8 | | 1.1.x | :beetle: :lock: | 9 | | 1.0.x | :lock: | 10 | | 0.1.x | :x: | 11 | 12 | - :rocket: - Currently addressing feature requests 13 | - :beetle: - Currently addressing bug reports 14 | - :lock: - Currently addressing security reports 15 | - :x: - No longer supported 16 | 17 | ## Reporting a Vulnerability 18 | 19 | To report a security vulnerability please create a security report on the [GitHub issue tracker][issues] tracker. 20 | Security issues will be triaged as soon as possible. We also welcome pull requests, please see our 21 | [contributing guidelines][contributing] for more information on how to contribute. 22 | 23 | [issues]: https://github.com/aaronmallen/activeinteractor/issues 24 | [contributing]: https://github.com/aaronmallen/activeinteractor/blob/main/CONTRIBUTING.md 25 | -------------------------------------------------------------------------------- /spec/integration/an_interactor_with_before_rollback_callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An interactor with .before_rollback callbacks', type: :integration do 6 | let(:interactor_class) do 7 | build_interactor do 8 | before_rollback :test_before_rollback 9 | 10 | def perform 11 | context.fail! 12 | end 13 | 14 | private 15 | 16 | def test_before_rollback; end 17 | end 18 | end 19 | 20 | include_examples 'a class with interactor methods' 21 | include_examples 'a class with interactor callback methods' 22 | include_examples 'a class with interactor context methods' 23 | 24 | describe '.perform' do 25 | subject { interactor_class.perform } 26 | 27 | it { is_expected.to be_a interactor_class.context_class } 28 | 29 | it 'is expected to receive #test_before_rollback' do 30 | expect_any_instance_of(interactor_class).to receive(:test_before_rollback) 31 | subject 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/integration/an_interactor_with_around_perform_callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An interactor with .around_perform callbacks', type: :integration do 6 | let(:interactor_class) do 7 | build_interactor do 8 | around_perform :test_around_perform 9 | 10 | def perform 11 | context.perform_step << 2 12 | end 13 | 14 | private 15 | 16 | def test_around_perform 17 | context.perform_step = [] 18 | context.perform_step << 1 19 | yield 20 | context.perform_step << 3 21 | end 22 | end 23 | end 24 | 25 | include_examples 'a class with interactor methods' 26 | include_examples 'a class with interactor callback methods' 27 | include_examples 'a class with interactor context methods' 28 | 29 | describe '.perform' do 30 | subject { interactor_class.perform } 31 | 32 | it { is_expected.to be_a interactor_class.context_class } 33 | it { is_expected.to have_attributes(perform_step: [1, 2, 3]) } 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/integration/an_interactor_with_deferred_after_callbacks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An interactor with deferred after callbacks', type: :integration do 6 | let!(:interactor_with_config) do 7 | build_interactor('InteractorWithConfig') do 8 | defer_after_callbacks_when_organized 9 | 10 | def perform 11 | context.test_field_1 = 'test' 12 | end 13 | end 14 | end 15 | 16 | let!(:interactor_without_config) do 17 | build_interactor('InteractorWithoutConfig') do 18 | 19 | def perform 20 | context.test_field_2 = 'test' 21 | end 22 | end 23 | end 24 | 25 | it 'is expected to have true for InteractorWithConfig#after_callbacks_deferred_when_organized' do 26 | expect(InteractorWithConfig).to have_attributes(after_callbacks_deferred_when_organized: true) 27 | end 28 | 29 | it 'is expected to have false for InteractorWithoutConfig#after_callbacks_deferred_when_organized' do 30 | expect(InteractorWithoutConfig).to have_attributes(after_callbacks_deferred_when_organized: false) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Aaron Allen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/integration/an_interactor_with_around_rollback_callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An interactor with .around_rollback callbacks', type: :integration do 6 | let(:interactor_class) do 7 | build_interactor do 8 | around_rollback :test_around_rollback 9 | 10 | def perform 11 | context.fail! 12 | end 13 | 14 | def rollback 15 | context.rollback_step << 2 16 | end 17 | 18 | private 19 | 20 | def test_around_rollback 21 | context.rollback_step = [] 22 | context.rollback_step << 1 23 | yield 24 | context.rollback_step << 3 25 | end 26 | end 27 | end 28 | 29 | include_examples 'a class with interactor methods' 30 | include_examples 'a class with interactor callback methods' 31 | include_examples 'a class with interactor context methods' 32 | 33 | describe '.perform' do 34 | subject { interactor_class.perform } 35 | 36 | it { is_expected.to be_a interactor_class.context_class } 37 | it { is_expected.to have_attributes(rollback_step: [1, 2, 3]) } 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/active_interactor/interactor/perform/options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActiveInteractor::Interactor::Perform::Options do 6 | subject { described_class.new } 7 | 8 | it { is_expected.to respond_to :skip_each_perform_callbacks } 9 | it { is_expected.to respond_to :skip_perform_callbacks } 10 | it { is_expected.to respond_to :skip_rollback } 11 | it { is_expected.to respond_to :skip_rollback_callbacks } 12 | it { is_expected.to respond_to :validate } 13 | it { is_expected.to respond_to :validate_on_calling } 14 | it { is_expected.to respond_to :validate_on_called } 15 | 16 | describe 'defaults' do 17 | it { is_expected.to have_attributes(skip_each_perform_callbacks: false) } 18 | it { is_expected.to have_attributes(skip_perform_callbacks: false) } 19 | it { is_expected.to have_attributes(skip_rollback: false) } 20 | it { is_expected.to have_attributes(skip_rollback_callbacks: false) } 21 | it { is_expected.to have_attributes(validate: true) } 22 | it { is_expected.to have_attributes(validate_on_calling: true) } 23 | it { is_expected.to have_attributes(validate_on_called: true) } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/integration/active_record_integration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | begin 5 | require 'active_interactor/rails' 6 | require 'active_interactor/rails/orm/active_record' 7 | 8 | RSpec.describe 'ActiveRecord integration', type: :integration do 9 | let!(:active_record_base_mock) { build_class('ActiveRecordBaseMock') } 10 | 11 | context 'after ActiveSupport.run_load_hooks has been received with :active_record' do 12 | before { ActiveSupport.run_load_hooks(:active_record, active_record_base_mock) } 13 | 14 | describe 'an ActiveRecord model class with .acts_as_context and attribute :foo' do 15 | let(:model_class) do 16 | build_class('ModelClass', active_record_base_mock) do 17 | include ActiveModel::Attributes 18 | include ActiveModel::Model 19 | acts_as_context 20 | attribute :foo 21 | end 22 | end 23 | 24 | include_examples 'A class that extends ActiveInteractor::Models' 25 | end 26 | end 27 | end 28 | rescue LoadError 29 | RSpec.describe 'ActiveRecord Integration', type: :integration do 30 | pending 'Rails not found skipping specs...' 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/integration/an_organizer_with_after_each_callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An organizer with .after_each callbacks', type: :integration do 6 | let!(:interactor1) { build_interactor('TestInteractor1') } 7 | let!(:interactor2) { build_interactor('TestInteractor2') } 8 | let(:interactor_class) do 9 | build_organizer do 10 | after_each_perform :test_after_each_perform 11 | organize TestInteractor1, TestInteractor2 12 | 13 | private 14 | 15 | def test_after_each_perform; end 16 | end 17 | end 18 | 19 | include_examples 'a class with interactor methods' 20 | include_examples 'a class with interactor callback methods' 21 | include_examples 'a class with interactor context methods' 22 | include_examples 'a class with organizer callback methods' 23 | 24 | describe '.perform' do 25 | subject { interactor_class.perform } 26 | 27 | it { is_expected.to be_a interactor_class.context_class } 28 | 29 | it 'is expected to receive #test_after_each_perform twice' do 30 | expect_any_instance_of(interactor_class).to receive(:test_after_each_perform) 31 | .exactly(:twice) 32 | subject 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/integration/an_organizer_with_before_each_callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An organizer with .before_each callbacks', type: :integration do 6 | let!(:interactor1) { build_interactor('TestInteractor1') } 7 | let!(:interactor2) { build_interactor('TestInteractor2') } 8 | let(:interactor_class) do 9 | build_organizer do 10 | before_each_perform :test_before_each_perform 11 | organize TestInteractor1, TestInteractor2 12 | 13 | private 14 | 15 | def test_before_each_perform; end 16 | end 17 | end 18 | 19 | include_examples 'a class with interactor methods' 20 | include_examples 'a class with interactor callback methods' 21 | include_examples 'a class with interactor context methods' 22 | include_examples 'a class with organizer callback methods' 23 | 24 | describe '.perform' do 25 | subject { interactor_class.perform } 26 | 27 | it { is_expected.to be_a interactor_class.context_class } 28 | 29 | it 'is expected to receive #test_before_each_perform twice' do 30 | expect_any_instance_of(interactor_class).to receive(:test_before_each_perform) 31 | .exactly(:twice) 32 | subject 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/integration/an_interactor_with_validations_on_calling_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An interactor with validations on :calling', type: :integration do 6 | let(:interactor_class) do 7 | build_interactor do 8 | context_validates :test_field, presence: true, on: :calling 9 | end 10 | end 11 | 12 | include_examples 'a class with interactor methods' 13 | include_examples 'a class with interactor callback methods' 14 | include_examples 'a class with interactor context methods' 15 | 16 | describe '.perform' do 17 | subject { interactor_class.perform(context_attributes) } 18 | 19 | context 'with :test_field "test"' do 20 | let(:context_attributes) { { test_field: 'test' } } 21 | 22 | it { is_expected.to be_an TestInteractor::Context } 23 | it { is_expected.to be_successful } 24 | end 25 | 26 | context 'with :test_field nil' do 27 | let(:context_attributes) { {} } 28 | 29 | it { is_expected.to be_an TestInteractor::Context } 30 | it { is_expected.to be_failure } 31 | 32 | it 'is expected to have errors on :some_field' do 33 | expect(subject.errors[:test_field]).not_to be_nil 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/active_interactor/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | # The base class all {Base interactors} should inherit from. 5 | # 6 | # When {Base} is loaded by your application an 7 | # {https://api.rubyonrails.org/classes/ActiveSupport/LazyLoadHooks.html ActiveSupport load hook} is called 8 | # with `:active_interactor` and {Base}. 9 | # 10 | # @author Aaron Allen 11 | # @since 0.1.0 12 | # 13 | # @example a basic {Base interactor} 14 | # class MyInteractor < ActiveInteractor::Base 15 | # def perform 16 | # # TODO: implement the perform method 17 | # end 18 | # 19 | # def rollback 20 | # # TODO: implement the rollback method 21 | # end 22 | # end 23 | class Base 24 | extend ActiveInteractor::Interactor::Callbacks::ClassMethods 25 | extend ActiveInteractor::Interactor::Context::ClassMethods 26 | extend ActiveInteractor::Interactor::Perform::ClassMethods 27 | 28 | include ActiveSupport::Callbacks 29 | include ActiveInteractor::Interactor::Callbacks 30 | include ActiveInteractor::Interactor::Context 31 | include ActiveInteractor::Interactor::Perform 32 | end 33 | 34 | ActiveSupport.run_load_hooks(:active_interactor, Base) 35 | end 36 | -------------------------------------------------------------------------------- /lib/rails/generators/interactor/organizer_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/active_interactor/base' 4 | require 'rails/generators/interactor/generates_context' 5 | 6 | module Interactor 7 | module Generators 8 | class OrganizerGenerator < ActiveInteractor::Generators::NamedBase 9 | include GeneratesContext 10 | desc 'Generate an interactor organizer' 11 | argument :interactors, type: :array, default: [], banner: 'interactor interactor' 12 | 13 | class_option :context_attributes, type: :array, default: [], banner: 'attribute attribute', 14 | desc: 'Attributes for the context' 15 | 16 | def generate_application_organizer 17 | generate :'active_interactor:application_organizer' 18 | end 19 | 20 | def create_organizer 21 | template 'organizer.erb', active_interactor_file 22 | end 23 | 24 | def create_context 25 | return if skip_context? 26 | 27 | generate :'interactor:context', class_name, *context_attributes 28 | end 29 | 30 | hook_for :test_framework, in: :interactor 31 | 32 | private 33 | 34 | def context_attributes 35 | options[:context_attributes] 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | checks: 3 | argument-count: 4 | config: 5 | threshold: 4 6 | complex-logic: 7 | config: 8 | threshold: 4 9 | file-lines: 10 | config: 11 | threshold: 250 12 | method-complexity: 13 | config: 14 | threshold: 5 15 | method-count: 16 | config: 17 | threshold: 20 18 | method-lines: 19 | config: 20 | threshold: 25 21 | nested-control-flow: 22 | config: 23 | threshold: 4 24 | return-statements: 25 | config: 26 | threshold: 4 27 | similar-code: 28 | config: 29 | threshold: # language-specific defaults. an override will affect all languages. 30 | identical-code: 31 | config: 32 | threshold: # language-specific defaults. an override will affect all languages. 33 | plugins: 34 | fixme: 35 | enabled: true 36 | git-legal: 37 | enabled: true 38 | markdownlint: 39 | enabled: true 40 | rubocop: 41 | enabled: true 42 | channel: rubocop-1-9-1 43 | config: 44 | file: .rubocop-codeclimate.yml 45 | exclude_patterns: 46 | - .github/PULL_REQUEST_TEMPLATE.md 47 | - .github/ISSUE_TEMPLATE/* 48 | - .mdlrc 49 | - .yardopts 50 | - '/bin/*' 51 | - '**/spec/' 52 | - '**/vendor/' 53 | - '/.*ignore/' 54 | - /Gemfile.*/ 55 | - '*.yml' 56 | -------------------------------------------------------------------------------- /spec/integration/a_failing_interactor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'A failing interactor', type: :integration do 6 | let(:interactor_class) do 7 | build_interactor do 8 | def perform 9 | context.fail! 10 | end 11 | end 12 | end 13 | 14 | include_examples 'a class with interactor methods' 15 | include_examples 'a class with interactor callback methods' 16 | include_examples 'a class with interactor context methods' 17 | 18 | describe '.context_class' do 19 | subject { interactor_class.context_class } 20 | 21 | it { is_expected.to eq TestInteractor::Context } 22 | it { is_expected.to be < ActiveInteractor::Context::Base } 23 | end 24 | 25 | describe '.perform' do 26 | subject { interactor_class.perform } 27 | 28 | it { expect { subject }.not_to raise_error } 29 | it { is_expected.to be_a interactor_class.context_class } 30 | it { is_expected.to be_failure } 31 | 32 | it 'is expected to receive #rollback' do 33 | expect_any_instance_of(interactor_class).to receive(:rollback) 34 | subject 35 | end 36 | end 37 | 38 | describe '.perform!' do 39 | subject { interactor_class.perform! } 40 | 41 | it { expect { subject }.to raise_error(ActiveInteractor::Error::ContextFailure) } 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/integration/an_organizer_with_around_each_callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An organizer with .around_each callbacks', type: :integration do 6 | let!(:interactor1) { build_interactor('TestInteractor1') } 7 | let!(:interactor2) { build_interactor('TestInteractor2') } 8 | let(:interactor_class) do 9 | build_organizer do 10 | around_each_perform :test_around_each_perform 11 | organize TestInteractor1, TestInteractor2 12 | 13 | private 14 | 15 | def find_index 16 | (context.around_each_step.last || 0) + 1 17 | end 18 | 19 | def test_around_each_perform 20 | context.around_each_step ||= [] 21 | context.around_each_step << find_index 22 | yield 23 | context.around_each_step << find_index 24 | end 25 | end 26 | end 27 | 28 | include_examples 'a class with interactor methods' 29 | include_examples 'a class with interactor callback methods' 30 | include_examples 'a class with interactor context methods' 31 | include_examples 'a class with organizer callback methods' 32 | 33 | describe '.perform' do 34 | subject { interactor_class.perform } 35 | 36 | it { is_expected.to be_a interactor_class.context_class } 37 | it { is_expected.to have_attributes(around_each_step: [1, 2, 3, 4]) } 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature Request 3 | about: Propose a change. 4 | title: '[Feature Request]' 5 | labels: feature 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | 12 | #### Elevator pitch, describe and sell us your feature request 13 | 14 | 15 | 16 | 17 | 18 | **As a** , 19 | **I want** 20 | **so that** 21 | 22 | #### Is your feature request related to a problem 23 | 24 | 25 | 26 | 27 | #### Have you considered any alternatives 28 | 29 | 30 | 31 | #### Additional Comments 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | .env 15 | 16 | # Ignore Byebug command history file. 17 | .byebug_history 18 | 19 | ## Specific to RubyMotion: 20 | .dat* 21 | .repl_history 22 | build/ 23 | *.bridgesupport 24 | build-iPhoneOS/ 25 | build-iPhoneSimulator/ 26 | 27 | ## Specific to RubyMotion (use of CocoaPods): 28 | # 29 | # We recommend against adding the Pods directory to your .gitignore. However 30 | # you should judge for yourself, the pros and cons are mentioned at: 31 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 32 | # 33 | # vendor/Pods/ 34 | 35 | ## Documentation cache and generated files: 36 | /.yardoc/ 37 | /_yardoc/ 38 | /doc/ 39 | /rdoc/ 40 | 41 | ## Environment normalization: 42 | /.bundle/ 43 | /vendor/bundle 44 | /lib/bundler/man/ 45 | 46 | # for a library or gem, you might want to ignore these files since the code is 47 | # intended to run in multiple environments; otherwise, check them in: 48 | Gemfile.lock 49 | .ruby-version 50 | .ruby-gemset 51 | 52 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 53 | .rvmrc 54 | 55 | # ignore rspec configuration files 56 | .rspec 57 | .rspec_status 58 | -------------------------------------------------------------------------------- /spec/active_interactor/error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActiveInteractor::Error::ContextFailure do 6 | subject { described_class.new } 7 | 8 | it { is_expected.to respond_to :context } 9 | 10 | context 'when context is equal to nil' do 11 | subject { described_class.new(nil) } 12 | 13 | it { is_expected.to have_attributes(message: 'Context failed!') } 14 | end 15 | 16 | context 'when context is an instance of "TestContext"' do 17 | subject { described_class.new(TestContext.new) } 18 | 19 | before { build_context } 20 | 21 | it { is_expected.to have_attributes(message: 'TestContext failed!') } 22 | 23 | it 'is expected to have an instance of TestContext' do 24 | expect(subject.context).to be_a TestContext 25 | end 26 | end 27 | end 28 | 29 | RSpec.describe ActiveInteractor::Error::InvalidContextClass do 30 | subject { described_class.new } 31 | 32 | it { is_expected.to respond_to :class_name } 33 | 34 | context 'when class_name is equal to nil' do 35 | subject { described_class.new(nil) } 36 | 37 | it { is_expected.to have_attributes(message: 'invalid context class ') } 38 | end 39 | 40 | context 'when class_name is equal to "MyContect"' do 41 | subject { described_class.new('MyContext') } 42 | 43 | it { is_expected.to have_attributes(message: 'invalid context class MyContext') } 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/integration/an_interactor_with_validations_on_called_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An interactor with validations on :called', type: :integration do 6 | let(:interactor_class) do 7 | build_interactor do 8 | context_validates :test_field, presence: true, on: :called 9 | 10 | def perform 11 | context.test_field = 'test' if context.should_have_test_field 12 | end 13 | end 14 | end 15 | 16 | include_examples 'a class with interactor methods' 17 | include_examples 'a class with interactor callback methods' 18 | include_examples 'a class with interactor context methods' 19 | 20 | describe '.perform' do 21 | subject { interactor_class.perform(context_attributes) } 22 | 23 | context 'with :should_have_test_field true' do 24 | let(:context_attributes) { { should_have_test_field: true } } 25 | 26 | it { is_expected.to be_an TestInteractor::Context } 27 | it { is_expected.to be_successful } 28 | end 29 | 30 | context 'with :should_have_test_field false' do 31 | let(:context_attributes) { { should_have_test_field: false } } 32 | 33 | it { is_expected.to be_an TestInteractor::Context } 34 | it { is_expected.to be_failure } 35 | 36 | it 'is expected to have errors on :some_field' do 37 | expect(subject.errors[:test_field]).not_to be_nil 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/support/coverage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | require 'simplecov-lcov' 5 | 6 | module ActiveInteractor 7 | module Spec 8 | class Coverage 9 | DEFAULT_COVERAGE_TYPE = 'unit' 10 | EXCLUDED_FILES_PATTERN = '/spec/' 11 | TRACKED_FILES_PATTERN = 'lib/**/*.rb' 12 | 13 | FORMATTERS = [ 14 | SimpleCov::Formatter::HTMLFormatter, 15 | SimpleCov::Formatter::LcovFormatter 16 | ].freeze 17 | 18 | class << self 19 | def start 20 | setup_lcov_formatter 21 | start_simplecov 22 | end 23 | 24 | private 25 | 26 | def simplecov_formatter 27 | @simplecov_formatter ||= SimpleCov::Formatter::MultiFormatter.new(FORMATTERS) 28 | end 29 | 30 | def setup_lcov_formatter 31 | coverage_type = ENV.fetch('COVERAGE_TYPE', DEFAULT_COVERAGE_TYPE) 32 | 33 | SimpleCov::Formatter::LcovFormatter.config do |config| 34 | config.report_with_single_file = true 35 | config.single_report_path = "coverage/#{coverage_type}_lcov.info" 36 | end 37 | end 38 | 39 | def start_simplecov 40 | SimpleCov.start do 41 | add_filter EXCLUDED_FILES_PATTERN 42 | enable_coverage :branch 43 | track_files TRACKED_FILES_PATTERN 44 | formatter simplecov_formatter 45 | end 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/support/shared_examples/a_class_with_organizer_callback_methods_example.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'a class with organizer callback methods' do 4 | describe '.after_each_perform' do 5 | subject { interactor_class.after_each_perform(*args) } 6 | 7 | let(:args) { :some_method } 8 | 9 | it 'is expected to receive #set_callback with :each_perform, :after, :some_method' do 10 | expect(interactor_class).to receive(:set_callback) 11 | .with(:each_perform, :after, :some_method) 12 | .and_return(true) 13 | subject 14 | end 15 | end 16 | 17 | describe '.around_each_perform' do 18 | subject { interactor_class.around_each_perform(*args) } 19 | 20 | let(:args) { :some_method } 21 | 22 | it 'is expected to receive #set_callback with :each_perform, :around, :some_method' do 23 | expect(interactor_class).to receive(:set_callback) 24 | .with(:each_perform, :around, :some_method) 25 | .and_return(true) 26 | subject 27 | end 28 | end 29 | 30 | describe '.before_each_perform' do 31 | subject { interactor_class.before_each_perform(*args) } 32 | 33 | let(:args) { :some_method } 34 | 35 | it 'is expected to receive #set_callback with :each_perform, :before, :some_method' do 36 | expect(interactor_class).to receive(:set_callback) 37 | .with(:each_perform, :before, :some_method) 38 | .and_return(true) 39 | subject 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/integration/an_organizer_performing_in_parallel_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An organizer performing in parallel', type: :integration do 6 | let!(:test_interactor_1) do 7 | build_interactor('TestInteractor1') do 8 | def perform 9 | context.test_field_1 = 'test 1' 10 | end 11 | end 12 | end 13 | 14 | let!(:test_interactor_2) do 15 | build_interactor('TestInteractor2') do 16 | def perform 17 | context.test_field_2 = 'test 2' 18 | end 19 | end 20 | end 21 | 22 | let(:interactor_class) do 23 | build_organizer do 24 | perform_in_parallel 25 | organize TestInteractor1, TestInteractor2 26 | end 27 | end 28 | 29 | include_examples 'a class with interactor methods' 30 | include_examples 'a class with interactor callback methods' 31 | include_examples 'a class with interactor context methods' 32 | include_examples 'a class with organizer callback methods' 33 | 34 | describe '.context_class' do 35 | subject { interactor_class.context_class } 36 | 37 | it { is_expected.to eq TestOrganizer::Context } 38 | it { is_expected.to be < ActiveInteractor::Context::Base } 39 | end 40 | 41 | describe '.perform' do 42 | subject { interactor_class.perform } 43 | 44 | it { is_expected.to be_a interactor_class.context_class } 45 | it { is_expected.to be_successful } 46 | it { is_expected.to have_attributes(test_field_1: 'test 1', test_field_2: 'test 2') } 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/active_interactor/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | # The ActiveInteractor version info 5 | # 6 | # @author Aaron Allen 7 | # @since unreleased 8 | module Version 9 | # The ActiveInterctor major version number 10 | # @return [Integer] The ActiveInteractor major version number 11 | MAJOR = 1 12 | 13 | # The ActiveInterctor minor version number 14 | # @return [Integer] The ActiveInteractor minor version number 15 | MINOR = 2 16 | 17 | # The ActiveInterctor patch version number 18 | # @return [Integer] The ActiveInteractor patch version number 19 | PATCH = 2 20 | 21 | # The ActiveInterctor pre-release version 22 | # @return [String | nil] The ActiveInteractor pre-release version 23 | PRE = nil 24 | 25 | # The ActiveInterctor meta version 26 | # @return [String | nil] The ActiveInteractor meta version 27 | META = nil 28 | 29 | # The ActiveInterctor rubygems version 30 | # @return [String] The ActiveInteractor rubygems version 31 | def self.gem_version 32 | pre_meta = PRE.nil? ? nil : [PRE, META].compact.join('.').freeze 33 | [MAJOR, MINOR, PATCH, pre_meta].compact.join('.').freeze 34 | end 35 | 36 | # The ActiveInterctor semver version 37 | # @return [String] The ActiveInteractor semver version 38 | def self.semver 39 | version = [MAJOR, MINOR, PATCH].join('.') 40 | version = "#{version}-#{PRE}" if PRE 41 | version = "#{version}+#{META}" if META 42 | version.freeze 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/active_interactor/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | # ActiveInteractor errors 5 | # 6 | # @author Aaron Allen 7 | # @since 0.1.5 8 | module Error 9 | # Raised when an {Base interactor} {Context::Base context} {Context::Status#fail! fails}. 10 | # 11 | # @!attribute [r] context 12 | # An instance of {Context::Base context} used for debugging. 13 | # 14 | # @return [Context::Base] an instance of {Context::Base} 15 | class ContextFailure < StandardError 16 | attr_reader :context 17 | 18 | # Initialize a new instance of {ContextFailure} 19 | # 20 | # @param context [Class, nil] an instance of {Context::Base context} 21 | # @return [ContextFailure] a new instance of {ContextFailure} 22 | def initialize(context = nil) 23 | @context = context 24 | context_class_name = context&.class&.name || 'Context' 25 | super("#{context_class_name} failed!") 26 | end 27 | end 28 | 29 | # Raised when an invalid {Context::Base context} class is assigned to an {Base interactor}. 30 | # 31 | # @since 1.0.0 32 | # 33 | # @!attribute [r] class_name 34 | # The class name of the {Context::Base context} used for debugging. 35 | # 36 | # @return [String|nil] the class name of the {Context::Base context} 37 | class InvalidContextClass < StandardError 38 | attr_reader :class_name 39 | 40 | def initialize(class_name = nil) 41 | @class_name = class_name 42 | super("invalid context class #{class_name}") 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/support/helpers/factories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/string/inflections' 4 | 5 | module Spec 6 | module Helpers 7 | module FactoryCollection 8 | def self.factories 9 | @factories ||= [] 10 | end 11 | end 12 | 13 | module Factories 14 | def build_class(class_name, parent_class = nil, &block) 15 | Object.send(:remove_const, class_name.to_sym) if Object.const_defined?(class_name) 16 | klass = create_class(class_name, parent_class) 17 | klass.class_eval(&block) if block 18 | FactoryCollection.factories << klass.name.to_sym 19 | klass 20 | end 21 | 22 | def build_context(class_name = 'TestContext', &block) 23 | build_class(class_name, ActiveInteractor::Context::Base, &block) 24 | end 25 | 26 | def build_interactor(class_name = 'TestInteractor', &block) 27 | build_class(class_name, ActiveInteractor::Base, &block) 28 | end 29 | 30 | def build_organizer(class_name = 'TestOrganizer', &block) 31 | build_class(class_name, ActiveInteractor::Organizer::Base, &block) 32 | end 33 | 34 | def clean_factories! 35 | FactoryCollection.factories.each do |factory| 36 | Object.send(:remove_const, factory) if Object.const_defined?(factory) 37 | end 38 | end 39 | 40 | private 41 | 42 | def create_class(class_name, parent_class = nil) 43 | return Object.const_set(class_name, Class.new) unless parent_class 44 | 45 | Object.const_set(class_name, Class.new(parent_class)) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/active_interactor/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | # The ActiveInteractor configuration object 5 | # 6 | # @author Aaron Allen 7 | # @since 1.0.0 8 | # 9 | # @!attribute [rw] logger 10 | # The logger instance to use for logging. 11 | # 12 | # @since 1.0.0 13 | # 14 | # @return [Class] an instance of Logger. 15 | # 16 | # @!method initialize(options = {}) 17 | # Initialize a new instance of {Config} 18 | # 19 | # @since 1.0.0 20 | # 21 | # @param options [Hash{Symbol=>*}] the attributes to assign to {Config} 22 | # @option options [Class] :logger (Logger.new(STDOUT)) the {Config#logger} attribute 23 | # @return [Config] a new instance of {Config} 24 | class Config 25 | include ActiveInteractor::Configurable 26 | defaults logger: Logger.new($stdout) 27 | end 28 | 29 | # The ActiveInteractor configuration 30 | # 31 | # @since 0.1.0 32 | # 33 | # @return [Config] a {Config} instance 34 | def self.config 35 | @config ||= ActiveInteractor::Config.new 36 | end 37 | 38 | # Configure the ActiveInteractor gem 39 | # 40 | # @since 0.1.0 41 | # 42 | # @example Configure ActiveInteractor 43 | # require 'active_interactor' 44 | # ActiveInteractor.configure do |config| 45 | # config.logger = ::Rails.logger 46 | # end 47 | # @yield [#config] 48 | def self.configure 49 | yield config 50 | end 51 | 52 | # The logger instance to use for logging 53 | # 54 | # @since 0.1.0 55 | # 56 | # @return [Class] the {Config#logger #config#logger} instance 57 | def self.logger 58 | config.logger 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/active_interactor/context/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | module Context 5 | # Context error methods. Because {Errors} is a module classes should include {Errors} rather than 6 | # inherit from it. 7 | # 8 | # @api private 9 | # @author Aaron Allen 10 | # @since 1.0.3 11 | module Errors 12 | # Generic errors generated outside of validation. 13 | # 14 | # @return [ActiveModel::Errors] an instance of `ActiveModel::Errors` 15 | def failure_errors 16 | @failure_errors ||= ActiveModel::Errors.new(self) 17 | end 18 | 19 | private 20 | 21 | def add_errors(errors) 22 | errors.each do |error| 23 | if self.errors.respond_to?(:import) 24 | self.errors.import(error) 25 | else 26 | self.errors.add(error[0], error[1]) 27 | end 28 | end 29 | end 30 | 31 | def clear_all_errors 32 | errors.clear 33 | failure_errors.clear 34 | end 35 | 36 | def handle_errors(errors) 37 | if errors.is_a?(String) 38 | failure_errors.add(:context, errors) 39 | else 40 | failure_errors.merge!(errors) 41 | end 42 | end 43 | 44 | def merge_errors!(other) 45 | errors.merge!(other.errors) 46 | failure_errors.merge!(other.errors) 47 | failure_errors.merge!(other.failure_errors) 48 | end 49 | 50 | def resolve_errors 51 | all_errors = (failure_errors.uniq + errors.uniq).compact.uniq 52 | clear_all_errors 53 | add_errors(all_errors) 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/active_interactor/models.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | # Helper methods for using classes that do not inherit from {Context::Base} as {Context::Base context} objects 5 | # for {Base interactors}. Classes should extend {Models}. 6 | # 7 | # @author Aaron Allen 8 | # @since 1.0.0 9 | module Models 10 | # Instance methods needed for a {Context::Base context} class to function properly. 11 | # 12 | # @private 13 | # @author Aaron Allen 14 | # @since 1.0.0 15 | module InstanceMethods 16 | def initialize(*) 17 | @attributes = self.class._default_attributes&.deep_dup 18 | super 19 | end 20 | end 21 | 22 | # Include methods needed for a {Context::Base context} class to function properly. 23 | # 24 | # @note You must include ActiveModel::Model and ActiveModel::Attributes or a similar implementation for 25 | # the object to function properly. 26 | # 27 | # @example 28 | # class User 29 | # include ActiveModel::Model 30 | # include ActiveModel::Attributes 31 | # extend ActiveInteractor::Models 32 | # acts_as_context 33 | # end 34 | # 35 | # class MyInteractor < ActiveInteractor::Base 36 | # contextualize_with :user 37 | # end 38 | def acts_as_context 39 | class_eval do 40 | extend ActiveInteractor::Context::Attributes::ClassMethods 41 | 42 | include ActiveInteractor::Context::Attributes 43 | include ActiveInteractor::Context::Errors 44 | include ActiveInteractor::Context::Status 45 | include ActiveInteractor::Models::InstanceMethods 46 | delegate :each_pair, to: :attributes 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | lint: 10 | uses: aaronmallen/activeinteractor/.github/workflows/lint.yml@main 11 | test: 12 | if: success() 13 | needs: 14 | - lint 15 | uses: aaronmallen/activeinteractor/.github/workflows/test.yml@main 16 | secrets: 17 | cc-test-reporter-id: ${{ secrets.CC_TEST_REPORTER_ID }} 18 | build: 19 | if: success() 20 | needs: 21 | - test 22 | uses: aaronmallen/activeinteractor/.github/workflows/build_gem.yml@main 23 | publish: 24 | name: Publish 25 | needs: 26 | - build 27 | if: success() 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: ruby/setup-ruby@v1 32 | with: 33 | ruby-version: '2.7' 34 | bundler-cache: true 35 | - name: Publish to RubyGems 36 | run: | 37 | mkdir -p $HOME/.gem 38 | touch $HOME/.gem/credentials 39 | chmod 0600 $HOME/.gem/credentials 40 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 41 | gem build *.gemspec 42 | gem push *.gem 43 | env: 44 | GEM_HOST_API_KEY: ${{secrets.RUBYGEMS_AUTH_TOKEN}} 45 | - name: Publish to GPR 46 | run: | 47 | mkdir -p $HOME/.gem 48 | touch $HOME/.gem/credentials 49 | chmod 0600 $HOME/.gem/credentials 50 | printf -- "---\n:github: Bearer ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 51 | gem build *.gemspec 52 | gem push --KEY github --host https://rubygems.pkg.github.com/${OWNER} *.gem 53 | env: 54 | GEM_HOST_API_KEY: ${{secrets.GPR_AUTH_TOKEN}} 55 | OWNER: aaronmallen 56 | -------------------------------------------------------------------------------- /lib/active_interactor/configurable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | # Configurable object methods. Because {Configurable} is a module classes should include {Configurable} rather than 5 | # inherit from it. 6 | # 7 | # @api private 8 | # @author Aaron Allen 9 | # @since 1.0.0 10 | module Configurable 11 | def self.included(base) 12 | base.class_eval do 13 | extend ClassMethods 14 | end 15 | end 16 | 17 | # Configurable object class methods. Because {ClassMethods} is a module classes should extend {ClassMethods} rather 18 | # than inherit from it. 19 | # 20 | # @api private 21 | # @author Aaron Allen 22 | # @since 1.0.0 23 | module ClassMethods 24 | # Get or Set the default attributes for a {Configurable} class. This method will create an `attr_accessor` on 25 | # the configurable class as well as set a default value for the attribute. 26 | # 27 | # @param options [Hash{Symbol=>*}, nil] the default options to set on the {Configurable} class 28 | # @return [Hash{Symbol=>*}] the passed options or the set defaults if no options are passed. 29 | def defaults(options = {}) 30 | return __defaults if options.empty? 31 | 32 | options.each do |key, value| 33 | __defaults[key.to_sym] = value 34 | send(:attr_accessor, key.to_sym) 35 | end 36 | end 37 | 38 | private 39 | 40 | def __defaults 41 | @__defaults ||= {} 42 | end 43 | end 44 | 45 | # nodoc # 46 | # @private 47 | def initialize(options = {}) 48 | self.class.defaults.merge(options).each do |key, value| 49 | instance_variable_set("@#{key}", value) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/integration/an_interactor_with_an_existing_context_class_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An interactor with an existing .context_class', type: :integration do 6 | context 'having .name "AnInteractor"' do 7 | let(:interactor_class) { build_interactor('AnInteractor') } 8 | 9 | include_examples 'a class with interactor methods' 10 | include_examples 'a class with interactor callback methods' 11 | include_examples 'a class with interactor context methods' 12 | 13 | context 'having a class named "AnInteractorContext" with validations' do 14 | let!(:context_class) do 15 | build_context('AnInteractorContext') do 16 | validates :test_field, presence: true 17 | end 18 | end 19 | 20 | describe '.context_class' do 21 | subject { interactor_class.context_class } 22 | 23 | it { is_expected.to eq AnInteractorContext } 24 | it { is_expected.to be < ActiveInteractor::Context::Base } 25 | end 26 | 27 | describe '.perform' do 28 | subject { interactor_class.perform(context_attributes) } 29 | 30 | context 'with valid context attributes' do 31 | let(:context_attributes) { { test_field: 'test' } } 32 | 33 | it { is_expected.to be_an AnInteractorContext } 34 | it { is_expected.to be_successful } 35 | end 36 | 37 | context 'with invalid context attributes' do 38 | let(:context_attributes) { {} } 39 | 40 | it { is_expected.to be_an AnInteractorContext } 41 | it { is_expected.to be_failure } 42 | 43 | it 'is expected to have errors on :some_field' do 44 | expect(subject.errors[:test_field]).not_to be_nil 45 | end 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /activeinteractor.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/active_interactor/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.platform = Gem::Platform::RUBY 7 | spec.name = 'activeinteractor' 8 | spec.version = ActiveInteractor::Version.gem_version 9 | spec.summary = 'Ruby interactors with ActiveModel::Validations' 10 | spec.description = <<~DESC 11 | An implementation of the Command Pattern for Ruby with ActiveModel::Validations inspired by the interactor gem. 12 | Rich support for attributes, callbacks, and validations, and thread safe performance methods. 13 | DESC 14 | 15 | spec.required_ruby_version = '>= 2.5.0' 16 | 17 | spec.license = 'MIT' 18 | 19 | spec.authors = ['Aaron Allen'] 20 | spec.email = ['hello@aaronmallen.me'] 21 | spec.homepage = 'https://github.com/aaronmallen/activeinteractor' 22 | 23 | spec.files = Dir['.yardopts', 'CHANGELOG.md', 'LICENSE', 'README.md', 'lib/**/*'] 24 | spec.require_paths = ['lib'] 25 | 26 | spec.metadata = { 27 | 'bug_tracker_uri' => 'https://github.com/aaronmallen/activeinteractor/issues', 28 | 'changelog_uri' => 29 | "https://github.com/aaronmallen/activeinteractor/blob/v#{ActiveInteractor::Version.semver}/CHANGELOG.md", 30 | 'documentation_uri' => "https://www.rubydoc.info/gems/activeinteractor/#{ActiveInteractor::Version.gem_version}", 31 | 'hompage_uri' => spec.homepage, 32 | 'source_code_uri' => "https://github.com/aaronmallen/activeinteractor/tree/v#{ActiveInteractor::Version.semver}", 33 | 'wiki_uri' => 'https://github.com/aaronmallen/activeinteractor/wiki' 34 | } 35 | 36 | spec.add_dependency 'activemodel', '>= 4.2', '< 9' 37 | spec.add_dependency 'activesupport', '>= 4.2', '< 9' 38 | 39 | spec.add_development_dependency 'bundler', '~> 2.3' 40 | end 41 | -------------------------------------------------------------------------------- /spec/integration/an_organizer_with_options_callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An organizer with options callbacks', type: :integration do 6 | let!(:interactor1) do 7 | build_interactor('TestInteractor1') do 8 | def perform 9 | context.step = 3 if context.step == 2 10 | context.step = 6 if context.step == 5 11 | end 12 | end 13 | end 14 | 15 | let(:interactor_class) do 16 | build_organizer do 17 | organize do 18 | add TestInteractor1, before: :test_before_method, after: :test_after_method 19 | add TestInteractor1, before: lambda { 20 | context.step = 5 if context.step == 4 21 | }, after: lambda { 22 | context.step = 7 if context.step == 6 23 | } 24 | end 25 | 26 | private 27 | 28 | def test_before_method 29 | context.step = 2 if context.step == 1 30 | end 31 | 32 | def test_after_method 33 | context.step = 4 if context.step == 3 34 | end 35 | end 36 | end 37 | 38 | include_examples 'a class with interactor methods' 39 | include_examples 'a class with interactor callback methods' 40 | include_examples 'a class with interactor context methods' 41 | include_examples 'a class with organizer callback methods' 42 | 43 | describe '.perform' do 44 | subject { interactor_class.perform(step: 1) } 45 | 46 | it { is_expected.to be_a interactor_class.context_class } 47 | 48 | it 'is expected to receive #test_before_method once' do 49 | expect_any_instance_of(interactor_class).to receive(:test_before_method) 50 | .exactly(:once) 51 | subject 52 | end 53 | 54 | it 'is expected to receive #test_after_method once' do 55 | expect_any_instance_of(interactor_class).to receive(:test_after_method) 56 | .exactly(:once) 57 | subject 58 | end 59 | 60 | it 'runs callbacks in sequence' do 61 | expect(subject.step).to eq(7) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/support/shared_examples/a_class_with_interactor_context_methods_example.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'a class with interactor context methods' do 4 | describe '.context_attributes' do 5 | subject { interactor_class.context_attributes(attributes) } 6 | 7 | let(:attributes) { :some_attribute } 8 | 9 | it 'is expected to receive #attributes on .context_class with :some_attribute' do 10 | expect(interactor_class.context_class).to receive(:attributes) 11 | .with(:some_attribute) 12 | .and_return(true) 13 | subject 14 | end 15 | end 16 | 17 | describe '#context_fail!' do 18 | subject { interactor_class.new.context_fail! } 19 | 20 | it 'is expected to receive #fail! on instance of .context_class' do 21 | expect_any_instance_of(interactor_class.context_class).to receive(:fail!) 22 | .and_return(true) 23 | subject 24 | end 25 | end 26 | 27 | describe '#context_rollback!' do 28 | subject { interactor_class.new.context_rollback! } 29 | 30 | it 'is expected to receive #rollback! on instance of .context_class' do 31 | expect_any_instance_of(interactor_class.context_class).to receive(:rollback!) 32 | .and_return(true) 33 | subject 34 | end 35 | end 36 | 37 | describe '#context_valid?' do 38 | subject { interactor_class.new.context_valid? } 39 | 40 | it 'is expected to receive #valid? on instance of .context_class' do 41 | expect_any_instance_of(interactor_class.context_class).to receive(:valid?) 42 | .and_return(true) 43 | subject 44 | end 45 | end 46 | 47 | describe '#finalize_context!' do 48 | subject { instance.finalize_context! } 49 | 50 | let(:instance) { interactor_class.new } 51 | 52 | it 'is expected to receive #called! on instance of .context_class' do 53 | expect_any_instance_of(interactor_class.context_class).to receive(:called!) 54 | .with(instance) 55 | subject 56 | end 57 | 58 | it { is_expected.to be_an interactor_class.context_class } 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/integration/an_organizer_with_failing_nested_organizer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An organizer with failing nested organizer', type: :integration do 6 | let!(:parent_interactor1) { build_interactor('TestParentInteractor1') } 7 | let!(:parent_interactor2) { build_interactor('TestParentInteractor2') } 8 | let!(:child_interactor1) { build_interactor('TestChildInteractor1') } 9 | let!(:child_interactor2) do 10 | build_interactor('TestChildInteractor2') do 11 | def perform 12 | context.fail! 13 | end 14 | end 15 | end 16 | 17 | let!(:child_interactor_class) do 18 | build_organizer('TestChildOrganizer') do 19 | organize TestChildInteractor1, TestChildInteractor2 20 | end 21 | end 22 | 23 | let(:parent_interactor_class) do 24 | build_organizer('TestParentOrganizer') do 25 | organize TestParentInteractor1, TestChildOrganizer, TestParentInteractor2 26 | end 27 | end 28 | 29 | describe '.perform' do 30 | subject { parent_interactor_class.perform } 31 | 32 | before do 33 | expect_any_instance_of(child_interactor_class).to receive(:perform).exactly(:once).and_call_original 34 | expect_any_instance_of(child_interactor1).to receive(:perform).exactly(:once).and_call_original 35 | expect_any_instance_of(child_interactor2).to receive(:perform).exactly(:once).and_call_original 36 | expect_any_instance_of(child_interactor2).to receive(:rollback).exactly(:once).and_call_original 37 | expect_any_instance_of(child_interactor1).to receive(:rollback).exactly(:once).and_call_original 38 | expect_any_instance_of(parent_interactor1).to receive(:rollback).exactly(:once).and_call_original 39 | expect_any_instance_of(parent_interactor2).not_to receive(:perform).exactly(:once).and_call_original 40 | expect_any_instance_of(parent_interactor2).not_to receive(:rollback).exactly(:once).and_call_original 41 | end 42 | 43 | it { is_expected.to be_a parent_interactor_class.context_class } 44 | 45 | it { is_expected.to be_failure } 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | cc-test-reporter-id: 7 | required: false 8 | 9 | jobs: 10 | test: 11 | env: 12 | CC_TEST_REPORTER_ID: ${{ secrets.cc-test-reporter-id }} 13 | name: Test ruby ${{ matrix.ruby-version }} 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Setup Branch 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Ruby ${{ matrix.ruby-version }} 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{ matrix.ruby-version }} 23 | bundler-cache: true 24 | 25 | - name: Skip Coverage 26 | if: env.CC_TEST_REPORTER_ID == null 27 | run: | 28 | echo "###########################" 29 | echo "Skipping Coverage Reporting" 30 | echo "###########################" 31 | 32 | - name: Setup Coverage Reporter 33 | env: 34 | GIT_BRANCH: ${{ github.ref }} 35 | if: env.CC_TEST_REPORTER_ID != null 36 | run: | 37 | curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 38 | chmod +x ./cc-test-reporter 39 | ./cc-test-reporter before-build 40 | 41 | - name: Run Unit Tests 42 | run: COVERAGE_TYPE=unit bundle exec rspec --tag \~type:integration --format documentation --order rand 43 | 44 | - name: Run Integration Tests 45 | run: COVERAGE_TYPE=integration bundle exec rspec --tag type:integration --format documentation --order rand 46 | 47 | - name: Report Coverage 48 | if: success() && env.CC_TEST_REPORTER_ID != null 49 | run: | 50 | ./cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.unit.json coverage/unit_lcov.info 51 | ./cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.integration.json coverage/integration_lcov.info 52 | ./cc-test-reporter sum-coverage coverage/codeclimate.*.json -p 2 53 | ./cc-test-reporter upload-coverage 54 | strategy: 55 | matrix: 56 | ruby-version: 57 | - '2.7' 58 | -------------------------------------------------------------------------------- /lib/active_interactor/context/loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | module Context 5 | # Find or create {Base context} classes for a given {ActiveInteractor::Base interactor} 6 | # 7 | # @api private 8 | # @author Aaron Allen 9 | # @since 1.0.0 10 | module Loader 11 | # ActiveInteractor base classes 12 | # @return [Array] 13 | BASE_CLASSES = [ActiveInteractor::Base, ActiveInteractor::Organizer::Base].freeze 14 | # The {ActiveInteractor::Context::Base} class 15 | # @return [Const] 16 | BASE_CONTEXT = ActiveInteractor::Context::Base 17 | 18 | # Create a {Base context} class for a given {ActiveInteractor::Base interactor} 19 | # 20 | # @param context_class_name [Symbol, String] the class name of the 21 | # {Base context} class to create 22 | # @param interactor_class [Const] an {ActiveInteractor::Base interactor} class 23 | # @return [Const] a class that inherits from {Base} 24 | def self.create(context_class_name, interactor_class) 25 | interactor_class.const_set(context_class_name.to_s.camelize, Class.new(BASE_CONTEXT)) 26 | end 27 | 28 | # Find or create a {Base context} class for a given {ActiveInteractor::Base interactor}. If a class exists 29 | # following the pattern of `InteractorNameContext` or `InteractorName::Context` then that class will be returned 30 | # otherwise a new class will be created with the pattern `InteractorName::Context`. 31 | # 32 | # @param interactor_class [Const] an {ActiveInteractor::Base interactor} class 33 | # @return [Const] a class that inherits from {Base} 34 | def self.find_or_create(interactor_class) 35 | return BASE_CONTEXT if BASE_CLASSES.include?(interactor_class) 36 | 37 | klass = possible_classes(interactor_class).first 38 | klass || create('Context', interactor_class) 39 | end 40 | 41 | def self.possible_classes(interactor_class) 42 | ["#{interactor_class.name}::Context", "#{interactor_class.name}Context"] 43 | .map(&:safe_constantize) 44 | .compact 45 | .reject { |klass| klass&.name&.include?('ActiveInteractor') } 46 | end 47 | private_class_method :possible_classes 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/integration/an_interactor_with_after_context_validation_callbacks_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An interactor with .after_context_validation callbacks', type: :integration do 6 | let(:interactor_class) do 7 | build_interactor do 8 | context_validates :test_field, presence: true 9 | after_context_validation :downcase_test_field 10 | 11 | private 12 | 13 | def downcase_test_field 14 | context.test_field = context.test_field.downcase 15 | end 16 | end 17 | end 18 | 19 | include_examples 'a class with interactor methods' 20 | include_examples 'a class with interactor callback methods' 21 | include_examples 'a class with interactor context methods' 22 | 23 | describe '.perform' do 24 | subject { interactor_class.perform(context_attributes) } 25 | 26 | context 'with valid context attributes' do 27 | let(:context_attributes) { { test_field: 'TEST' } } 28 | 29 | it { is_expected.to be_a interactor_class.context_class } 30 | it { is_expected.to be_successful } 31 | it { is_expected.to have_attributes(test_field: 'test') } 32 | end 33 | end 34 | 35 | context 'having a condition on #after_context_valdation' do 36 | let(:interactor_class) do 37 | build_interactor do 38 | context_validates :test_field, presence: true 39 | after_context_validation :downcase_test_field, if: -> { context.should_downcase } 40 | 41 | private 42 | 43 | def downcase_test_field 44 | context.test_field = context.test_field.downcase 45 | end 46 | end 47 | end 48 | 49 | describe '.perform' do 50 | subject { interactor_class.perform(context_attributes) } 51 | 52 | context 'with :test_field "TEST" and :should_downcase true' do 53 | let(:context_attributes) { { test_field: 'TEST', should_downcase: true } } 54 | 55 | it { is_expected.to be_a interactor_class.context_class } 56 | it { is_expected.to be_successful } 57 | it { is_expected.to have_attributes(test_field: 'test') } 58 | end 59 | 60 | context 'with :test_field "TEST" and :should_downcase false' do 61 | let(:context_attributes) { { test_field: 'TEST', should_downcase: false } } 62 | 63 | it { is_expected.to be_a interactor_class.context_class } 64 | it { is_expected.to be_successful } 65 | it { is_expected.to have_attributes(test_field: 'TEST') } 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/active_interactor/organizer/interactor_interface_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | module Organizer 5 | # A collection of {InteractorInterface} 6 | # 7 | # @api private 8 | # @author Aaron Allen 9 | # @since 1.0.0 10 | # 11 | # @!attribute [r] collection 12 | # An array of {InteractorInterface} 13 | # 14 | # @return [Array] the {InteractorInterface} collection 15 | class InteractorInterfaceCollection 16 | attr_reader :collection 17 | 18 | # @!method map(&block) 19 | # Invokes the given block once for each element of {#collection}. 20 | # @return [Array] a new array containing the values returned by the block. 21 | delegate :map, to: :collection 22 | 23 | # Initialize a new instance of {InteractorInterfaceCollection} 24 | # @return [InteractorInterfaceCollection] a new instance of {InteractorInterfaceCollection} 25 | def initialize 26 | @collection = [] 27 | end 28 | 29 | # Add an {InteractorInterface} to the {#collection} 30 | # 31 | # @param interactor_class [Const, Symbol, String] an {ActiveInteractor::Base interactor} class 32 | # @param filters [Hash{Symbol=> Proc, Symbol}] conditional options for the {ActiveInteractor::Base interactor} 33 | # class 34 | # @option filters [Proc, Symbol] :if only call the {ActiveInteractor::Base interactor} 35 | # {Interactor::Perform::ClassMethods#perform .perform} if `Proc` or `method` returns `true` 36 | # @option filters [Proc, Symbol] :unless only call the {ActiveInteractor::Base interactor} 37 | # {Interactor::Perform::ClassMethods#perform .perform} if `Proc` or `method` returns `false` or `nil` 38 | # @return [self] the {InteractorInterfaceCollection} instance 39 | def add(interactor_class, filters = {}) 40 | interface = ActiveInteractor::Organizer::InteractorInterface.new(interactor_class, filters) 41 | collection << interface if interface.interactor_class 42 | self 43 | end 44 | 45 | # Add multiple {InteractorInterface} to the {#collection} 46 | # 47 | # @param interactor_classes [Array] the {ActiveInteractor::Base interactor} classes 48 | # @return [self] the {InteractorInterfaceCollection} instance 49 | def concat(interactor_classes) 50 | interactor_classes.flatten.each { |interactor_class| add(interactor_class) } 51 | self 52 | end 53 | 54 | # Calls the given block once for each element in {#collection}, passing that element as a parameter. 55 | # @return [self] the {InteractorInterfaceCollection} instance 56 | def each(&block) 57 | collection.each(&block) if block 58 | self 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/active_interactor/organizer/interactor_interface_collection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActiveInteractor::Organizer::InteractorInterfaceCollection do 6 | describe '#add' do 7 | subject { instance.add(interactor) } 8 | 9 | let(:instance) { described_class.new } 10 | 11 | context 'with an interactor that does not exist' do 12 | let(:interactor) { :an_interactor_that_does_not_exist } 13 | 14 | it { expect { subject }.not_to(change { instance.collection.count }) } 15 | it { is_expected.to be_a described_class } 16 | end 17 | 18 | context 'with an existing interactor' do 19 | before { build_interactor } 20 | 21 | context 'when interactors are passed as contants' do 22 | let(:interactor) { TestInteractor } 23 | 24 | it { expect { subject }.to change { instance.collection.count }.by(1) } 25 | it { is_expected.to be_a described_class } 26 | 27 | it 'is expected to add the appropriate interactor' do 28 | subject 29 | expect(instance.collection.first.interactor_class).to eq TestInteractor 30 | end 31 | end 32 | 33 | context 'when interactors are passed as symbols' do 34 | let(:interactor) { :test_interactor } 35 | 36 | it { expect { subject }.to change { instance.collection.count }.by(1) } 37 | it { is_expected.to be_a described_class } 38 | 39 | it 'is expected to add the appropriate interactor' do 40 | subject 41 | expect(instance.collection.first.interactor_class).to eq TestInteractor 42 | end 43 | end 44 | 45 | context 'when interactors are passed as strings' do 46 | let(:interactor) { 'TestInteractor' } 47 | 48 | it { expect { subject }.to change { instance.collection.count }.by(1) } 49 | it { is_expected.to be_a described_class } 50 | 51 | it 'is expected to add the appropriate interactor' do 52 | subject 53 | expect(instance.collection.first.interactor_class).to eq TestInteractor 54 | end 55 | end 56 | end 57 | end 58 | 59 | describe '#concat' do 60 | subject { instance.concat(interactors) } 61 | 62 | let(:instance) { described_class.new } 63 | 64 | context 'with two existing interactors' do 65 | let!(:interactor1) { build_interactor('TestInteractor1') } 66 | let!(:interactor2) { build_interactor('TestInteractor2') } 67 | let(:interactors) { %i[test_interactor_1 test_interactor_2] } 68 | 69 | it { expect { subject }.to change { instance.collection.count }.by(2) } 70 | 71 | it 'is expected to add the appropriate interactors' do 72 | subject 73 | expect(instance.collection.first.interactor_class).to eq TestInteractor1 74 | expect(instance.collection.last.interactor_class).to eq TestInteractor2 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/active_interactor/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActiveInteractor::Base do 6 | let(:interactor_class) { described_class } 7 | 8 | include_examples 'a class with interactor methods' 9 | include_examples 'a class with interactor callback methods' 10 | include_examples 'a class with interactor context methods' 11 | 12 | describe '.contextualize_with' do 13 | subject { described_class.contextualize_with(klass) } 14 | 15 | context 'with an class that does not exist' do 16 | let(:klass) { 'SomeClassThatDoesNotExist' } 17 | 18 | it { expect { subject }.to raise_error(ActiveInteractor::Error::InvalidContextClass) } 19 | end 20 | 21 | context 'with context class TestContext' do 22 | before { build_context } 23 | 24 | context 'when passed as a string' do 25 | let(:klass) { 'TestContext' } 26 | 27 | it 'is expected to assign the appropriate context class' do 28 | subject 29 | expect(described_class.context_class).to eq TestContext 30 | end 31 | 32 | # https://github.com/aaronmallen/activeinteractor/issues/168 33 | context 'when singularized' do 34 | let!(:singularized_class) { build_context('PlaceData') } 35 | let(:klass) { 'PlaceData' } 36 | 37 | it 'is expected to assign the appropriate context class' do 38 | subject 39 | expect(described_class.context_class).to eq PlaceData 40 | end 41 | end 42 | end 43 | 44 | context 'when passed as a symbol' do 45 | let(:klass) { :test_context } 46 | 47 | it 'is expected to assign the appropriate context class' do 48 | subject 49 | expect(described_class.context_class).to eq TestContext 50 | end 51 | 52 | # https://github.com/aaronmallen/activeinteractor/issues/168 53 | context 'when singularized' do 54 | let!(:singularized_class) { build_context('PlaceData') } 55 | let(:klass) { :place_data } 56 | 57 | it 'is expected to assign the appropriate context class' do 58 | subject 59 | expect(described_class.context_class).to eq PlaceData 60 | end 61 | end 62 | end 63 | 64 | context 'when passed as a constant' do 65 | let(:klass) { TestContext } 66 | 67 | it 'is expected to assign the appropriate context class' do 68 | subject 69 | expect(described_class.context_class).to eq TestContext 70 | end 71 | 72 | # https://github.com/aaronmallen/activeinteractor/issues/168 73 | context 'when singularized' do 74 | let!(:singularized_class) { build_context('PlaceData') } 75 | let(:klass) { PlaceData } 76 | 77 | it 'is expected to assign the appropriate context class' do 78 | subject 79 | expect(described_class.context_class).to eq PlaceData 80 | end 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/support/shared_examples/a_class_that_extends_active_interactor_models_example.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'A class that extends ActiveInteractor::Models' do 4 | it { expect(model_class).to respond_to :attribute } 5 | it { expect(model_class).to respond_to :attributes } 6 | 7 | describe 'as an instance' do 8 | subject { model_class.new } 9 | 10 | it { is_expected.to respond_to :attributes } 11 | it { is_expected.to respond_to :called! } 12 | it { is_expected.to respond_to :fail! } 13 | it { is_expected.to respond_to :fail? } 14 | it { is_expected.to respond_to :failure? } 15 | it { is_expected.to respond_to :merge! } 16 | it { is_expected.to respond_to :rollback! } 17 | it { is_expected.to respond_to :success? } 18 | it { is_expected.to respond_to :successful? } 19 | end 20 | 21 | describe '.new' do 22 | subject { model_class.new(attributes) } 23 | 24 | describe 'with arguments {:foo => "foo"}' do 25 | let(:attributes) { { foo: 'foo' } } 26 | 27 | it { is_expected.to have_attributes(foo: 'foo') } 28 | end 29 | 30 | describe 'with arguments {:bar => "bar"}' do 31 | let(:attributes) { model_class.new(bar: 'bar') } 32 | 33 | it { expect { subject }.to raise_error(ActiveModel::UnknownAttributeError) } 34 | end 35 | 36 | describe 'with arguments <#ModelClass foo="foo">' do 37 | let(:other_instance) { model_class.new(foo: 'foo') } 38 | let(:attributes) { other_instance } 39 | 40 | it { is_expected.to have_attributes(foo: 'foo') } 41 | 42 | context 'with argument instance having @_failed eq to true' do 43 | before { other_instance.instance_variable_set('@_failed', true) } 44 | 45 | it { is_expected.to be_failure } 46 | end 47 | 48 | context 'with argument instance having @_called eq to ["foo"]' do 49 | before { other_instance.instance_variable_set('@_called', %w[foo]) } 50 | 51 | it 'is expected to have instance variable @_called eq to ["foo"]' do 52 | expect(subject.instance_variable_get('@_called')).to eq %w[foo] 53 | end 54 | end 55 | 56 | context 'with argument instance having @_rolled_back eq to true' do 57 | before { other_instance.instance_variable_set('@_rolled_back', true) } 58 | 59 | it 'is expected to have instance variable @_rolled_back eq to true' do 60 | expect(subject.instance_variable_get('@_rolled_back')).to eq true 61 | end 62 | end 63 | end 64 | 65 | describe 'with arguments <#OtherClass bar="bar">' do 66 | let(:attributes) { model_class.new(other_instance) } 67 | let!(:other_class) do 68 | build_class('OtherClass') do 69 | include ActiveModel::Attributes 70 | include ActiveModel::Model 71 | extend ActiveInteractor::Models 72 | acts_as_context 73 | attribute :bar 74 | end 75 | end 76 | let(:other_instance) { other_class.new(bar: 'bar') } 77 | 78 | it { expect { subject }.to raise_error(ActiveModel::UnknownAttributeError) } 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/active_interactor/organizer/organize.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | module Organizer 5 | # Organizer organize methods. Because {Organize} is a module classes should include {Organize} rather than inherit 6 | # from it. 7 | # 8 | # @author Aaron Allen 9 | # @since 1.0.0 10 | module Organize 11 | # Organizer organize class methods. Because {ClassMethods} is a module classes should extend {ClassMethods} 12 | # rather than inherit from it. 13 | # 14 | # @author Aaron Allen 15 | # @since 1.0.0 16 | module ClassMethods 17 | # {#organized Organize} {ActiveInteractor::Base interactors} to be called by the {Base organizer}. 18 | # A block will be evaluated on the {#organized InteractorInterfaceCollection} if a block is given. 19 | # 20 | # @since 0.1.0 21 | # 22 | # @example Basic organization of {ActiveInteractor::Base interactors} 23 | # class MyInteractor1 < ActiveInteractor::Base; end 24 | # class MyInteractor2 < ActiveInteractor::Base; end 25 | # class MyOrganizer < ActiveInteractor::Organizer::Base 26 | # organize :my_interactor_1, :my_interactor_2 27 | # end 28 | # 29 | # @example Conditional organization of {ActiveInteractor::Base interactors} 30 | # class MyInteractor1 < ActiveInteractor::Base; end 31 | # class MyInteractor2 < ActiveInteractor::Base; end 32 | # class MyOrganizer < ActiveInteractor::Organizer::Base 33 | # organize do 34 | # add :my_interactor_1 35 | # add :my_interactor_2, if: -> { context.valid? } 36 | # end 37 | # end 38 | # 39 | # @example organization of {ActiveInteractor::Base interactors} with {Interactor::Perform::Options options} 40 | # class MyInteractor1 < ActiveInteractor::Base; end 41 | # class MyInteractor2 < ActiveInteractor::Base; end 42 | # class MyOrganizer < ActiveInteractor::Organizer::Base 43 | # organize do 44 | # add :my_interactor_1, validate: false 45 | # add :my_interactor_2, skip_perform_callbacks: true 46 | # end 47 | # end 48 | # 49 | # @param interactors [Array, nil] the {ActiveInteractor::Base} interactor classes to be 50 | # {#organized organized} 51 | # @return [InteractorInterfaceCollection] the {#organized} {ActiveInteractor::Base interactors} 52 | def organize(*interactors, &block) 53 | organized.concat(interactors) if interactors 54 | organized.instance_eval(&block) if block 55 | organized 56 | end 57 | 58 | # An organized collection of {ActiveInteractor::Base interactors} 59 | # 60 | # @return [InteractorInterfaceCollection] an instance of {InteractorInterfaceCollection} 61 | def organized 62 | @organized ||= ActiveInteractor::Organizer::InteractorInterfaceCollection.new 63 | end 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/integration/an_interactor_with_validations_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An interactor with validations', type: :integration do 6 | let(:interactor_class) do 7 | build_interactor do 8 | context_validates :test_field, presence: true 9 | 10 | def perform 11 | context.other_field = context.test_field 12 | end 13 | 14 | def rollback 15 | context.other_field = 'failed' 16 | end 17 | end 18 | end 19 | 20 | include_examples 'a class with interactor methods' 21 | include_examples 'a class with interactor callback methods' 22 | include_examples 'a class with interactor context methods' 23 | 24 | describe '.perform' do 25 | subject { interactor_class.perform(context_attributes) } 26 | 27 | context 'with valid context attributes' do 28 | let(:context_attributes) { { test_field: 'test' } } 29 | 30 | it { is_expected.to be_a interactor_class.context_class } 31 | it { is_expected.to be_successful } 32 | it { is_expected.to have_attributes(test_field: 'test', other_field: 'test') } 33 | end 34 | 35 | context 'with invalid context attributes' do 36 | let(:context_attributes) { {} } 37 | 38 | it { is_expected.to be_a interactor_class.context_class } 39 | it { is_expected.to be_failure } 40 | it { is_expected.to have_attributes(other_field: 'failed') } 41 | 42 | it 'is expected to have errors on :test_field' do 43 | expect(subject.errors[:test_field]).not_to be_nil 44 | end 45 | end 46 | end 47 | 48 | context 'having conditional validations' do 49 | let(:interactor_class) do 50 | build_interactor do 51 | context_validates :test_field, presence: true, if: :test_condition 52 | 53 | def perform 54 | context.other_field = context.test_field 55 | end 56 | 57 | def rollback 58 | context.other_field = 'failed' 59 | end 60 | end 61 | end 62 | 63 | describe '.perform' do 64 | subject { interactor_class.perform(context_attributes) } 65 | 66 | context 'with :test_field defined and :test_condition false' do 67 | let(:context_attributes) { { test_field: 'test', test_condition: false } } 68 | 69 | it { is_expected.to be_a interactor_class.context_class } 70 | it { is_expected.to be_successful } 71 | it { is_expected.to have_attributes(test_field: 'test', test_condition: false, other_field: 'test') } 72 | end 73 | 74 | context 'with :test_field defined and :test_condition true' do 75 | let(:context_attributes) { { test_field: 'test', test_condition: true } } 76 | 77 | it { is_expected.to be_a interactor_class.context_class } 78 | it { is_expected.to be_successful } 79 | it { is_expected.to have_attributes(test_field: 'test', test_condition: true, other_field: 'test') } 80 | end 81 | 82 | context 'with :test_field undefined and :test_condition true' do 83 | let(:context_attributes) { { test_condition: true } } 84 | 85 | it { is_expected.to be_a interactor_class.context_class } 86 | it { is_expected.to be_failure } 87 | it { is_expected.to have_attributes(test_condition: true, other_field: 'failed') } 88 | 89 | it 'is expected to have errors on :test_field' do 90 | expect(subject.errors[:test_field]).not_to be_nil 91 | end 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 26 | * Trolling, insulting/derogatory comments, and personal or political attacks 27 | * Public or private harassment 28 | * Publishing others' private information, such as a physical or electronic 29 | address, without explicit permission 30 | * Other conduct which could reasonably be considered inappropriate in a 31 | professional setting 32 | 33 | ## Our Responsibilities 34 | 35 | Project maintainers are responsible for clarifying the standards of acceptable 36 | behavior and are expected to take appropriate and fair corrective action in 37 | response to any instances of unacceptable behavior. 38 | 39 | Project maintainers have the right and responsibility to remove, edit, or 40 | reject comments, commits, code, wiki edits, issues, and other contributions 41 | that are not aligned to this Code of Conduct, or to ban temporarily or 42 | permanently any contributor for other behaviors that they deem inappropriate, 43 | threatening, offensive, or harmful. 44 | 45 | ## Scope 46 | 47 | This Code of Conduct applies both within project spaces and in public spaces 48 | when an individual is representing the project or its community. Examples of 49 | representing a project or community include using an official project e-mail 50 | address, posting via an official social media account, or acting as an appointed 51 | representative at an online or offline event. Representation of a project may be 52 | further defined and clarified by project maintainers. 53 | 54 | ## Enforcement 55 | 56 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 57 | reported by contacting the project team at hello@aaronmallen.me. All 58 | complaints will be reviewed and investigated and will result in a response that 59 | is deemed necessary and appropriate to the circumstances. The project team is 60 | obligated to maintain confidentiality with regard to the reporter of an incident. 61 | Further details of specific enforcement policies may be posted separately. 62 | 63 | Project maintainers who do not follow or enforce the Code of Conduct in good 64 | faith may face temporary or permanent repercussions as determined by other 65 | members of the project's leadership. 66 | 67 | ## Attribution 68 | 69 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 70 | available at [http://contributor-covenant.org/version/1/4][version] 71 | 72 | [homepage]: http://contributor-covenant.org 73 | [version]: http://contributor-covenant.org/version/1/4/ 74 | -------------------------------------------------------------------------------- /spec/integration/an_organizer_with_all_perform_callbacks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An organizer with around all callbacks', type: :integration do 6 | let!(:test_interactor_1) do 7 | build_interactor('TestInteractor1') do 8 | before_perform do 9 | context.before_perform_1 = context.around_all_perform_start + 1 10 | end 11 | 12 | after_perform do 13 | context.after_perform_1 = context.around_perform_1_end + 1 14 | end 15 | 16 | around_perform :around 17 | def around 18 | context.around_perform_1_start = context.before_perform_1 + 1 19 | yield 20 | context.around_perform_1_end = context.perform_1 + 1 21 | end 22 | 23 | def perform 24 | context.perform_1 = context.around_perform_1_start + 1 25 | end 26 | end 27 | end 28 | 29 | let!(:test_interactor_2) do 30 | build_interactor('TestInteractor2') do 31 | defer_after_callbacks_when_organized 32 | 33 | before_perform do 34 | context.before_perform_2 = context.after_perform_1 + 1 35 | end 36 | 37 | after_perform do 38 | context.after_perform_2 = context.after_all_perform + 1 39 | end 40 | 41 | around_perform :around 42 | def around 43 | context.around_perform_2_start = context.before_perform_2 + 1 44 | yield 45 | context.around_perform_2_end = context.perform_2 + 1 46 | end 47 | 48 | def perform 49 | context.perform_2 = context.around_perform_2_start + 1 50 | end 51 | end 52 | end 53 | 54 | let(:interactor_class) do 55 | build_organizer do 56 | before_all_perform do 57 | context.before_all_perform = 1 58 | end 59 | 60 | after_all_perform do 61 | context.after_all_perform = context.around_all_perform_end + 1 62 | end 63 | 64 | around_all_perform :around_all 65 | def around_all 66 | context.around_all_perform_start = context.before_all_perform + 1 67 | yield 68 | context.around_all_perform_end = context.around_perform_2_end + 1 69 | end 70 | 71 | organize TestInteractor1, TestInteractor2 72 | end 73 | end 74 | 75 | include_examples 'a class with interactor methods' 76 | include_examples 'a class with interactor callback methods' 77 | include_examples 'a class with interactor context methods' 78 | include_examples 'a class with organizer callback methods' 79 | 80 | describe '.context_class' do 81 | subject { interactor_class.context_class } 82 | 83 | it { is_expected.to eq TestOrganizer::Context } 84 | it { is_expected.to be < ActiveInteractor::Context::Base } 85 | end 86 | 87 | describe '.perform' do 88 | subject { interactor_class.perform } 89 | 90 | it { is_expected.to be_a interactor_class.context_class } 91 | it { is_expected.to be_successful } 92 | it { is_expected.to have_attributes( 93 | before_all_perform: 1, 94 | around_all_perform_start: 2, 95 | 96 | before_perform_1: 3, 97 | around_perform_1_start: 4, 98 | perform_1: 5, 99 | around_perform_1_end: 6, 100 | after_perform_1: 7, 101 | 102 | before_perform_2: 8, 103 | around_perform_2_start: 9, 104 | perform_2: 10, 105 | around_perform_2_end: 11, 106 | 107 | around_all_perform_end: 12, 108 | after_all_perform: 13, 109 | after_perform_2: 14, 110 | ) } 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /spec/support/shared_examples/a_class_with_interactor_callback_methods_example.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples 'a class with interactor callback methods' do 4 | describe '.after_context_validation' do 5 | subject { interactor_class.after_context_validation(*args) } 6 | 7 | let(:args) { :some_method } 8 | 9 | it 'is expected to receive #set_callback with :validation, :after, :some_method, { :prepend => true }' do 10 | expect(interactor_class).to receive(:set_callback) 11 | .with(:validation, :after, :some_method, { prepend: true }) 12 | .and_return(true) 13 | subject 14 | end 15 | end 16 | 17 | describe '.after_perform' do 18 | subject { interactor_class.after_perform(*args) } 19 | 20 | let(:args) { :some_method } 21 | 22 | it 'is expected to receive #set_callback with :perform, :after, :some_method' do 23 | expect(interactor_class).to receive(:set_callback) 24 | .with(:perform, :after, :some_method) 25 | .and_return(true) 26 | subject 27 | end 28 | end 29 | 30 | describe '.after_rollback' do 31 | subject { interactor_class.after_rollback(*args) } 32 | 33 | let(:args) { :some_method } 34 | 35 | it 'is expected to receive #set_callback with :rollback, :after, :some_method' do 36 | expect(interactor_class).to receive(:set_callback) 37 | .with(:rollback, :after, :some_method) 38 | .and_return(true) 39 | subject 40 | end 41 | end 42 | 43 | describe '.around_perform' do 44 | subject { interactor_class.around_perform(*args) } 45 | 46 | let(:args) { :some_method } 47 | 48 | it 'is expected to receive #set_callback with :perform, :around, :some_method' do 49 | expect(interactor_class).to receive(:set_callback) 50 | .with(:perform, :around, :some_method) 51 | .and_return(true) 52 | subject 53 | end 54 | end 55 | 56 | describe '.around_rollback' do 57 | subject { interactor_class.around_rollback(*args) } 58 | 59 | let(:args) { :some_method } 60 | 61 | it 'is expected to receive #set_callback with :rollback, :around, :some_method' do 62 | expect(interactor_class).to receive(:set_callback) 63 | .with(:rollback, :around, :some_method) 64 | .and_return(true) 65 | subject 66 | end 67 | end 68 | 69 | describe '.before_context_validation' do 70 | subject { interactor_class.before_context_validation(*args) } 71 | 72 | let(:args) { :some_method } 73 | 74 | it 'is expected to receive #set_callback with :validation, :before, :some_method' do 75 | expect(interactor_class).to receive(:set_callback) 76 | .with(:validation, :before, :some_method, {}) 77 | .and_return(true) 78 | subject 79 | end 80 | end 81 | 82 | describe '.before_perform' do 83 | subject { interactor_class.before_perform(*args) } 84 | 85 | let(:args) { :some_method } 86 | 87 | it 'is expected to receive #set_callback with :perform, :before, :some_method' do 88 | expect(interactor_class).to receive(:set_callback) 89 | .with(:perform, :before, :some_method) 90 | .and_return(true) 91 | subject 92 | end 93 | end 94 | 95 | describe '.before_rollback' do 96 | subject { interactor_class.before_rollback(*args) } 97 | 98 | let(:args) { :some_method } 99 | 100 | it 'is expected to receive #set_callback with :rollback, :before, :some_method' do 101 | expect(interactor_class).to receive(:set_callback) 102 | .with(:rollback, :before, :some_method) 103 | .and_return(true) 104 | subject 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/integration/an_organizer_containing_organizer_with_after_callbacks_deferred_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An organizer containing an organizer with after callbacks deferred', type: :integration do 6 | let!(:test_interactor_1) do 7 | build_interactor('TestInteractor1') do 8 | defer_after_callbacks_when_organized 9 | 10 | after_perform do 11 | context.after_perform_1a = context.after_perform_1b + 1 12 | end 13 | 14 | after_perform do 15 | context.after_perform_1b = context.after_perform_2 + 1 16 | end 17 | 18 | def perform 19 | context.perform_1 = 1 20 | end 21 | end 22 | end 23 | 24 | let!(:test_interactor_2) do 25 | build_interactor('TestInteractor2') do 26 | after_perform do 27 | context.after_perform_2 = context.perform_2 + 1 28 | end 29 | 30 | def perform 31 | context.perform_2 = context.after_perform_3a + 1 32 | end 33 | end 34 | end 35 | 36 | let!(:test_interactor_3) do 37 | build_interactor('TestInteractor3') do 38 | defer_after_callbacks_when_organized 39 | 40 | after_perform do 41 | context.after_perform_3a = context.after_perform_3b + 1 42 | end 43 | 44 | after_perform do 45 | context.after_perform_3b = context.after_perform_4a + 1 46 | end 47 | 48 | def perform 49 | context.perform_3 = context.perform_1 + 1 50 | end 51 | end 52 | end 53 | 54 | let!(:test_interactor_4) do 55 | build_interactor('TestInteractor4') do 56 | after_perform do 57 | context.after_perform_4a = context.after_perform_4b + 1 58 | end 59 | 60 | after_perform do 61 | context.after_perform_4b = context.perform_4 + 1 62 | end 63 | 64 | def perform 65 | context.perform_4 = context.perform_3 + 1 66 | end 67 | end 68 | end 69 | 70 | let!(:test_sub_organizer_5) do 71 | build_organizer('TestSubOrganizer5') do 72 | defer_after_callbacks_when_organized 73 | 74 | after_perform do 75 | context.after_perform_5a = context.after_perform_5b + 1 76 | end 77 | 78 | after_perform do 79 | context.after_perform_5b = context.after_perform_1a + 1 80 | end 81 | 82 | organize TestInteractor3, TestInteractor4 83 | end 84 | end 85 | 86 | let(:interactor_class) do 87 | build_organizer do 88 | organize TestInteractor1, TestSubOrganizer5, TestInteractor2 89 | end 90 | end 91 | 92 | include_examples 'a class with interactor methods' 93 | include_examples 'a class with interactor callback methods' 94 | include_examples 'a class with interactor context methods' 95 | include_examples 'a class with organizer callback methods' 96 | 97 | describe '.context_class' do 98 | subject { interactor_class.context_class } 99 | 100 | it { is_expected.to eq TestOrganizer::Context } 101 | it { is_expected.to be < ActiveInteractor::Context::Base } 102 | end 103 | 104 | describe '.perform' do 105 | subject { interactor_class.perform } 106 | 107 | it { is_expected.to be_a interactor_class.context_class } 108 | it { is_expected.to be_successful } 109 | it { is_expected.to have_attributes( 110 | perform_1: 1, 111 | perform_3: 2, 112 | perform_4: 3, 113 | after_perform_4b: 4, 114 | after_perform_4a: 5, 115 | after_perform_3b: 6, 116 | after_perform_3a: 7, 117 | perform_2: 8, 118 | after_perform_2: 9, 119 | after_perform_1b: 10, 120 | after_perform_1a: 11, 121 | after_perform_5b: 12, 122 | after_perform_5a: 13, 123 | ) } 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActiveInteractor 2 | 3 | >[!WARNING] 4 | > This gem is no longer maintained. Please consider using 5 | > [domainic-command](https://github.com/domainic/domainic/tree/main/domainic-command) as an alternative. 6 | 7 | [![Version](https://img.shields.io/gem/v/activeinteractor.svg?logo=ruby)](https://rubygems.org/gems/activeinteractor) 8 | [![Build Status](https://github.com/aaronmallen/activeinteractor/workflows/Build/badge.svg)](https://github.com/aaronmallen/activeinteractor/actions) 9 | [![Maintainability](https://api.codeclimate.com/v1/badges/2f1cb318f681a1eebb27/maintainability)](https://codeclimate.com/github/aaronmallen/activeinteractor/maintainability) 10 | [![Test Coverage](https://api.codeclimate.com/v1/badges/2f1cb318f681a1eebb27/test_coverage)](https://codeclimate.com/github/aaronmallen/activeinteractor/test_coverage) 11 | [![Yard Docs](http://img.shields.io/badge/yard-docs-blue.svg)](https://www.rubydoc.info/gems/activeinteractor) 12 | [![Inline docs](http://inch-ci.org/github/aaronmallen/activeinteractor.svg?branch=main)](http://inch-ci.org/github/aaronmallen/activeinteractor) 13 | [![License](https://img.shields.io/github/license/aaronmallen/activeinteractor.svg?maxAge=300)](https://github.com/aaronmallen/activeinteractor/blob/main/LICENSE) 14 | 15 | An implementation of the [command pattern] for Ruby with [ActiveModel::Validations] inspired by the 16 | [interactor][collective_idea_interactors] gem. Rich support for attributes, callbacks, and validations, 17 | and thread safe performance methods. 18 | 19 | Reduce controller bloat with procedural service objects. Checkout this [Medium article] for a crash 20 | course on how to use ActiveInteractors. Read the [wiki] for detailed usage information. 21 | 22 | ## Features 23 | 24 | * [Context validation][wiki_context_validation] 25 | * [Callbacks][wiki_callbacks] 26 | * Thread safe performance calls 27 | * Organize multiple interactors [conditionally][wiki_organizers_conditionally] or in [parallel][wiki_organizers_parallel] 28 | 29 | ## Documentation 30 | 31 | Be sure to read the [wiki] for detailed information on how to use ActiveInteractor. 32 | 33 | For technical documentation please see the gem's [ruby docs]. 34 | 35 | ## Install 36 | 37 | Add this line to your application's Gemfile: 38 | 39 | ```ruby 40 | gem 'activeinteractor', require: 'active_interactor' 41 | ``` 42 | 43 | Or install it yourself as: 44 | 45 | ```sh 46 | gem install activeinteractor 47 | ``` 48 | 49 | ## Contributing 50 | 51 | Read our guidelines for [Contributing](CONTRIBUTING.md). 52 | 53 | ## Acknowledgements 54 | 55 | ActiveInteractor is made possible by wonderful [humans]. 56 | 57 | ## License 58 | 59 | The gem is available as open source under the terms of the [MIT License][mit_license]. 60 | 61 | [ActiveModel::Validations]: https://api.rubyonrails.org/classes/ActiveModel/Validations.html 62 | [business_logic_wikipedia]: https://en.wikipedia.org/wiki/Business_logic 63 | [collective_idea_interactors]: https://github.com/collectiveidea/interactor 64 | [command pattern]: https://en.wikipedia.org/wiki/Command_pattern 65 | [humans]: https://github.com/aaronmallen/activeinteractor/tree/main/HUMANS.md 66 | [Medium article]: https://medium.com/@aaronmallen/activeinteractor-8557c0dc78db 67 | [mit_license]: https://opensource.org/licenses/MIT 68 | [ruby docs]: https://www.rubydoc.info/gems/activeinteractor 69 | [wiki]: https://github.com/aaronmallen/activeinteractor/wiki 70 | [wiki_callbacks]: https://github.com/aaronmallen/activeinteractor/wiki/Callbacks 71 | [wiki_context_validation]: https://github.com/aaronmallen/activeinteractor/wiki/Context#validating-the-context 72 | [wiki_organizers_conditionally]: https://github.com/aaronmallen/activeinteractor/wiki/Interactors#organizing-interactors-conditionally 73 | [wiki_organizers_parallel]: https://github.com/aaronmallen/activeinteractor/wiki/Interactors#running-interactors-in-parallel 74 | -------------------------------------------------------------------------------- /spec/active_interactor/version_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActiveInteractor::Version do 6 | describe '::MAJOR' do 7 | subject(:major) { described_class::MAJOR } 8 | 9 | it { is_expected.to be_a Integer } 10 | end 11 | 12 | describe '::MINOR' do 13 | subject(:minor) { described_class::MINOR } 14 | 15 | it { is_expected.to be_a Integer } 16 | end 17 | 18 | describe '::PATCH' do 19 | subject(:patch) { described_class::PATCH } 20 | 21 | it { is_expected.to be_a Integer } 22 | end 23 | 24 | describe '::PRE' do 25 | subject(:pre) { described_class::PRE } 26 | 27 | it 'is a String or Nil' do 28 | expect([String, NilClass]).to include pre.class 29 | end 30 | end 31 | 32 | describe '::META' do 33 | subject(:meta) { described_class::META } 34 | 35 | it 'is a String or Nil' do 36 | expect([String, NilClass]).to include meta.class 37 | end 38 | end 39 | 40 | describe '.gem_version' do 41 | subject(:gem_version) { described_class.gem_version } 42 | 43 | context 'when version is 1.0.0-beta.1+test' do 44 | before do 45 | stub_const('ActiveInteractor::Version::MAJOR', 1) 46 | stub_const('ActiveInteractor::Version::MINOR', 0) 47 | stub_const('ActiveInteractor::Version::PATCH', 0) 48 | stub_const('ActiveInteractor::Version::PRE', 'beta.1') 49 | stub_const('ActiveInteractor::Version::META', 'test') 50 | end 51 | 52 | it { is_expected.to eq '1.0.0.beta.1.test' } 53 | end 54 | 55 | context 'when version is 1.0.0+test' do 56 | before do 57 | stub_const('ActiveInteractor::Version::MAJOR', 1) 58 | stub_const('ActiveInteractor::Version::MINOR', 0) 59 | stub_const('ActiveInteractor::Version::PATCH', 0) 60 | stub_const('ActiveInteractor::Version::PRE', nil) 61 | stub_const('ActiveInteractor::Version::META', 'test') 62 | end 63 | 64 | it { is_expected.to eq '1.0.0' } 65 | end 66 | end 67 | 68 | describe '.semver' do 69 | subject(:semver) { described_class.semver } 70 | 71 | context 'when version is 1.0.0' do 72 | before do 73 | stub_const('ActiveInteractor::Version::MAJOR', 1) 74 | stub_const('ActiveInteractor::Version::MINOR', 0) 75 | stub_const('ActiveInteractor::Version::PATCH', 0) 76 | stub_const('ActiveInteractor::Version::PRE', nil) 77 | stub_const('ActiveInteractor::Version::META', nil) 78 | end 79 | 80 | it { is_expected.to eq '1.0.0' } 81 | end 82 | 83 | context 'when version is 1.0.0-beta.1' do 84 | before do 85 | stub_const('ActiveInteractor::Version::MAJOR', 1) 86 | stub_const('ActiveInteractor::Version::MINOR', 0) 87 | stub_const('ActiveInteractor::Version::PATCH', 0) 88 | stub_const('ActiveInteractor::Version::PRE', 'beta.1') 89 | stub_const('ActiveInteractor::Version::META', nil) 90 | end 91 | 92 | it { is_expected.to eq '1.0.0-beta.1' } 93 | end 94 | 95 | context 'when version is 1.0.0-beta.1+test' do 96 | before do 97 | stub_const('ActiveInteractor::Version::MAJOR', 1) 98 | stub_const('ActiveInteractor::Version::MINOR', 0) 99 | stub_const('ActiveInteractor::Version::PATCH', 0) 100 | stub_const('ActiveInteractor::Version::PRE', 'beta.1') 101 | stub_const('ActiveInteractor::Version::META', 'test') 102 | end 103 | 104 | it { is_expected.to eq '1.0.0-beta.1+test' } 105 | end 106 | 107 | context 'when version is 1.0.0+test' do 108 | before do 109 | stub_const('ActiveInteractor::Version::MAJOR', 1) 110 | stub_const('ActiveInteractor::Version::MINOR', 0) 111 | stub_const('ActiveInteractor::Version::PATCH', 0) 112 | stub_const('ActiveInteractor::Version::PRE', nil) 113 | stub_const('ActiveInteractor::Version::META', 'test') 114 | end 115 | 116 | it { is_expected.to eq '1.0.0+test' } 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We love pull requests from everyone. By participating in this project, you 4 | agree to abide by the our [code of conduct]. 5 | 6 | Here are some ways *you* can contribute: 7 | 8 | * by using alpha, beta, and prerelease versions 9 | * by reporting bugs 10 | * by suggesting new features 11 | * by writing or editing documentation 12 | * by writing specifications 13 | * by writing code ( **no patch is too small** : fix typos, add comments, clean up inconsistent whitespace ) 14 | * by refactoring code 15 | * by closing [issues] 16 | * by reviewing patches 17 | 18 | ## Submitting an Issue 19 | 20 | * We use the [GitHub issue tracker][issues] to track bugs and features. 21 | * Before submitting a bug report or feature request, check to make sure it hasn't 22 | already been submitted. 23 | * When submitting a bug report, please include a [Gist][] that includes a stack 24 | trace and any details that may be necessary to reproduce the bug, including 25 | your gem version, Ruby version, and operating system. Ideally, a bug report 26 | should include a pull request with failing specs. 27 | 28 | ## Cleaning up issues 29 | 30 | * Issues that have no response from the submitter will be closed after 30 days. 31 | * Issues will be closed once they're assumed to be fixed or answered. If the 32 | maintainer is wrong, it can be opened again. 33 | * If your issue is closed by mistake, please understand and explain the issue. 34 | We will happily reopen the issue. 35 | 36 | ## Submitting a Pull Request 37 | 38 | 1. [Fork][fork] the [official repository][repo]. 39 | 1. [Create a topic branch.][branch] 40 | 1. Implement your feature or bug fix. 41 | 1. Add, commit, and push your changes. 42 | 1. [Submit a pull request.][pr] 43 | 44 | ### Branches and Versions 45 | 46 | Each major/minor version of the project has a corresponding stable branch. For example version `1.1.1` is based off the 47 | `1-1-stable` branch, likewise version `1.2.0` will be based off the `1-2-stable` branch. If your pull request is to 48 | address an issue with a particular version your work should be based off the appropriate branch, and your pull request 49 | should be set to merge into that branch as well. There may be occasions where you will be asked to open a separate PR 50 | to apply your patch changes to the main branch or other version stable branches. 51 | 52 | ### Notes 53 | 54 | * Please add tests if you changed code. Contributions without tests won't be accepted. 55 | * If you don't know how to add tests, please put in a PR and leave a comment 56 | asking for help. We love helping! 57 | * Please don't update the Gem version. 58 | 59 | ## Setting Up 60 | 61 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. 62 | You can also run `bin/console` for an interactive prompt that will allow you to experiment. 63 | 64 | To install this gem onto your local machine, run `bundle exec rake install`. 65 | 66 | ## Running the test suite 67 | 68 | The default rake task will run the full test suite and lint: 69 | 70 | ```sh 71 | bundle exec rake 72 | ``` 73 | 74 | To run an individual rspec test, you can provide a path and line number: 75 | 76 | ```sh 77 | bundle exec rspec spec/path/to/spec.rb:123 78 | ``` 79 | 80 | ## Formatting and Style 81 | 82 | Our style guide is defined in [`.rubocop.yml`](https://github.com/aaronmallen/activeinteractor/blob/main/.rubocop.yml) 83 | 84 | To run the linter: 85 | 86 | ```sh 87 | bin/rubocop 88 | ``` 89 | 90 | To run the linter with auto correct: 91 | 92 | ```sh 93 | bin/rubocop -A 94 | ``` 95 | 96 | Inspired by [factory_bot] 97 | 98 | [code_of_conduct]: CODE_OF_CONDUCT.md 99 | [repo]: https://github.com/aaronmallen/activeinteractor/tree/main 100 | [issues]: https://github.com/aaronmallen/activeinteractor/issues 101 | [fork]: https://help.github.com/articles/fork-a-repo/ 102 | [branch]: https://help.github.com/articles/creating-and-deleting-branches-within-your-repository/ 103 | [pr]: https://help.github.com/articles/using-pull-requests/ 104 | [gist]: https://gist.github.com/ 105 | [factory_bot]: https://github.com/thoughtbot/factory_bot/blob/master/CONTRIBUTING.md 106 | -------------------------------------------------------------------------------- /lib/active_interactor/interactor/worker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | module Interactor 5 | # A worker class to call {Base interactor} {Interactor::Perform#perform #perform} and 6 | # {Interactor::Perform#rollback #rollback} methods in a thread safe manner. 7 | # 8 | # @api private 9 | # @author Aaron Allen 10 | # @since 0.1.0 11 | class Worker 12 | # Initialize a new instance of {Worker} 13 | # 14 | # @param interactor [Base] an {Base interactor} instance 15 | # @return [Worker] a new instance of {Worker} 16 | def initialize(interactor) 17 | @interactor = interactor.deep_dup 18 | end 19 | 20 | # Run the {Base interactor} instance's {Interactor::Perform#perform #perform} with callbacks and validation. 21 | # 22 | # @return [Class] a {ActiveInteractor::Context::Base context} instance 23 | def execute_perform 24 | execute_perform! 25 | rescue Error::ContextFailure => e 26 | ActiveInteractor.logger.error("ActiveInteractor: #{e}") 27 | context 28 | end 29 | 30 | # Run the {Base interactor} instance's {Interactor::Perform#perform #perform} with callbacks and validation 31 | # without rescuing {Error::ContextFailure}. 32 | # 33 | # @raise [Error::ContextFailure] if the {Base interactor} fails it's {ActiveInteractor::Context::Base context} 34 | # @return [Class] a {ActiveInteractor::Context::Base context} instance 35 | def execute_perform! 36 | execute_context! 37 | rescue StandardError => e 38 | handle_error(e) 39 | end 40 | 41 | # Run the {Base interactor} instance's {Interactor::Perform#rollback #rollback} with callbacks 42 | # 43 | # @return [Boolean] `true` if context was successfully rolled back 44 | def execute_rollback 45 | return if interactor.options.skip_rollback 46 | 47 | execute_interactor_rollback! 48 | end 49 | 50 | private 51 | 52 | attr_reader :context, :interactor 53 | 54 | def execute_context! 55 | if interactor.options.skip_perform_callbacks 56 | execute_context_with_validation_check! 57 | else 58 | execute_context_with_callbacks! 59 | end 60 | end 61 | 62 | def execute_context_with_callbacks! 63 | result = interactor.run_callbacks :perform do 64 | execute_context_with_validation_check! 65 | @context = interactor.finalize_context! 66 | end 67 | 68 | if context&.success? && interactor.respond_to?(:run_deferred_after_perform_callbacks_on_children) 69 | interactor.run_deferred_after_perform_callbacks_on_children 70 | end 71 | 72 | result 73 | end 74 | 75 | def execute_context_with_validation! 76 | validate_on_calling 77 | interactor.perform 78 | validate_on_called 79 | end 80 | 81 | def execute_context_with_validation_check! 82 | return interactor.perform unless interactor.options.validate 83 | 84 | execute_context_with_validation! 85 | end 86 | 87 | def execute_interactor_rollback! 88 | return interactor.context_rollback! if interactor.options.skip_rollback_callbacks 89 | 90 | interactor.run_callbacks :rollback do 91 | interactor.context_rollback! 92 | end 93 | end 94 | 95 | def handle_error(exception) 96 | @context = interactor.finalize_context! 97 | execute_rollback 98 | raise exception 99 | end 100 | 101 | def validate_context(validation_context = nil) 102 | interactor.run_callbacks :validation do 103 | interactor.context_valid?(validation_context) 104 | end 105 | end 106 | 107 | def validate_on_calling 108 | return unless interactor.options.validate_on_calling 109 | 110 | interactor.context_fail! unless validate_context(:calling) 111 | end 112 | 113 | def validate_on_called 114 | return unless interactor.options.validate_on_called 115 | 116 | interactor.context_fail! unless validate_context(:called) 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/active_interactor/context/status.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | module Context 5 | # Context status methods. Because {Status} is a module classes should include {Status} rather than inherit from it. 6 | # 7 | # @author Aaron Allen 8 | # @since 1.0.0 9 | module Status 10 | # Add an instance of {ActiveInteractor::Base interactor} to the list of {ActiveInteractor::Base interactors} 11 | # called on the {Base context}. This list is used when {#rollback!} is called on a {Base context} instance. 12 | # 13 | # @since 0.1.0 14 | # 15 | # @param interactor [Class] an {ActiveInteractor::Base interactor} instance 16 | # @return [Array] the list of called {ActiveInteractor::Base interactors} 17 | def called!(interactor) 18 | _called << interactor 19 | end 20 | 21 | # Fail the {Base context} instance. Failing an instance raises an error that may be rescued by the calling 22 | # {ActiveInteractor::Base interactor}. The instance is also flagged as having failed. 23 | # 24 | # @since 0.1.0 25 | # 26 | # @example Fail an interactor context 27 | # class MyInteractor < ActiveInteractor::Base 28 | # def perform 29 | # context.fail! 30 | # end 31 | # end 32 | # 33 | # MyInteractor.perform! 34 | # ActiveInteractor::Error::ContextFailure "<#MyInteractor::Context>" 35 | # 36 | # @param errors [ActiveModel::Errors, String] error messages for the failure 37 | # @see https://api.rubyonrails.org/classes/ActiveModel/Errors.html ActiveModel::Errors 38 | # @raise [Error::ContextFailure] 39 | def fail!(errors = nil) 40 | handle_errors(errors) if errors 41 | @_failed = true 42 | resolve 43 | raise ActiveInteractor::Error::ContextFailure, self 44 | end 45 | 46 | # Whether the {Base context} instance has {#fail! failed}. By default, a new instance is successful and only 47 | # changes when explicitly {#fail! failed}. 48 | # 49 | # @since 0.1.0 50 | # @note The {#failure?} method is the inverse of the {#success?} method 51 | # 52 | # @example Check if a context has failed 53 | # result = MyInteractor.perform 54 | # result.failure? 55 | # #=> false 56 | # 57 | # @return [Boolean] `false` by default or `true` if {#fail! failed}. 58 | def failure? 59 | @_failed || false 60 | end 61 | alias fail? failure? 62 | 63 | # Resolve an instance of {Base context}. Called when an interactor 64 | # is finished with it's context. 65 | # 66 | # @since 1.0.3 67 | # @return [self] the instance of {Base context} 68 | def resolve 69 | resolve_errors 70 | self 71 | end 72 | 73 | # {#rollback! Rollback} an instance of {Base context}. Any {ActiveInteractor::Base interactors} the instance has 74 | # been passed via the {#called!} method are asked to roll themselves back by invoking their 75 | # {Interactor::Perform#rollback #rollback} methods. The instance is also flagged as rolled back. 76 | # 77 | # @since 0.1.0 78 | # 79 | # @return [Boolean] `true` if {#rollback! rolled back} successfully or `false` if already 80 | # {#rollback! rolled back} 81 | def rollback! 82 | return false if @_rolled_back 83 | 84 | _called.reverse_each(&:rollback) 85 | @_rolled_back = true 86 | end 87 | 88 | # Whether the {Base context} instance is successful. By default, a new instance is successful and only changes 89 | # when explicitly {#fail! failed}. 90 | # 91 | # @since 0.1.0 92 | # @note The {#success?} method is the inverse of the {#failure?} method 93 | # 94 | # @example Check if a context has failed 95 | # result = MyInteractor.perform 96 | # result.success? 97 | # #=> true 98 | # 99 | # @return [Boolean] `true` by default or `false` if {#fail! failed} 100 | def success? 101 | !failure? 102 | end 103 | alias successful? success? 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/active_interactor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_model' 4 | require 'active_support/callbacks' 5 | require 'active_support/core_ext/array/extract_options' 6 | require 'active_support/core_ext/class/attribute' 7 | require 'active_support/core_ext/string/inflections' 8 | require 'active_support/dependencies/autoload' 9 | require 'logger' 10 | require 'ostruct' 11 | 12 | require 'active_interactor/configurable' 13 | require 'active_interactor/config' 14 | require 'active_interactor/version' 15 | 16 | # An {Base interactor} is a simple, single-purpose service object. {Base Interactors} can be used to reduce the 17 | # responsibility of your controllers, workers, and models and encapsulate your application's 18 | # {https://en.wikipedia.org/wiki/Business_logic business logic}. Each {Base interactor} represents one thing that your 19 | # application does. 20 | # 21 | # Each {Base interactor} has it's own immutable {Context::Base context} which contains everything the 22 | # {Base interactor} needs to do its work. When an {Base interactor} does its single purpose, it affects its given 23 | # {Context::Base context}. 24 | # 25 | # @see https://medium.com/@aaronmallen/activeinteractor-8557c0dc78db Basic Usage 26 | # @see https://github.com/aaronmallen/activeinteractor/wiki Advanced Usage 27 | # @see https://github.com/aaronmallen/activeinteractor Source Code 28 | # @see https://github.com/aaronmallen/activeinteractor/issues Issues 29 | # 30 | # ## License 31 | # 32 | # Copyright (c) 2019 Aaron Allen 33 | # 34 | # Permission is hereby granted, free of charge, to any person obtaining a copy 35 | # of this software and associated documentation files (the "Software"), to deal 36 | # in the Software without restriction, including without limitation the rights 37 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 38 | # copies of the Software, and to permit persons to whom the Software is 39 | # furnished to do so, subject to the following conditions: 40 | # 41 | # The above copyright notice and this permission notice shall be included in 42 | # all copies or substantial portions of the Software. 43 | # 44 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 45 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 46 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 47 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 48 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 49 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 50 | # THE SOFTWARE. 51 | module ActiveInteractor 52 | extend ActiveSupport::Autoload 53 | 54 | autoload :Base 55 | 56 | # {Context::Base Context} classes and modules 57 | # 58 | # @see https://github.com/aaronmallen/activeinteractor/wiki/Context Context 59 | # 60 | # @author Aaron Allen 61 | # @since 0.1.0 62 | module Context 63 | extend ActiveSupport::Autoload 64 | 65 | autoload :Attributes 66 | autoload :Base 67 | autoload :Errors 68 | autoload :Loader 69 | autoload :Status 70 | end 71 | 72 | # {Base Interactor} classes and modules 73 | # 74 | # @see https://github.com/aaronmallen/activeinteractor/wiki/Interactors Interactors 75 | # 76 | # @author Aaron Allen 77 | # @since 0.1.0 78 | module Interactor 79 | extend ActiveSupport::Autoload 80 | 81 | autoload :Callbacks 82 | autoload :Context 83 | autoload :Perform 84 | autoload :Worker 85 | end 86 | 87 | autoload :Models 88 | 89 | # {Organizer::Base Organizer} classes and modules 90 | # 91 | # @see https://github.com/aaronmallen/activeinteractor/wiki/Interactors#organizers Organizers 92 | # 93 | # @author Aaron Allen 94 | # @since 0.1.0 95 | module Organizer 96 | extend ActiveSupport::Autoload 97 | 98 | autoload :Base 99 | autoload :Callbacks 100 | autoload :InteractorInterface 101 | autoload :InteractorInterfaceCollection 102 | autoload :Organize 103 | autoload :Perform 104 | end 105 | 106 | eager_autoload do 107 | autoload :Error 108 | end 109 | end 110 | 111 | require 'active_interactor/rails' if defined?(::Rails) 112 | -------------------------------------------------------------------------------- /spec/integration/an_organizer_with_after_callbacks_deferred_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'An organizer with after callbacks deferred', type: :integration do 6 | let!(:test_interactor_1) do 7 | build_interactor('TestInteractor1') do 8 | defer_after_callbacks_when_organized 9 | 10 | after_perform do 11 | context.after_perform_1a = context.after_perform_1b + 1 12 | end 13 | 14 | after_perform do 15 | context.after_perform_1b = context.after_perform_3a + 1 16 | end 17 | 18 | def perform 19 | context.perform_1 = 1 20 | end 21 | end 22 | end 23 | 24 | let!(:test_interactor_2) do 25 | build_interactor('TestInteractor2') do 26 | defer_after_callbacks_when_organized 27 | 28 | after_perform do 29 | context.after_perform_2 = context.after_perform_1a + 1 30 | end 31 | 32 | def perform 33 | context.perform_2 = context.perform_1 + 1 34 | end 35 | end 36 | end 37 | 38 | let!(:test_interactor_3) do 39 | build_interactor('TestInteractor3') do 40 | after_perform do 41 | context.after_perform_3a = context.after_perform_3b + 1 42 | end 43 | 44 | after_perform do 45 | context.after_perform_3b = context.perform_3 + 1 46 | end 47 | 48 | def perform 49 | context.perform_3 = context.perform_2 + 1 50 | end 51 | end 52 | end 53 | 54 | let(:interactor_class) do 55 | build_organizer do 56 | organize TestInteractor1, TestInteractor2, TestInteractor3 57 | end 58 | end 59 | 60 | include_examples 'a class with interactor methods' 61 | include_examples 'a class with interactor callback methods' 62 | include_examples 'a class with interactor context methods' 63 | include_examples 'a class with organizer callback methods' 64 | 65 | describe '.context_class' do 66 | subject { interactor_class.context_class } 67 | 68 | it { is_expected.to eq TestOrganizer::Context } 69 | it { is_expected.to be < ActiveInteractor::Context::Base } 70 | end 71 | 72 | describe '.perform' do 73 | subject { interactor_class.perform } 74 | 75 | it { is_expected.to be_a interactor_class.context_class } 76 | it { is_expected.to be_successful } 77 | it { is_expected.to have_attributes( 78 | perform_1: 1, 79 | perform_2: 2, 80 | perform_3: 3, 81 | after_perform_3b: 4, 82 | after_perform_3a: 5, 83 | after_perform_1b: 6, 84 | after_perform_1a: 7, 85 | after_perform_2: 8, 86 | ) } 87 | 88 | context 'when last interactor fails' do 89 | let!(:failing_interactor) do 90 | build_interactor('FailingInteractor') do 91 | def perform 92 | context.fail! 93 | end 94 | end 95 | end 96 | 97 | let(:interactor_class) do 98 | build_organizer do 99 | organize TestInteractor1, TestInteractor2, FailingInteractor 100 | end 101 | end 102 | 103 | subject { interactor_class.perform} 104 | 105 | it { is_expected.to have_attributes( 106 | perform_1: 1, 107 | perform_2: 2, 108 | )} 109 | 110 | it { is_expected.to_not respond_to( 111 | :after_perform_1a, 112 | :after_perform_1b, 113 | )} 114 | end 115 | 116 | context 'when after_perform in first interactor fails' do 117 | let!(:failing_interactor) do 118 | build_interactor('FailingInteractor') do 119 | defer_after_callbacks_when_organized 120 | 121 | after_perform do 122 | context.fail! 123 | end 124 | 125 | def perform 126 | context.perform_1 = 1 127 | end 128 | end 129 | end 130 | 131 | let(:interactor_class) do 132 | build_organizer do 133 | organize FailingInteractor, TestInteractor2, TestInteractor3 134 | end 135 | end 136 | 137 | subject { interactor_class.perform} 138 | 139 | it { is_expected.to have_attributes( 140 | perform_1: 1, 141 | perform_2: 2, 142 | perform_3: 3, 143 | after_perform_3b: 4, 144 | after_perform_3a: 5, 145 | )} 146 | 147 | it { is_expected.to_not respond_to( 148 | :after_perform_1a, 149 | :after_perform_1b, 150 | :after_perform_2, 151 | )} 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/active_interactor/organizer/perform.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | module Organizer 5 | # Organizer perform methods. Because {Perform} is a module classes should include {Perform} rather than inherit 6 | # from it. 7 | # 8 | # @author Aaron Allen 9 | # @since 1.0.0 10 | module Perform 11 | # Organizer perform class methods. Because {ClassMethods} is a module classes should extend {ClassMethods} 12 | # rather than inherit from it. 13 | # 14 | # @author Aaron Allen 15 | # @since 1.0.0 16 | # 17 | # @!attribute [r] parallel 18 | # If `true` the {Base organizer} will call {Interactor::Perform#perform #perform} on its 19 | # {Organizer::Organize::ClassMethods#organize .organized} {ActiveInteractor::Base interactors} in parallel. 20 | # An {Base organizer} will have {.parallel} `false` by default. 21 | # 22 | # @!scope class 23 | # @since 1.0.0 24 | # 25 | # @return [Boolean] whether or not to call {Interactor::Perform#perform #perform} on its 26 | # {Organizer::Organize::ClassMethods#organize .organized} {ActiveInteractor::Base interactors} in parallel. 27 | module ClassMethods 28 | # Set {.parallel} to `true` 29 | # 30 | # @example a basic {Base organizer} set to perform in parallel 31 | # class MyOrganizer < ActiveInteractor::Organizer::Base 32 | # perform_in_parallel 33 | # end 34 | def perform_in_parallel 35 | self.parallel = true 36 | end 37 | end 38 | 39 | def self.included(base) 40 | base.class_eval do 41 | class_attribute :parallel, instance_writer: false, default: false 42 | end 43 | end 44 | 45 | # Call the {Organize::ClassMethods#organized .organized} {ActiveInteractor::Base interactors} 46 | # {Interactor::Perform#perform #perform}. An {Base organizer} is expected not to define its own 47 | # {Interactor::Perform#perform #perform} method in favor of this default implementation. 48 | def perform 49 | run_callbacks :all_perform do 50 | if self.class.parallel 51 | perform_in_parallel 52 | else 53 | perform_in_order 54 | end 55 | end 56 | end 57 | 58 | def run_deferred_after_perform_callbacks_on_children 59 | self.class.organized.each do |interface| 60 | next unless interface.interactor_class.after_callbacks_deferred_when_organized 61 | 62 | context.merge!(interface.execute_deferred_after_perform_callbacks(context)) 63 | end 64 | end 65 | 66 | private 67 | 68 | def execute_interactor(interface, fail_on_error = false, perform_options = {}) 69 | interface.perform(self, context, fail_on_error, perform_options) 70 | end 71 | 72 | def execute_interactor_with_callbacks(interface, fail_on_error = false, perform_options = {}) 73 | args = [interface, fail_on_error, perform_options] 74 | return execute_interactor(*args) if options.skip_each_perform_callbacks 75 | 76 | run_callbacks :each_perform do 77 | execute_interactor(*args) 78 | end 79 | end 80 | 81 | def merge_contexts(contexts) 82 | contexts.each { |context| @context.merge!(context) } 83 | context_fail! if contexts.any?(&:failure?) 84 | end 85 | 86 | def execute_and_merge_contexts(interface) 87 | interface.execute_inplace_callback(self, :before) 88 | result = execute_interactor_with_callbacks(interface, true) 89 | return if result.nil? 90 | 91 | context.merge!(result) 92 | context_fail! if result.failure? 93 | interface.execute_inplace_callback(self, :after) 94 | end 95 | 96 | def perform_in_order 97 | self.class.organized.each do |interface| 98 | execute_and_merge_contexts(interface) 99 | end 100 | rescue ActiveInteractor::Error::ContextFailure => e 101 | context.merge!(e.context) 102 | end 103 | 104 | def perform_in_parallel 105 | results = self.class.organized.map do |interface| 106 | Thread.new { execute_interactor_with_callbacks(interface, false, skip_rollback: true) } 107 | end 108 | merge_contexts(results.map(&:value)) 109 | end 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /spec/integration/a_basic_interactor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'A basic interactor', type: :integration do 6 | let(:interactor_class) do 7 | build_interactor do 8 | def perform 9 | context.test_field = 'test' 10 | end 11 | end 12 | end 13 | 14 | include_examples 'a class with interactor methods' 15 | include_examples 'a class with interactor callback methods' 16 | include_examples 'a class with interactor context methods' 17 | 18 | describe '.context_class' do 19 | subject { interactor_class.context_class } 20 | 21 | it { is_expected.to eq TestInteractor::Context } 22 | it { is_expected.to be < ActiveInteractor::Context::Base } 23 | end 24 | 25 | describe '.perform' do 26 | subject { interactor_class.perform } 27 | 28 | it { is_expected.to be_a interactor_class.context_class } 29 | it { is_expected.to be_successful } 30 | it { is_expected.to have_attributes(test_field: 'test') } 31 | end 32 | 33 | describe '.perform!' do 34 | subject { interactor_class.perform! } 35 | 36 | it { expect { subject }.not_to raise_error } 37 | it { is_expected.to be_a interactor_class.context_class } 38 | it { is_expected.to be_successful } 39 | it { is_expected.to have_attributes(test_field: 'test') } 40 | end 41 | 42 | context 'having #context_attributes :test_field' do 43 | let(:interactor_class) do 44 | build_interactor do 45 | context_attributes :test_field 46 | 47 | def perform 48 | context.test_field = 'test' 49 | context.some_other_field = 'test 2' 50 | end 51 | end 52 | end 53 | 54 | include_examples 'a class with interactor methods' 55 | include_examples 'a class with interactor callback methods' 56 | include_examples 'a class with interactor context methods' 57 | 58 | describe '.perform' do 59 | subject(:result) { interactor_class.perform } 60 | 61 | it { is_expected.to be_a interactor_class.context_class } 62 | it { is_expected.to be_successful } 63 | it { is_expected.to have_attributes(test_field: 'test', some_other_field: 'test 2') } 64 | 65 | describe '.attributes' do 66 | subject { result.attributes } 67 | 68 | it { is_expected.to eq(test_field: 'test') } 69 | end 70 | end 71 | end 72 | 73 | context 'having a .name "AnInteractor"' do 74 | let(:interactor_class) { build_interactor('AnInteractor') } 75 | 76 | context 'having a class defined named "AnInteractorContext"' do 77 | let!(:context_class) { build_context('AnInteractorContext') } 78 | 79 | describe '.context_class' do 80 | subject { interactor_class.context_class } 81 | 82 | it { is_expected.to eq AnInteractorContext } 83 | it { is_expected.to be < ActiveInteractor::Context::Base } 84 | end 85 | end 86 | end 87 | 88 | context 'with a context class named "ATestContext"' do 89 | let!(:context_class) { build_context('ATestContext') } 90 | 91 | context 'with .contextualize_with :a_test_context' do 92 | let(:interactor_class) do 93 | build_interactor do 94 | contextualize_with :a_test_context 95 | end 96 | end 97 | 98 | describe '.context_class' do 99 | subject { interactor_class.context_class } 100 | 101 | it { is_expected.to eq ATestContext } 102 | it { is_expected.to be < ActiveInteractor::Context::Base } 103 | end 104 | end 105 | end 106 | 107 | context 'having default context attributes {:foo => "foo"}' do 108 | let(:interactor_class) do 109 | build_interactor do 110 | context_attribute :foo, default: -> { 'foo' } 111 | end 112 | end 113 | 114 | describe '.perform' do 115 | subject { interactor_class.perform(context_attributes) } 116 | 117 | context 'when no context is passed' do 118 | let(:context_attributes) { {} } 119 | 120 | it { is_expected.to have_attributes(foo: 'foo') } 121 | end 122 | 123 | context 'when context {:foo => "bar"} is passed' do 124 | let(:context_attributes) { { foo: 'bar' } } 125 | 126 | it { is_expected.to have_attributes(foo: 'bar') } 127 | end 128 | end 129 | end 130 | 131 | context 'having default context attributes {:foo => "foo", :bar => "bar"} using the #context_attributes method' do 132 | let(:interactor_class) do 133 | build_interactor do 134 | context_attributes foo: { default: -> { 'foo' } }, bar: { default: -> { 'bar' } } 135 | end 136 | 137 | describe '.perform' do 138 | subject { interactor_class.perform(context_attributes) } 139 | 140 | context 'when no context is passed' do 141 | let(:context_attributes) { {} } 142 | 143 | it { is_expected.to have_attributes(foo: 'foo', bar: 'bar') } 144 | end 145 | 146 | context 'when context {:foo => "bar"} is passed' do 147 | let(:context_attributes) { { foo: 'bar' } } 148 | 149 | it { is_expected.to have_attributes(foo: 'bar', bar: 'bar') } 150 | end 151 | end 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/active_interactor/organizer/interactor_interface.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | module Organizer 5 | # An interface object to facilitate conditionally calling {Interactor::Perform::ClassMethods#perform .perform} on 6 | # an {ActiveInteractor::Base interactor} 7 | # 8 | # @api private 9 | # @author Aaron Allen 10 | # @since 1.0.0 11 | # 12 | # @!attribute [r] filters 13 | # Conditional options for the {ActiveInteractor::Base interactor} class 14 | # 15 | # @return [Hash{Symbol=>Proc, Symbol}] conditional options for the {ActiveInteractor::Base interactor} class 16 | # 17 | # @!attribute [r] callbacks 18 | # Callbacks for the interactor_class 19 | # 20 | # @since 1.1.0 21 | # 22 | # @return [Hash{Symbol=>*}] the interactor callbacks 23 | # 24 | # @!attribute [r] interactor_class 25 | # An {ActiveInteractor::Base interactor} class 26 | # 27 | # @return [Const] an {ActiveInteractor::Base interactor} class 28 | # 29 | # @!attribute [r] perform_options 30 | # {Interactor::Perform::Options} for the {ActiveInteractor::Base interactor} {Interactor::Perform#perform #perform} 31 | # 32 | # @see Interactor::Perform::Options 33 | # 34 | # @return [Hash{Symbol=>*}] {Interactor::Perform::Options} for the {ActiveInteractor::Base interactor} 35 | # {Interactor::Perform#perform #perform} 36 | # 37 | # @!attribute [r] deferred_after_perform_callbacks 38 | # Deffered callbacks for the interactor_class 39 | # @since 1.2.0 40 | # 41 | # @return [Hash{Symbol=>*}] the interactor callbacks 42 | class InteractorInterface 43 | attr_reader :filters, :callbacks, :interactor_class, :perform_options, :deferred_after_perform_callbacks 44 | 45 | # Keywords for conditional filters 46 | # @return [Array] 47 | CONDITIONAL_FILTERS = %i[if unless].freeze 48 | CALLBACKS = %i[before after].freeze 49 | 50 | # Initialize a new instance of {InteractorInterface} 51 | # 52 | # @param interactor_class [Const] an {ActiveInteractor::Base interactor} class 53 | # @param options [Hash] options to use for the {ActiveInteractor::Base interactor's} 54 | # {Interactor::Perform::ClassMethods#perform .perform}. See {Interactor::Perform::Options}. 55 | # @return [InteractorInterface] a new instance of {InteractorInterface} 56 | def initialize(interactor_class, options = {}) 57 | @interactor_class = interactor_class.to_s.camelize.safe_constantize 58 | @filters = options.select { |key, _value| CONDITIONAL_FILTERS.include?(key) } 59 | @callbacks = options.select { |key, _value| CALLBACKS.include?(key) } 60 | @perform_options = options.reject { |key, _value| CONDITIONAL_FILTERS.include?(key) || CALLBACKS.include?(key) } 61 | init_deferred_after_perform_callbacks 62 | end 63 | 64 | # Call the {#interactor_class} {Interactor::Perform::ClassMethods#perform .perform} or 65 | # {Interactor::Perform::ClassMethods#perform! .perform!} method if all conditions in {#filters} are properly met. 66 | # 67 | # @param target [Class] the calling {Base organizer} instance 68 | # @param context [Class] an instance of {Context::Base context} 69 | # @param fail_on_error [Boolean] if `true` {Interactor::Perform::ClassMethods#perform! .perform!} will be called 70 | # on the {#interactor_class} other wise {Interactor::Perform::ClassMethods#perform .perform} will be called. 71 | # @param perform_options [Hash] additional {Interactor::Perform::Options} to merge with {#perform_options} 72 | # @raise [Error::ContextFailure] if `fail_on_error` is `true` and the {#interactor_class} 73 | # {Context::Status#fail! fails} its {Context::Base context}. 74 | # @return [Class] an instance of {Context::Base context} 75 | def perform(target, context, fail_on_error = false, perform_options = {}) 76 | return if check_conditionals(target, :if) == false 77 | return if check_conditionals(target, :unless) == true 78 | 79 | skip_deferred_after_perform_callbacks 80 | 81 | method = fail_on_error ? :perform! : :perform 82 | options = self.perform_options.merge(perform_options) 83 | interactor_class.send(method, context, options) 84 | end 85 | 86 | def execute_inplace_callback(target, callback) 87 | resolve_option(target, callbacks[callback]) 88 | end 89 | 90 | def execute_deferred_after_perform_callbacks(context) 91 | return unless deferred_after_perform_callbacks.present? 92 | 93 | interactor = interactor_class.new(context) 94 | env = ActiveSupport::Callbacks::Filters::Environment.new(interactor, false, nil) 95 | invoke_after(env) 96 | interactor.send(:context) 97 | end 98 | 99 | private 100 | 101 | if ActiveSupport.version >= '7.1' 102 | def invoke_after(env) 103 | deferred_after_perform_callbacks.compile(nil).invoke_after(env) 104 | end 105 | else 106 | def invoke_after(env) 107 | deferred_after_perform_callbacks.compile.invoke_after(env) 108 | end 109 | end 110 | 111 | def init_deferred_after_perform_callbacks 112 | after_callbacks_deferred = interactor_class.present? && 113 | interactor_class.after_callbacks_deferred_when_organized 114 | @deferred_after_perform_callbacks = after_callbacks_deferred ? interactor_class._perform_callbacks : nil 115 | end 116 | 117 | def skip_deferred_after_perform_callbacks 118 | return unless deferred_after_perform_callbacks.present? 119 | 120 | deferred_after_perform_callbacks.each do |callback| 121 | interactor_class.skip_callback(:perform, :after, callback.filter, raise: false) 122 | end 123 | end 124 | 125 | def check_conditionals(target, filter) 126 | resolve_option(target, filters[filter]) 127 | end 128 | 129 | def resolve_option(target, opt) 130 | return unless opt 131 | 132 | return target.send(opt) if opt.is_a?(Symbol) 133 | return target.instance_exec(&opt) if opt.is_a?(Proc) 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/active_interactor/context/attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | module Context 5 | # Context attribute methods. Because {Attributes} is a module classes should include {Attributes} rather than 6 | # inherit from it. 7 | # 8 | # @author Aaron Allen 9 | # @since 0.1.4 10 | module Attributes 11 | # Context attribute class methods. Because {ClassMethods} is a module classes should extend {ClassMethods} rather 12 | # than inherit from it. 13 | # 14 | # @author Aaron Allen 15 | # @since 0.1.4 16 | module ClassMethods 17 | # Get or set attributes on a {Base context} class 18 | # 19 | # @example Set attributes on a {Base context} class 20 | # class MyContext < ActiveInteractor::Context::Base 21 | # attributes :first_name, :last_name 22 | # end 23 | # 24 | # @example Get attributes defined on a {Base context} class 25 | # MyContext.attributes 26 | # #=> [:first_name, :last_name] 27 | # 28 | # @example Set defaults for attributes on a {Base context} class 29 | # class MyContext < ActiveInteractor::Context::Base 30 | # attributes first_name: { default: -> { 'Aaron' } }, last_name: { default: -> { 'Allen' } } 31 | # end 32 | # 33 | # @return [Array] the defined attributes 34 | def attributes(*attributes) 35 | attributes.compact.uniq.each { |attr| attribute(attr) } 36 | 37 | attribute_names.sort.collect(&:to_sym) 38 | end 39 | 40 | private 41 | 42 | def attribute?(attr_name) 43 | attribute_types.key?(attr_name.to_s) 44 | end 45 | alias has_attribute? attribute? 46 | end 47 | 48 | # Initialize a new instance of {Base} 49 | # 50 | # @param context [Hash, Base, Class] attributes to assign to the {Base context} 51 | # @return [Base] a new instance of {Base} 52 | def initialize(context = {}) 53 | merge_errors!(context) if context.respond_to?(:errors) 54 | copy_flags!(context) 55 | copy_called!(context) 56 | context = context_attributes_as_hash(context) || {} 57 | super 58 | 59 | merge_attribute_values(context) 60 | end 61 | 62 | # Returns the value of an attribute 63 | # 64 | # @since 1.0.5 65 | # 66 | # @param name [String, Symbol] the key of the value to be returned 67 | # @returns [*] the attribute value 68 | def [](name) 69 | @table[name.to_sym] || attributes[name.to_sym] 70 | end 71 | 72 | # Sets value of a Hash attribute in context.attributes 73 | # 74 | # @since 1.1.0 75 | # 76 | # @param name [String, Symbol] the key name of the attribute 77 | # @param value [*] the value to be given attribute name 78 | # @returns [*] the attribute value 79 | def []=(name, value) 80 | public_send("#{name}=", value) 81 | 82 | super unless @table.nil? 83 | end 84 | 85 | # Get values defined on the instance of {Base context} whose keys are defined on the {Base context} class' 86 | # {ClassMethods#attributes .attributes} 87 | # 88 | # @example Get attributes defined on an instance of {Base context} 89 | # class MyContext < ActiveInteractor::Context::Base 90 | # attributes :first_name, :last_name 91 | # end 92 | # 93 | # context = MyContext.new(first_name: 'Aaron', last_name: 'Allen', occupation: 'Ruby Nerd') 94 | # #=> <#MyContext first_name='Aaron' last_name='Allen' occupation='Ruby Nerd') 95 | # 96 | # context.attributes 97 | # #=> { first_name: 'Aaron', last_name: 'Allen' } 98 | # 99 | # context.occupation 100 | # #=> 'Ruby Nerd' 101 | # 102 | # @return [Hash{Symbol => *}] the defined attributes and values 103 | def attributes 104 | super.symbolize_keys 105 | end 106 | 107 | # Check if the {Base context} instance has an attribute 108 | # 109 | # @since 1.0.1 110 | # 111 | # @param attr_name [Symbol, String] the name of the attribute to check 112 | # @return [Boolean] whether or not the {Base context} instance has the attribute 113 | def attribute?(attr_name) 114 | @attributes.key?(attr_name.to_s) 115 | end 116 | alias has_attribute? attribute? 117 | 118 | # Merge an instance of {Base context} into the calling {Base context} instance 119 | # 120 | # @since 1.0.0 121 | # 122 | # @example 123 | # context = MyContext.new(first_name: 'Aaron', last_name: 'Allen') 124 | # other_context = MyContext.new(last_name: 'Awesome') 125 | # context.merge!(other_context) 126 | # #=> <#MyContext first_name='Aaron' last_name='Awesome'> 127 | # 128 | # @param context [Class] a {Base context} instance to be merged 129 | # @return [self] the {Base context} instance 130 | def merge!(context) 131 | merge_errors!(context) if context.respond_to?(:errors) 132 | copy_flags!(context) 133 | 134 | merged_context_attributes(context).each_pair do |key, value| 135 | self[key] = value unless value.nil? 136 | end 137 | self 138 | end 139 | 140 | private 141 | 142 | def _called 143 | @_called ||= [] 144 | end 145 | 146 | def merged_context_attributes(context) 147 | attrs = {} 148 | attrs.merge!(context.to_h) if context.respond_to?(:to_h) 149 | attrs.merge!(context.attributes.to_h) if context.respond_to?(:attributes) 150 | attrs 151 | end 152 | 153 | def context_attributes_as_hash(context) 154 | return context.to_h if context&.respond_to?(:to_h) 155 | return context.attributes.to_h if context.respond_to?(:attributes) 156 | end 157 | 158 | def copy_called!(context) 159 | value = context.instance_variable_get('@_called') || [] 160 | instance_variable_set('@_called', value) 161 | end 162 | 163 | def copy_flags!(context) 164 | %w[_failed _rolled_back].each do |flag| 165 | value = context.instance_variable_get("@#{flag}") 166 | instance_variable_set("@#{flag}", value) 167 | end 168 | end 169 | 170 | def merge_attribute_values(context) 171 | return unless context 172 | 173 | attributes.compact.merge(context).each_pair do |key, value| 174 | self[key] = value 175 | end 176 | end 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /spec/active_interactor/organizer/interactor_interface_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActiveInteractor::Organizer::InteractorInterface do 6 | describe '.new' do 7 | subject(:instance) { described_class.new(interactor_class, options) } 8 | 9 | context 'with an interactor that does not exist' do 10 | let(:interactor_class) { :an_interactor_that_does_not_exist } 11 | let(:options) { {} } 12 | 13 | describe '#interactor_class' do 14 | subject { instance.interactor_class } 15 | 16 | it { is_expected.to be_nil } 17 | end 18 | end 19 | 20 | RSpec.shared_examples 'an instance of InteractorInterface correctly parse options' do 21 | context 'with options {:if => :some_method }' do 22 | let(:options) { { if: :some_method } } 23 | 24 | describe '#filters' do 25 | subject { instance.filters } 26 | 27 | it { is_expected.to eq(if: :some_method) } 28 | end 29 | 30 | describe '#perform_options' do 31 | subject { instance.perform_options } 32 | 33 | it { is_expected.to be_empty } 34 | end 35 | end 36 | 37 | context 'with options {:if => -> { context.test == true } }' do 38 | let(:options) { { if: -> { context.test == true } } } 39 | 40 | describe '#filters' do 41 | subject { instance.filters } 42 | 43 | it { expect(subject[:if]).not_to be_nil } 44 | it { expect(subject[:if]).to be_a Proc } 45 | end 46 | 47 | describe '#perform_options' do 48 | subject { instance.perform_options } 49 | 50 | it { is_expected.to be_empty } 51 | end 52 | end 53 | 54 | context 'with options {:unless => :some_method }' do 55 | let(:options) { { unless: :some_method } } 56 | 57 | describe '#filters' do 58 | subject { instance.filters } 59 | 60 | it { is_expected.to eq(unless: :some_method) } 61 | end 62 | 63 | describe '#perform_options' do 64 | subject { instance.perform_options } 65 | 66 | it { is_expected.to be_empty } 67 | end 68 | end 69 | 70 | context 'with options {:unless => -> { context.test == true } }' do 71 | let(:options) { { unless: -> { context.test == true } } } 72 | 73 | describe '#filters' do 74 | subject { instance.filters } 75 | 76 | it { expect(subject[:unless]).not_to be_nil } 77 | it { expect(subject[:unless]).to be_a Proc } 78 | end 79 | 80 | describe '#perform_options' do 81 | subject { instance.perform_options } 82 | 83 | it { is_expected.to be_empty } 84 | end 85 | end 86 | 87 | context 'with options {:before => :some_method }' do 88 | let(:options) { { before: :some_method } } 89 | 90 | describe '#callbacks' do 91 | subject { instance.callbacks } 92 | 93 | it { is_expected.to eq(before: :some_method) } 94 | end 95 | 96 | describe '#perform_options' do 97 | subject { instance.perform_options } 98 | 99 | it { is_expected.to be_empty } 100 | end 101 | end 102 | 103 | context 'with options {:before => -> { context.test = true } }' do 104 | let(:options) { { before: -> { context.test = true } } } 105 | 106 | describe '#callbacks' do 107 | subject { instance.callbacks } 108 | 109 | it { expect(subject[:before]).not_to be_nil } 110 | it { expect(subject[:before]).to be_a Proc } 111 | end 112 | 113 | describe '#perform_options' do 114 | subject { instance.perform_options } 115 | 116 | it { is_expected.to be_empty } 117 | end 118 | end 119 | 120 | context 'with options {:after => :some_method }' do 121 | let(:options) { { after: :some_method } } 122 | 123 | describe '#callbacks' do 124 | subject { instance.callbacks } 125 | 126 | it { is_expected.to eq(after: :some_method) } 127 | end 128 | 129 | describe '#perform_options' do 130 | subject { instance.perform_options } 131 | 132 | it { is_expected.to be_empty } 133 | end 134 | end 135 | 136 | context 'with options {:after => -> { context.test = true } }' do 137 | let(:options) { { after: -> { context.test = true } } } 138 | 139 | describe '#callbacks' do 140 | subject { instance.callbacks } 141 | 142 | it { expect(subject[:after]).not_to be_nil } 143 | it { expect(subject[:after]).to be_a Proc } 144 | end 145 | 146 | describe '#perform_options' do 147 | subject { instance.perform_options } 148 | 149 | it { is_expected.to be_empty } 150 | end 151 | end 152 | 153 | context 'with options { :validate => false }' do 154 | let(:options) { { validate: false } } 155 | 156 | describe '#filters' do 157 | subject { instance.filters } 158 | 159 | it { is_expected.to be_empty } 160 | end 161 | 162 | describe '#perform_options' do 163 | subject { instance.perform_options } 164 | 165 | it { is_expected.to eq(validate: false) } 166 | end 167 | end 168 | 169 | context 'with options { :if => :some_method, :validate => false, :before => :other_method }' do 170 | let(:options) { { if: :some_method, validate: false, before: :other_method } } 171 | 172 | describe '#filters' do 173 | subject { instance.filters } 174 | 175 | it { is_expected.to eq(if: :some_method) } 176 | end 177 | 178 | describe '#callbacks' do 179 | subject { instance.callbacks } 180 | 181 | it { is_expected.to eq(before: :other_method) } 182 | end 183 | 184 | describe '#perform_options' do 185 | subject { instance.perform_options } 186 | 187 | it { is_expected.to eq(validate: false) } 188 | end 189 | end 190 | end 191 | 192 | context 'with an existing interactor' do 193 | before { build_interactor } 194 | 195 | context 'when interactors are passed as contants' do 196 | let(:interactor_class) { TestInteractor } 197 | let(:options) { {} } 198 | 199 | include_examples 'an instance of InteractorInterface correctly parse options' 200 | 201 | describe '#interactor_class' do 202 | subject { instance.interactor_class } 203 | 204 | it { is_expected.to eq TestInteractor } 205 | end 206 | end 207 | 208 | context 'when interactors are passed as symbols' do 209 | let(:interactor_class) { :test_interactor } 210 | let(:options) { {} } 211 | 212 | include_examples 'an instance of InteractorInterface correctly parse options' 213 | 214 | describe '#interactor_class' do 215 | subject { instance.interactor_class } 216 | 217 | it { is_expected.to eq TestInteractor } 218 | end 219 | end 220 | 221 | context 'when interactors are passed as strings' do 222 | let(:interactor_class) { 'TestInteractor' } 223 | let(:options) { {} } 224 | 225 | include_examples 'an instance of InteractorInterface correctly parse options' 226 | 227 | describe '#interactor_class' do 228 | subject { instance.interactor_class } 229 | 230 | it { is_expected.to eq TestInteractor } 231 | end 232 | end 233 | end 234 | end 235 | end 236 | -------------------------------------------------------------------------------- /spec/active_interactor/interactor/worker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActiveInteractor::Interactor::Worker do 6 | context 'with interactor class TestInteractor' do 7 | before { build_interactor } 8 | 9 | let(:interactor) { TestInteractor.new } 10 | 11 | RSpec.shared_examples 'an interactor with options' do 12 | context 'when interactor has options :skip_perform_callbacks eq to true' do 13 | let(:interactor) { TestInteractor.new.with_options(skip_perform_callbacks: true) } 14 | 15 | it 'is expected not to receive #run_callbacks with :perform' do 16 | allow_any_instance_of(TestInteractor).to receive(:run_callbacks) 17 | .with(:validation).and_call_original 18 | expect_any_instance_of(TestInteractor).not_to receive(:run_callbacks) 19 | .with(:perform) 20 | subject 21 | end 22 | end 23 | 24 | context 'when interactor has options :validate eq to false' do 25 | let(:interactor) { TestInteractor.new.with_options(validate: false) } 26 | 27 | it 'is expected not to receive #run_callbacks with :validation' do 28 | expect_any_instance_of(TestInteractor).not_to receive(:run_callbacks) 29 | .with(:validation) 30 | subject 31 | end 32 | end 33 | 34 | context 'when interactor has options :validate_on_calling eq to false' do 35 | let(:interactor) { TestInteractor.new.with_options(validate_on_calling: false) } 36 | 37 | before do 38 | allow_any_instance_of(TestInteractor).to receive(:context_valid?) 39 | .with(:called).and_return(true) 40 | end 41 | 42 | it 'is expected not to receive #context_valid? with :calling' do 43 | expect_any_instance_of(TestInteractor).not_to receive(:context_valid?) 44 | .with(:calling) 45 | subject 46 | end 47 | 48 | it 'is expected to receive #context_valid? with :called' do 49 | expect_any_instance_of(TestInteractor).to receive(:context_valid?) 50 | .with(:called) 51 | subject 52 | end 53 | end 54 | 55 | context 'when interactor has options :validate_on_called eq to false' do 56 | let(:interactor) { TestInteractor.new.with_options(validate_on_called: false) } 57 | 58 | before do 59 | allow_any_instance_of(TestInteractor).to receive(:context_valid?) 60 | .with(:calling).and_return(true) 61 | end 62 | 63 | it 'is expected to receive #context_valid? with :calling' do 64 | expect_any_instance_of(TestInteractor).to receive(:context_valid?) 65 | .with(:calling) 66 | subject 67 | end 68 | 69 | it 'is expected not to receive #context_valid? with :called' do 70 | expect_any_instance_of(TestInteractor).not_to receive(:context_valid?) 71 | .with(:called) 72 | subject 73 | end 74 | end 75 | end 76 | 77 | describe '#execute_perform' do 78 | subject { described_class.new(interactor).execute_perform } 79 | 80 | it { is_expected.to be_an TestInteractor.context_class } 81 | 82 | context 'when context fails' do 83 | before do 84 | allow_any_instance_of(TestInteractor).to receive(:perform) 85 | .and_raise(ActiveInteractor::Error::ContextFailure) 86 | end 87 | 88 | it { expect { subject }.not_to raise_error } 89 | it { is_expected.to be_an TestInteractor.context_class } 90 | end 91 | 92 | include_examples 'an interactor with options' 93 | end 94 | 95 | describe '#execute_perform!' do 96 | subject { described_class.new(interactor).execute_perform! } 97 | 98 | it { is_expected.to be_an TestInteractor.context_class } 99 | 100 | it 'is expected to run perform callbacks on interactor' do 101 | expect_any_instance_of(TestInteractor).to receive(:run_callbacks) 102 | .with(:perform) 103 | subject 104 | end 105 | 106 | it 'is expected to receive #perform on interactor instance' do 107 | expect_any_instance_of(TestInteractor).to receive(:perform) 108 | subject 109 | end 110 | 111 | context 'when interactor context is invalid on :calling' do 112 | before do 113 | allow_any_instance_of(TestInteractor.context_class).to receive(:valid?) 114 | .with(:calling) 115 | .and_return(false) 116 | allow_any_instance_of(TestInteractor.context_class).to receive(:valid?) 117 | .with(:called) 118 | .and_return(true) 119 | end 120 | 121 | it { expect { subject }.to raise_error(ActiveInteractor::Error::ContextFailure) } 122 | 123 | it 'is expected to rollback the interactor context' do 124 | expect_any_instance_of(TestInteractor).to receive(:context_rollback!) 125 | expect { subject }.to raise_error(ActiveInteractor::Error::ContextFailure) 126 | end 127 | end 128 | 129 | context 'when interactor context is invalid on :called' do 130 | before do 131 | allow_any_instance_of(TestInteractor.context_class).to receive(:valid?) 132 | .with(:calling) 133 | .and_return(true) 134 | allow_any_instance_of(TestInteractor.context_class).to receive(:valid?) 135 | .with(:called) 136 | .and_return(false) 137 | end 138 | 139 | it { expect { subject }.to raise_error(ActiveInteractor::Error::ContextFailure) } 140 | 141 | it 'is expected to rollback the interactor context' do 142 | expect_any_instance_of(TestInteractor).to receive(:context_rollback!) 143 | expect { subject }.to raise_error(ActiveInteractor::Error::ContextFailure) 144 | end 145 | end 146 | 147 | include_examples 'an interactor with options' 148 | end 149 | 150 | describe '#execute_rollback' do 151 | subject { described_class.new(interactor).execute_rollback } 152 | 153 | it 'is expected to receive #run_callbacks on interactor with :rollback' do 154 | expect_any_instance_of(TestInteractor).to receive(:run_callbacks) 155 | .with(:rollback) 156 | subject 157 | end 158 | 159 | it 'is expected to receive #context_rollback on interactor instance' do 160 | expect_any_instance_of(TestInteractor).to receive(:context_rollback!) 161 | subject 162 | end 163 | 164 | context 'when interactor has options :skip_rollback eq to true' do 165 | let(:interactor) { TestInteractor.new.with_options(skip_rollback: true) } 166 | 167 | it 'is expected not to receive #context_rollback on interactor instance' do 168 | expect_any_instance_of(TestInteractor).not_to receive(:context_rollback!) 169 | subject 170 | end 171 | end 172 | 173 | context 'when interactor has options :skip_rollback_callbacks eq to true' do 174 | let(:interactor) { TestInteractor.new.with_options(skip_rollback_callbacks: true) } 175 | 176 | it 'is expected to receive #context_rollback on interactor instance' do 177 | expect_any_instance_of(TestInteractor).to receive(:context_rollback!) 178 | subject 179 | end 180 | 181 | it 'is expected not to receive #run_callbacks on interactor with :rollback' do 182 | expect_any_instance_of(TestInteractor).not_to receive(:run_callbacks) 183 | .with(:rollback) 184 | subject 185 | end 186 | end 187 | end 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /lib/active_interactor/organizer/callbacks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveInteractor 4 | module Organizer 5 | # Organizer callback methods. Because {Callbacks} is a module classes should include {Callbacks} rather than 6 | # inherit from it. 7 | # 8 | # @author Aaron Allen 9 | # @since 1.0.0 10 | # @see https://github.com/aaronmallen/activeinteractor/wiki/Callbacks Callbacks 11 | # @see https://api.rubyonrails.org/classes/ActiveSupport/Callbacks.html ActiveSupport::Callbacks 12 | module Callbacks 13 | # Organizer callback class methods. Because {ClassMethods} is a module classes should extend {ClassMethods} 14 | # rather than inherit from it. 15 | # 16 | # @author Aaron Allen 17 | # @since 1.0.0 18 | module ClassMethods 19 | # Define a callback to call after each {Organizer::Organize::ClassMethods#organized organized} 20 | # {ActiveInteractor::Base interactor's} {Interactor::Perform#perform #perform} method has been called. 21 | # 22 | # @since 0.1.3 23 | # 24 | # @example 25 | # class MyInteractor1 < ActiveInteractor::Base 26 | # def perform 27 | # puts 'MyInteractor1' 28 | # end 29 | # end 30 | # 31 | # class MyInteractor2 < ActiveInteractor::Base 32 | # def perform 33 | # puts 'MyInteractor2' 34 | # end 35 | # end 36 | # 37 | # class MyOrganizer < ActiveInteractor::Organizer 38 | # after_each_perform :print_done 39 | # 40 | # organized MyInteractor1, MyInteractor2 41 | # 42 | # private 43 | # 44 | # def print_done 45 | # puts 'Done' 46 | # end 47 | # end 48 | # 49 | # MyOrganizer.perform 50 | # "MyInteractor1" 51 | # "Done" 52 | # "MyInteractor2" 53 | # "Done" 54 | # #=> 55 | def after_each_perform(*filters, &block) 56 | set_callback(:each_perform, :after, *filters, &block) 57 | end 58 | 59 | # Define a callback to call around each {Organizer::Organize::ClassMethods#organized organized} 60 | # {ActiveInteractor::Base interactor's} {Interactor::Perform#perform #perform} method call. 61 | # 62 | # @since 0.1.3 63 | # 64 | # @example 65 | # class MyInteractor1 < ActiveInteractor::Base 66 | # def perform 67 | # puts 'MyInteractor1' 68 | # sleep(1) 69 | # end 70 | # end 71 | # 72 | # class MyInteractor2 < ActiveInteractor::Base 73 | # def perform 74 | # puts 'MyInteractor2' 75 | # sleep(1) 76 | # end 77 | # end 78 | # 79 | # class MyOrganizer < ActiveInteractor::Organizer 80 | # around_each_perform :print_time 81 | # 82 | # organized MyInteractor1, MyInteractor2 83 | # 84 | # private 85 | # 86 | # def print_time 87 | # puts Time.now.utc 88 | # yield 89 | # puts Time.now.utc 90 | # end 91 | # end 92 | # 93 | # MyOrganizer.perform 94 | # "2019-04-01 00:00:00 UTC" 95 | # "MyInteractor1" 96 | # "2019-04-01 00:00:01 UTC" 97 | # "2019-04-01 00:00:01 UTC" 98 | # "MyInteractor2" 99 | # "2019-04-01 00:00:02 UTC" 100 | # #=> 101 | def around_each_perform(*filters, &block) 102 | set_callback(:each_perform, :around, *filters, &block) 103 | end 104 | 105 | # Define a callback to call before each {Organizer::Organize::ClassMethods#organized organized} 106 | # {ActiveInteractor::Base interactor's} {Interactor::Perform#perform #perform} method has been called. 107 | # 108 | # @since 0.1.3 109 | # 110 | # @example 111 | # class MyInteractor1 < ActiveInteractor::Base 112 | # def perform 113 | # puts 'MyInteractor1' 114 | # end 115 | # end 116 | # 117 | # class MyInteractor2 < ActiveInteractor::Base 118 | # def perform 119 | # puts 'MyInteractor2' 120 | # end 121 | # end 122 | # 123 | # class MyOrganizer < ActiveInteractor::Organizer 124 | # before_each_perform :print_starting 125 | # 126 | # organized MyInteractor1, MyInteractor2 127 | # 128 | # private 129 | # 130 | # def print_starting 131 | # puts 'Starting' 132 | # end 133 | # end 134 | # 135 | # MyOrganizer.perform 136 | # "Starting" 137 | # "MyInteractor1" 138 | # "Starting" 139 | # "MyInteractor2" 140 | # #=> 141 | def before_each_perform(*filters, &block) 142 | set_callback(:each_perform, :before, *filters, &block) 143 | end 144 | 145 | # Define a callback to call after all {Organizer::Organize::ClassMethods#organized organized} 146 | # {ActiveInteractor::Base interactors'} {Interactor::Perform#perform #perform} methods have been called. 147 | # 148 | # @since v1.2.0 149 | # 150 | # @example 151 | # class MyInteractor1 < ActiveInteractor::Base 152 | # def perform 153 | # puts 'MyInteractor1' 154 | # end 155 | # end 156 | # 157 | # class MyInteractor2 < ActiveInteractor::Base 158 | # def perform 159 | # puts 'MyInteractor2' 160 | # end 161 | # end 162 | # 163 | # class MyOrganizer < ActiveInteractor::Organizer 164 | # after_all_perform :print_done 165 | # 166 | # organized MyInteractor1, MyInteractor2 167 | # 168 | # private 169 | # 170 | # def print_done 171 | # puts 'Done' 172 | # end 173 | # end 174 | # 175 | # MyOrganizer.perform 176 | # "MyInteractor1" 177 | # "MyInteractor2" 178 | # "Done" 179 | # #=> 180 | def after_all_perform(*filters, &block) 181 | set_callback(:all_perform, :after, *filters, &block) 182 | end 183 | 184 | # Define a callback to call around all {Organizer::Organize::ClassMethods#organized organized} 185 | # {ActiveInteractor::Base interactors'} {Interactor::Perform#perform #perform} method calls. 186 | # 187 | # @since v1.2.0 188 | # 189 | # @example 190 | # class MyInteractor1 < ActiveInteractor::Base 191 | # def perform 192 | # puts 'MyInteractor1' 193 | # sleep(1) 194 | # end 195 | # end 196 | # 197 | # class MyInteractor2 < ActiveInteractor::Base 198 | # def perform 199 | # puts 'MyInteractor2' 200 | # sleep(1) 201 | # end 202 | # end 203 | # 204 | # class MyOrganizer < ActiveInteractor::Organizer 205 | # around_all_perform :print_time 206 | # 207 | # organized MyInteractor1, MyInteractor2 208 | # 209 | # private 210 | # 211 | # def print_time 212 | # puts Time.now.utc 213 | # yield 214 | # puts Time.now.utc 215 | # end 216 | # end 217 | # 218 | # MyOrganizer.perform 219 | # "2019-04-01 00:00:00 UTC" 220 | # "MyInteractor1" 221 | # "MyInteractor2" 222 | # "2019-04-01 00:00:02 UTC" 223 | # #=> 224 | def around_all_perform(*filters, &block) 225 | set_callback(:all_perform, :around, *filters, &block) 226 | end 227 | 228 | # Define a callback to call before all {Organizer::Organize::ClassMethods#organized organized} 229 | # {ActiveInteractor::Base interactors'} {Interactor::Perform#perform #perform} methods have been called. 230 | # 231 | # @since v1.2.0 232 | # 233 | # @example 234 | # class MyInteractor1 < ActiveInteractor::Base 235 | # def perform 236 | # puts 'MyInteractor1' 237 | # end 238 | # end 239 | # 240 | # class MyInteractor2 < ActiveInteractor::Base 241 | # def perform 242 | # puts 'MyInteractor2' 243 | # end 244 | # end 245 | # 246 | # class MyOrganizer < ActiveInteractor::Organizer 247 | # before_all_perform :print_starting 248 | # 249 | # organized MyInteractor1, MyInteractor2 250 | # 251 | # private 252 | # 253 | # def print_starting 254 | # puts 'Starting' 255 | # end 256 | # end 257 | # 258 | # MyOrganizer.perform 259 | # "Starting" 260 | # "MyInteractor1" 261 | # "MyInteractor2" 262 | # #=> 263 | def before_all_perform(*filters, &block) 264 | set_callback(:all_perform, :before, *filters, &block) 265 | end 266 | end 267 | 268 | def self.included(base) 269 | base.class_eval do 270 | define_callbacks :each_perform, :all_perform 271 | end 272 | end 273 | end 274 | end 275 | end 276 | -------------------------------------------------------------------------------- /spec/active_interactor/organizer/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActiveInteractor::Organizer::Base do 6 | let(:interactor_class) { described_class } 7 | 8 | include_examples 'a class with interactor methods' 9 | include_examples 'a class with interactor callback methods' 10 | include_examples 'a class with interactor context methods' 11 | include_examples 'a class with organizer callback methods' 12 | 13 | describe '.contextualize_with' do 14 | subject { described_class.contextualize_with(klass) } 15 | 16 | context 'with an class that does not exist' do 17 | let(:klass) { 'SomeClassThatDoesNotExist' } 18 | 19 | it { expect { subject }.to raise_error(ActiveInteractor::Error::InvalidContextClass) } 20 | end 21 | 22 | context 'with context class TestContext' do 23 | before { build_context } 24 | 25 | context 'when passed as a string' do 26 | let(:klass) { 'TestContext' } 27 | 28 | it 'is expected to assign the appropriate context class' do 29 | subject 30 | expect(described_class.context_class).to eq TestContext 31 | end 32 | end 33 | 34 | context 'when passed as a symbol' do 35 | let(:klass) { :test_context } 36 | 37 | it 'is expected to assign the appropriate context class' do 38 | subject 39 | expect(described_class.context_class).to eq TestContext 40 | end 41 | end 42 | 43 | context 'when passed as a constant' do 44 | let(:klass) { TestContext } 45 | 46 | it 'is expected to assign the appropriate context class' do 47 | subject 48 | expect(described_class.context_class).to eq TestContext 49 | end 50 | end 51 | end 52 | end 53 | 54 | describe '.organize' do 55 | context 'with two existing interactors' do 56 | let!(:interactor1) { build_interactor('TestInteractor1') } 57 | let!(:interactor2) { build_interactor('TestInteractor2') } 58 | 59 | context 'when interactors are passed as args' do 60 | let(:organizer) do 61 | build_organizer do 62 | organize :test_interactor_1, :test_interactor_2 63 | end 64 | end 65 | 66 | describe '.organized' do 67 | subject { organizer.organized } 68 | 69 | it { expect(subject.collection).to all(be_a ActiveInteractor::Organizer::InteractorInterface) } 70 | 71 | it 'is expected to organize the approriate interactors' do 72 | expect(subject.collection.first.interactor_class).to eq TestInteractor1 73 | expect(subject.collection.last.interactor_class).to eq TestInteractor2 74 | end 75 | end 76 | end 77 | 78 | context 'when a block is passed' do 79 | let(:organizer) do 80 | build_organizer do 81 | organize do 82 | add :test_interactor_1 83 | add :test_interactor_2 84 | end 85 | end 86 | end 87 | 88 | describe '.organized' do 89 | subject { organizer.organized } 90 | 91 | it { expect(subject.collection).to all(be_a ActiveInteractor::Organizer::InteractorInterface) } 92 | 93 | it 'is expected to organize the approriate interactors' do 94 | expect(subject.collection.first.interactor_class).to eq TestInteractor1 95 | expect(subject.collection.last.interactor_class).to eq TestInteractor2 96 | end 97 | end 98 | end 99 | end 100 | end 101 | 102 | describe '.perform_in_parallel' do 103 | subject do 104 | build_organizer do 105 | perform_in_parallel 106 | end 107 | end 108 | 109 | it { is_expected.to have_attributes(parallel: true) } 110 | end 111 | 112 | describe '#perform' do 113 | subject { interactor_class.perform } 114 | 115 | context 'with two existing interactors' do 116 | let!(:interactor1) { build_interactor('TestInteractor1') } 117 | let!(:interactor2) { build_interactor('TestInteractor2') } 118 | let(:interactor_class) do 119 | build_organizer do 120 | organize TestInteractor1, TestInteractor2 121 | end 122 | end 123 | 124 | it { is_expected.to be_a interactor_class.context_class } 125 | 126 | it 'is expected to receive #perform on both interactors' do 127 | expect_any_instance_of(interactor1).to receive(:perform) 128 | expect_any_instance_of(interactor2).to receive(:perform) 129 | subject 130 | end 131 | 132 | context 'with options :skip_each_perform_callbacks eq to true' do 133 | subject { interactor_class.perform({}, skip_each_perform_callbacks: true) } 134 | 135 | it { is_expected.to be_a interactor_class.context_class } 136 | 137 | it 'is expected to receive #perform on both interactors' do 138 | expect_any_instance_of(interactor1).to receive(:perform) 139 | expect_any_instance_of(interactor2).to receive(:perform) 140 | subject 141 | end 142 | 143 | it 'is expected not to receive #run_callbacks with :each_perform' do 144 | expect_any_instance_of(interactor_class).not_to receive(:run_callbacks) 145 | .with(:each_perform) 146 | subject 147 | end 148 | end 149 | 150 | context 'when the first interactor context fails' do 151 | let!(:interactor1) do 152 | build_interactor('TestInteractor1') do 153 | def perform 154 | context.fail! 155 | end 156 | end 157 | end 158 | 159 | it { expect { subject }.not_to raise_error } 160 | it { is_expected.to be_failure } 161 | it { is_expected.to be_a interactor_class.context_class } 162 | 163 | it 'is expected to receive #perform on the first interactor' do 164 | expect_any_instance_of(interactor1).to receive(:perform) 165 | subject 166 | end 167 | 168 | it 'is expected not to receive #perform on the second interactor' do 169 | expect_any_instance_of(interactor2).not_to receive(:perform) 170 | subject 171 | end 172 | 173 | it 'is expected to receive #rollback on the first interactor' do 174 | expect_any_instance_of(interactor1).to receive(:rollback) 175 | subject 176 | end 177 | end 178 | 179 | context 'when the second interactor context fails' do 180 | let!(:interactor2) do 181 | build_interactor('TestInteractor2') do 182 | def perform 183 | context.fail! 184 | end 185 | end 186 | end 187 | 188 | it { expect { subject }.not_to raise_error } 189 | it { is_expected.to be_failure } 190 | it { is_expected.to be_a interactor_class.context_class } 191 | 192 | it 'is expected to receive #perform on both interactors' do 193 | expect_any_instance_of(interactor1).to receive(:perform) 194 | expect_any_instance_of(interactor2).to receive(:perform) 195 | subject 196 | end 197 | 198 | it 'is expected to receive #rollback on both interactors' do 199 | expect_any_instance_of(interactor1).to receive(:rollback) 200 | expect_any_instance_of(interactor2).to receive(:rollback) 201 | subject 202 | end 203 | end 204 | 205 | context 'when the organizer is set to perform in parallel' do 206 | let(:interactor_class) do 207 | build_organizer do 208 | perform_in_parallel 209 | 210 | organize TestInteractor1, TestInteractor2 211 | end 212 | end 213 | 214 | it { is_expected.to be_a interactor_class.context_class } 215 | 216 | it 'is expected to receive #perform on both interactors' do 217 | expect_any_instance_of(interactor1).to receive(:perform) 218 | expect_any_instance_of(interactor2).to receive(:perform) 219 | subject 220 | end 221 | 222 | context 'when the first interactor context fails' do 223 | let!(:interactor1) do 224 | build_interactor('TestInteractor1') do 225 | def perform 226 | context.fail! 227 | end 228 | end 229 | end 230 | 231 | it { expect { subject }.not_to raise_error } 232 | it { is_expected.to be_failure } 233 | it { is_expected.to be_a interactor_class.context_class } 234 | 235 | it 'is expected to receive #perform on both interactors' do 236 | expect_any_instance_of(interactor1).to receive(:perform) 237 | expect_any_instance_of(interactor2).to receive(:perform) 238 | subject 239 | end 240 | 241 | it 'is expected to receive #rollback both interactors' do 242 | expect_any_instance_of(interactor1).to receive(:rollback) 243 | expect_any_instance_of(interactor2).to receive(:rollback) 244 | subject 245 | end 246 | end 247 | 248 | context 'when the second interactor context fails' do 249 | let!(:interactor2) do 250 | build_interactor('TestInteractor2') do 251 | def perform 252 | context.fail! 253 | end 254 | end 255 | end 256 | 257 | it { expect { subject }.not_to raise_error } 258 | it { is_expected.to be_failure } 259 | it { is_expected.to be_a interactor_class.context_class } 260 | 261 | it 'is expected to receive #perform on both interactors' do 262 | expect_any_instance_of(interactor1).to receive(:perform) 263 | expect_any_instance_of(interactor2).to receive(:perform) 264 | subject 265 | end 266 | 267 | it 'is expected to receive #rollback on both interactors' do 268 | expect_any_instance_of(interactor1).to receive(:rollback) 269 | expect_any_instance_of(interactor2).to receive(:rollback) 270 | subject 271 | end 272 | end 273 | end 274 | end 275 | end 276 | end 277 | --------------------------------------------------------------------------------