├── .rspec ├── lib ├── pathway │ ├── rspec.rb │ ├── version.rb │ ├── rspec │ │ ├── matchers.rb │ │ └── matchers │ │ │ ├── field_list_helpers.rb │ │ │ ├── list_helpers.rb │ │ │ ├── succeed_on.rb │ │ │ ├── require_fields.rb │ │ │ ├── accept_optional_fields.rb │ │ │ ├── fail_on.rb │ │ │ └── form_schema_helpers.rb │ ├── plugins │ │ ├── auto_deconstruct_state.rb │ │ ├── simple_auth.rb │ │ ├── responder.rb │ │ ├── dry_validation.rb │ │ └── sequel_models.rb │ └── result.rb └── pathway.rb ├── Gemfile ├── bin ├── setup ├── console ├── pry ├── yri ├── rake ├── yard ├── erb ├── irb ├── ldiff ├── rbs ├── ri ├── yardoc ├── byebug ├── coderay ├── rdbg ├── rdoc ├── rspec ├── sequel ├── htmldiff ├── ruby-lsp ├── ruby-lsp-check ├── ruby-lsp-launcher ├── ruby-lsp-test-exec └── bundle ├── .gitignore ├── Rakefile ├── .github └── workflows │ └── tests.yml ├── spec ├── state_pattern_matching_spec.rb ├── spec_helper.rb ├── plugins_spec.rb ├── plugins │ ├── auto_deconstruct_state_spec.rb │ ├── responder_spec.rb │ ├── simple_auth_spec.rb │ ├── dry_validation_spec.rb │ ├── base_spec.rb │ └── sequel_models_spec.rb ├── state_spec.rb ├── result_spec.rb └── operation_call_pattern_matching_spec.rb ├── LICENSE.txt ├── pathway.gemspec ├── CHANGELOG.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /lib/pathway/rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pathway/rspec/matchers' 4 | -------------------------------------------------------------------------------- /lib/pathway/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pathway 4 | VERSION = '1.2.0' 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in pathway.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | 11 | # rspec failure tracking 12 | .rspec_status 13 | -------------------------------------------------------------------------------- /lib/pathway/rspec/matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pathway/rspec/matchers/succeed_on' 4 | require 'pathway/rspec/matchers/fail_on' 5 | require 'pathway/rspec/matchers/accept_optional_fields' 6 | require 'pathway/rspec/matchers/require_fields' 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) do |t| 7 | unless RUBY_VERSION =~ /^2\.7|^3\./ 8 | t.exclude_pattern = 'spec/operation_call_pattern_matching_spec.rb,spec/state_pattern_matching_spec.rb' 9 | end 10 | end 11 | 12 | task :default => :spec 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "pathway" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "pry" 14 | Pry.start(Pathway) 15 | -------------------------------------------------------------------------------- /lib/pathway/rspec/matchers/field_list_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pathway/rspec/matchers/list_helpers' 4 | 5 | module Pathway 6 | module Rspec 7 | module FieldListHelpers 8 | include ListHelpers 9 | 10 | def field_list 11 | as_list(@fields) 12 | end 13 | 14 | def were_was(list) 15 | list.size > 1 ? 'were' : 'was' 16 | end 17 | 18 | def pluralize_fields 19 | @fields.size > 1 ? 'fields' : 'field' 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/pathway/rspec/matchers/list_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pathway 4 | module Rspec 5 | module ListHelpers 6 | def as_list(items, **kwargs) 7 | as_sentence(items.map(&:inspect), **kwargs) 8 | end 9 | 10 | def as_sentence(items, connector: ', ', last_connector: ' and ') 11 | *rest, last = items 12 | 13 | result = String.new 14 | result << rest.join(connector) << last_connector if rest.any? 15 | result << last 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/pathway/plugins/auto_deconstruct_state.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pathway 4 | module Plugins 5 | module AutoDeconstructState 6 | module DSLMethods 7 | private 8 | 9 | def _callable(callable) 10 | if callable.is_a?(Symbol) && @operation.respond_to?(callable, true) && 11 | @operation.method(callable).arity != 0 && 12 | @operation.method(callable).parameters.all? { _1 in [:key|:keyreq|:keyrest|:block,*] } 13 | 14 | -> state { @operation.send(callable, **state) } 15 | else 16 | super 17 | end 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | ruby-version: [3.2, 3.3, 3.4] 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: ${{ matrix.ruby-version }} 21 | bundler: '2.4.22' 22 | bundler-cache: true 23 | - name: Run tests 24 | run: bundle exec rake 25 | - name: Coveralls GitHub Action 26 | if: matrix.ruby-version == '3.4' 27 | uses: coverallsapp/github-action@v2 28 | with: 29 | github-token: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /bin/pry: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'pry' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("pry", "pry") 28 | -------------------------------------------------------------------------------- /bin/yri: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'yri' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("yard", "yri") 28 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rake", "rake") 28 | -------------------------------------------------------------------------------- /bin/yard: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'yard' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("yard", "yard") 28 | -------------------------------------------------------------------------------- /bin/erb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'erb' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../.ruby-lsp/Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("erb", "erb") 28 | -------------------------------------------------------------------------------- /bin/irb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'irb' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../.ruby-lsp/Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("irb", "irb") 28 | -------------------------------------------------------------------------------- /bin/ldiff: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ldiff' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("diff-lcs", "ldiff") 28 | -------------------------------------------------------------------------------- /bin/rbs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rbs' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../.ruby-lsp/Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rbs", "rbs") 28 | -------------------------------------------------------------------------------- /bin/ri: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ri' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../.ruby-lsp/Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rdoc", "ri") 28 | -------------------------------------------------------------------------------- /bin/yardoc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'yardoc' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("yard", "yardoc") 28 | -------------------------------------------------------------------------------- /bin/byebug: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'byebug' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("byebug", "byebug") 28 | -------------------------------------------------------------------------------- /bin/coderay: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'coderay' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("coderay", "coderay") 28 | -------------------------------------------------------------------------------- /bin/rdbg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rdbg' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../.ruby-lsp/Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("debug", "rdbg") 28 | -------------------------------------------------------------------------------- /bin/rdoc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rdoc' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../.ruby-lsp/Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rdoc", "rdoc") 28 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rspec-core", "rspec") 28 | -------------------------------------------------------------------------------- /bin/sequel: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'sequel' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("sequel", "sequel") 28 | -------------------------------------------------------------------------------- /bin/htmldiff: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'htmldiff' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("diff-lcs", "htmldiff") 28 | -------------------------------------------------------------------------------- /bin/ruby-lsp: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ruby-lsp' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../.ruby-lsp/Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("ruby-lsp", "ruby-lsp") 28 | -------------------------------------------------------------------------------- /bin/ruby-lsp-check: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ruby-lsp-check' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../.ruby-lsp/Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("ruby-lsp", "ruby-lsp-check") 28 | -------------------------------------------------------------------------------- /bin/ruby-lsp-launcher: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ruby-lsp-launcher' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../.ruby-lsp/Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("ruby-lsp", "ruby-lsp-launcher") 28 | -------------------------------------------------------------------------------- /bin/ruby-lsp-test-exec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ruby-lsp-test-exec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../.ruby-lsp/Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("ruby-lsp", "ruby-lsp-test-exec") 28 | -------------------------------------------------------------------------------- /lib/pathway/plugins/simple_auth.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pathway 4 | module Plugins 5 | module SimpleAuth 6 | module ClassMethods 7 | def authorization(&block) 8 | define_method(:authorized?) do |*args| 9 | instance_exec(*args, &block) 10 | end 11 | end 12 | end 13 | 14 | module InstanceMethods 15 | def authorize(state, using: nil) 16 | auth_state = if using.is_a?(Array) 17 | authorize_with(*state.values_at(*using)) 18 | else 19 | authorize_with(state[using || result_key]) 20 | end 21 | 22 | auth_state.then { state } 23 | end 24 | 25 | def authorize_with(*objs) 26 | authorized?(*objs) ? wrap(objs) : error(:forbidden) 27 | end 28 | 29 | def authorized?(*) = true 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/state_pattern_matching_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Pathway 6 | describe State do 7 | class SimpleOp < Operation 8 | context :foo, bar: 10 9 | result_at :the_result 10 | end 11 | 12 | let(:operation) { SimpleOp.new(foo: 20) } 13 | let(:values) { { input: 'some value' } } 14 | subject(:state) { State.new(operation, values) } 15 | 16 | describe 'pattern matching' do 17 | context 'internal values' do 18 | let(:result) do 19 | case state 20 | in input: 21 | input 22 | end 23 | end 24 | 25 | it 'can extract values from internal state' do 26 | expect(result).to eq('some value') 27 | end 28 | end 29 | 30 | context 'operation context values' do 31 | let(:result) do 32 | case state 33 | in foo:, bar: 10 34 | foo 35 | end 36 | end 37 | 38 | it 'can extract values from operation context' do 39 | expect(result).to eq(20) 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Pablo Herrero 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 | -------------------------------------------------------------------------------- /lib/pathway/rspec/matchers/succeed_on.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define :succeed_on do |input| 4 | match do |operation| 5 | @operation, @input = operation, input 6 | 7 | success? && return_value_matches? 8 | end 9 | 10 | match_when_negated do |operation| 11 | raise NotImplementedError, '`expect().not_to succeed_on(input).returning()` is not supported.' if @value 12 | @operation, @input = operation, input 13 | 14 | !success? 15 | end 16 | 17 | chain :returning do |value| 18 | @value = value 19 | end 20 | 21 | description do 22 | "be successful" 23 | end 24 | 25 | failure_message do 26 | if !success? 27 | "Expected operation to be successful but failed with :#{result.error.type} error" 28 | else 29 | "Expected successful operation to return #{description_of(@value)} but instead got #{description_of(result.value)}" 30 | end 31 | end 32 | 33 | failure_message_when_negated do 34 | 'Did not to expected operation to be successful but it was' 35 | end 36 | 37 | def success? 38 | result.success? 39 | end 40 | 41 | def return_value_matches? 42 | @value.nil? || values_match?(@value, result.value) 43 | end 44 | 45 | def result 46 | @result ||= @operation.call(@input) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/pathway/plugins/responder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pathway 4 | module Plugins 5 | module Responder 6 | module ClassMethods 7 | def call(*args, **kwargs, &bl) 8 | result = super(*args, **kwargs) 9 | block_given? ? Responder.respond(result, &bl) : result 10 | end 11 | end 12 | 13 | class Responder 14 | def self.respond(...) 15 | r = new(...) 16 | r.respond 17 | end 18 | 19 | def initialize(result, &bl) 20 | @result, @context, @fails = result, bl.binding.receiver, {} 21 | instance_eval(&bl) 22 | end 23 | 24 | def success(&bl)= @ok = bl 25 | 26 | def failure(type = nil, &bl) 27 | if type.nil? 28 | @fail_default = bl 29 | else 30 | @fails[type] = bl 31 | end 32 | end 33 | 34 | def respond 35 | if @result.success? 36 | @context.instance_exec(@result.value, &@ok) 37 | elsif Error === @result.error && fail_block = @fails[@result.error.type] 38 | @context.instance_exec(@result.error, &fail_block) 39 | else 40 | @context.instance_exec(@result.error, &@fail_default) 41 | end 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | 5 | if ENV['CI'] 6 | require 'simplecov' 7 | require 'simplecov-lcov' 8 | 9 | SimpleCov.start do 10 | SimpleCov::Formatter::LcovFormatter.config do |c| 11 | c.report_with_single_file = true 12 | c.single_report_path = 'coverage/lcov.info' 13 | end 14 | 15 | formatter SimpleCov::Formatter::LcovFormatter 16 | add_filter '/spec/' 17 | add_filter '/lib/pathway/rspec' 18 | end 19 | end 20 | 21 | require 'pathway' 22 | require 'pathway/rspec' 23 | require 'sequel' 24 | require 'pry' 25 | require 'pry-byebug' 26 | 27 | # Load testing support files 28 | Dir[__dir__ + '/support/**/*.rb'].each { |support| require support } 29 | 30 | RSpec::Matchers.define_negated_matcher :exclude, :include 31 | 32 | RSpec.configure do |config| 33 | config.example_status_persistence_file_path = '.rspec_status' 34 | 35 | config.color = true 36 | config.tty = true 37 | config.formatter = :documentation 38 | config.profile_examples = true 39 | 40 | config.expect_with :rspec do |c| 41 | c.syntax = :expect 42 | c.include_chain_clauses_in_custom_matcher_descriptions = true 43 | end 44 | 45 | config.mock_with :rspec do |c| 46 | c.verify_partial_doubles = true 47 | end 48 | 49 | config.shared_context_metadata_behavior = :apply_to_host_groups 50 | config.filter_run_when_matching :focus 51 | 52 | config.order = :random 53 | Kernel.srand config.seed 54 | end 55 | -------------------------------------------------------------------------------- /lib/pathway/result.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Pathway 4 | class Result 5 | extend Forwardable 6 | attr_reader :value, :error 7 | 8 | class Success < Result 9 | def initialize(value) = @value = value 10 | def success? = true 11 | 12 | def then(bl=nil) 13 | result(block_given? ? yield(value): bl.call(value)) 14 | end 15 | 16 | def tee(...) 17 | follow = self.then(...) 18 | follow.failure? ? follow : self 19 | end 20 | 21 | private 22 | 23 | alias_method :value_for_deconstruct, :value 24 | end 25 | 26 | class Failure < Result 27 | def initialize(error) = @error = error 28 | def success? = false 29 | def then(_=nil) = self 30 | def tee(_=nil) = self 31 | 32 | private 33 | 34 | alias_method :value_for_deconstruct, :error 35 | end 36 | 37 | module Mixin 38 | Success = Result::Success 39 | Failure = Result::Failure 40 | end 41 | 42 | def self.success(value) = Success.new(value) 43 | def self.failure(error) = Failure.new(error) 44 | 45 | def self.result(object) 46 | object.is_a?(Result) ? object : success(object) 47 | end 48 | 49 | def failure? = !success? 50 | def deconstruct = [value_for_deconstruct] 51 | 52 | def deconstruct_keys(keys) 53 | if value_for_deconstruct.respond_to?(:deconstruct_keys) 54 | value_for_deconstruct.deconstruct_keys(keys) 55 | else 56 | {} 57 | end 58 | end 59 | 60 | delegate :result => 'self.class' 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /pathway.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("../lib", __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "pathway/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "pathway" 9 | spec.version = Pathway::VERSION 10 | spec.authors = ["Pablo Herrero"] 11 | spec.email = ["pablodherrero@gmail.com"] 12 | 13 | spec.summary = %q{Define your business logic in simple steps.} 14 | spec.description = %q{Define your business logic in simple steps.} 15 | spec.homepage = "https://github.com/pabloh/pathway" 16 | spec.license = "MIT" 17 | 18 | spec.metadata = { 19 | "bug_tracker_uri" => "https://github.com/pabloh/pathway/issues", 20 | "source_code_uri" => "https://github.com/pabloh/pathway", 21 | } 22 | 23 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 24 | f.match(%r{^(test|spec|features)/}) 25 | end 26 | spec.bindir = "exe" 27 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 28 | spec.require_paths = ["lib"] 29 | 30 | spec.required_ruby_version = ">= 3.2.0" 31 | 32 | spec.add_dependency "dry-inflector", ">= 0.1.0" 33 | spec.add_dependency "contextualizer", "~> 0.1.0" 34 | 35 | spec.add_development_dependency "dry-validation", ">= 1.0" 36 | spec.add_development_dependency "bundler", ">= 2.4.10" 37 | spec.add_development_dependency "sequel", "~> 5.0" 38 | spec.add_development_dependency "rake", "~> 13.0" 39 | spec.add_development_dependency "rspec", "~> 3.11" 40 | spec.add_development_dependency "simplecov-lcov", '~> 0.8.0' 41 | spec.add_development_dependency "simplecov" 42 | spec.add_development_dependency "pry" 43 | spec.add_development_dependency "reline" 44 | spec.add_development_dependency "byebug" 45 | spec.add_development_dependency "pry-byebug" 46 | spec.add_development_dependency "pry-doc" 47 | end 48 | -------------------------------------------------------------------------------- /lib/pathway/rspec/matchers/require_fields.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pathway/rspec/matchers/form_schema_helpers' 4 | 5 | RSpec::Matchers.define :require_fields do |*fields| 6 | match do |form| 7 | @form, @fields = form, fields 8 | 9 | not_defined.empty? && 10 | not_required.empty? && 11 | allowing_null_values_matches? && 12 | not_allowing_null_values_matches? 13 | end 14 | 15 | match_when_negated do |form| 16 | raise NotImplementedError, 'expect().not_to require_fields.not_allowing_null_values is not supported.' if @allowing_null_values || @not_allowing_null_values 17 | 18 | @form, @fields = form, fields 19 | 20 | not_defined.empty? && required.empty? 21 | end 22 | 23 | description do 24 | null_value_allowed = @allowing_null_values ? ' allowing null values' : '' 25 | null_value_disallowed = @not_allowing_null_values ? ' not allowing null values' : '' 26 | "require #{field_list} as #{pluralize_fields}#{null_value_allowed}#{null_value_disallowed}" 27 | end 28 | 29 | failure_message do 30 | null_value_allowed = @allowing_null_values ? ' allowing null values' : '' 31 | null_value_disallowed = @not_allowing_null_values ? ' not allowing null values' : '' 32 | 33 | "Expected to require #{field_list} as #{pluralize_fields}#{null_value_allowed}#{null_value_disallowed} but " + 34 | as_sentence([not_required_list, not_defined_list, accepting_null_list, not_accepting_null_list].compact, 35 | connector: '; ', last_connector: '; and ') 36 | end 37 | 38 | failure_message_when_negated do 39 | "Did not expect to require #{field_list} as #{pluralize_fields} but " + 40 | [required_list, not_defined_list].compact.join('; and ') 41 | end 42 | 43 | include Pathway::Rspec::FormSchemaHelpers 44 | 45 | def required_list 46 | "#{as_list(required)} #{were_was(required)} required" if required.any? 47 | end 48 | 49 | def not_required_list 50 | "#{as_list(not_required)} #{were_was(not_required)} not required" if not_required.any? 51 | end 52 | 53 | chain :allowing_null_values do 54 | fail 'cannot use allowing_null_values and not_allowing_null_values at the same time' if @not_allowing_null_values 55 | 56 | @allowing_null_values = true 57 | end 58 | 59 | chain :not_allowing_null_values do 60 | fail 'cannot use allowing_null_values and not_allowing_null_values at the same time' if @allowing_null_values 61 | 62 | @not_allowing_null_values = true 63 | end 64 | end 65 | 66 | RSpec::Matchers.alias_matcher :require_field, :require_fields 67 | -------------------------------------------------------------------------------- /spec/plugins_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Pathway 6 | describe Operation do 7 | module SimplePlugin 8 | module InstanceMethods 9 | attr :foo 10 | end 11 | 12 | module ClassMethods 13 | attr_accessor :bar 14 | 15 | def inherited(subclass) 16 | super 17 | subclass.bar = bar 18 | end 19 | end 20 | 21 | module DSLMethods 22 | attr :qux 23 | end 24 | 25 | def self.apply(opr, bar: nil) 26 | opr.result_at :the_result 27 | opr.bar = bar 28 | end 29 | end 30 | 31 | class AnOperation < Operation 32 | plugin SimplePlugin, bar: 'SOME VALUE' 33 | end 34 | 35 | class ASubOperation < AnOperation 36 | end 37 | 38 | class OtherOperation < Operation 39 | end 40 | 41 | describe '.plugin' do 42 | it 'includes InstanceMethods module to the class and its subclasses' do 43 | expect(AnOperation.instance_methods).to include(:foo) 44 | expect(ASubOperation.instance_methods).to include(:foo) 45 | end 46 | 47 | it 'includes ClassMethods module to the singleton class and its subclasses' do 48 | expect(AnOperation.methods).to include(:bar) 49 | expect(ASubOperation.methods).to include(:bar) 50 | end 51 | 52 | it 'includes DSLMethods module to the nested DSL class and its subclasses' do 53 | expect(AnOperation::DSL.instance_methods).to include(:qux) 54 | expect(ASubOperation::DSL.instance_methods).to include(:qux) 55 | end 56 | 57 | it "calls 'apply' with its arguments on the Operation where is used" do 58 | expect(AnOperation.result_key).to eq(:the_result) 59 | expect(AnOperation.bar).to eq('SOME VALUE') 60 | expect(ASubOperation.bar).to eq('SOME VALUE') 61 | end 62 | 63 | it 'does not affect main Operation class' do 64 | expect(Operation.instance_methods).to_not include(:foo) 65 | expect(Operation.methods).to_not include(:bar) 66 | expect(Operation::DSL.instance_methods).to_not include(:qux) 67 | expect(Operation.result_key).to eq(:value) 68 | end 69 | 70 | it 'does not affect other Operation subclasses' do 71 | expect(OtherOperation.instance_methods).to_not include(:foo) 72 | expect(OtherOperation.methods).to_not include(:bar) 73 | expect(OtherOperation::DSL.instance_methods).to_not include(:qux) 74 | expect(OtherOperation.result_key).to eq(:value) 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/pathway/rspec/matchers/accept_optional_fields.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pathway/rspec/matchers/form_schema_helpers' 4 | 5 | RSpec::Matchers.define :accept_optional_fields do |*fields| 6 | match do |form| 7 | @form, @fields = form, fields 8 | 9 | not_defined.empty? && 10 | not_optional.empty? && 11 | allowing_null_values_matches? && 12 | not_allowing_null_values_matches? 13 | end 14 | 15 | match_when_negated do |form| 16 | raise NotImplementedError, 'expect().not_to accept_optional_fields.not_allowing_null_values is not supported.' if @allowing_null_values || @not_allowing_null_values 17 | 18 | @form, @fields = form, fields 19 | 20 | not_defined.empty? && optional.empty? 21 | end 22 | 23 | description do 24 | null_value_allowed = @allowing_null_values ? ' allowing null values' : '' 25 | null_value_disallowed = @not_allowing_null_values ? ' not allowing null values' : '' 26 | "accept #{field_list} as optional #{pluralize_fields}#{null_value_allowed}#{null_value_disallowed}" 27 | end 28 | 29 | failure_message do 30 | null_value_allowed = @allowing_null_values ? ' allowing null values' : '' 31 | null_value_disallowed = @not_allowing_null_values ? ' not allowing null values' : '' 32 | 33 | "Expected to accept #{field_list} as optional #{pluralize_fields}#{null_value_allowed}#{null_value_disallowed} but " + 34 | as_sentence([not_optional_list, not_defined_list, accepting_null_list, not_accepting_null_list].compact, 35 | connector: '; ', last_connector: '; and ') 36 | end 37 | 38 | failure_message_when_negated do 39 | "Did not expect to accept #{field_list} as optional #{pluralize_fields} but " + 40 | [optional_list, not_defined_list].compact.join('; and ') 41 | end 42 | 43 | include Pathway::Rspec::FormSchemaHelpers 44 | 45 | def optional_list 46 | "#{as_list(optional)} #{were_was(optional)} optional" if optional.any? 47 | end 48 | 49 | def not_optional_list 50 | "#{as_list(not_optional)} #{were_was(not_optional)} not optional" if not_optional.any? 51 | end 52 | 53 | chain :allowing_null_values do 54 | fail 'cannot use allowing_null_values and not_allowing_null_values at the same time' if @not_allowing_null_values 55 | 56 | @allowing_null_values = true 57 | end 58 | 59 | chain :not_allowing_null_values do 60 | fail 'cannot use allowing_null_values and not_allowing_null_values at the same time' if @allowing_null_values 61 | 62 | @not_allowing_null_values = true 63 | end 64 | end 65 | 66 | RSpec::Matchers.alias_matcher :accept_optional_field, :accept_optional_fields 67 | -------------------------------------------------------------------------------- /lib/pathway/plugins/dry_validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dry/validation' 4 | 5 | module Pathway 6 | module Plugins 7 | module DryValidation 8 | module ClassMethods 9 | attr_reader :contract_class, :contract_options 10 | attr_accessor :auto_wire 11 | 12 | def contract(base = nil, &) 13 | if block_given? 14 | base ||= _base_contract 15 | self.contract_class = Class.new(base, &) 16 | elsif base 17 | self.contract_class = base 18 | else 19 | raise ArgumentError, 'Either a contract class or a block must be provided' 20 | end 21 | end 22 | 23 | def params(...) 24 | contract { params(...) } 25 | end 26 | 27 | def contract_class= klass 28 | @contract_class = klass 29 | @contract_options = (klass.dry_initializer.options - Dry::Validation::Contract.dry_initializer.options).map(&:target) 30 | @builded_contract = @contract_options.empty? && klass.schema ? klass.new : nil 31 | end 32 | 33 | def build_contract(**) 34 | @builded_contract || contract_class.new(**) 35 | end 36 | 37 | def inherited(subclass) 38 | super 39 | subclass.auto_wire = auto_wire 40 | subclass.contract_class = contract_class 41 | end 42 | 43 | private 44 | 45 | def _base_contract 46 | superclass.respond_to?(:contract_class) ? superclass.contract_class : Dry::Validation::Contract 47 | end 48 | end 49 | 50 | module InstanceMethods 51 | extend Forwardable 52 | 53 | delegate %i[build_contract contract_options auto_wire] => 'self.class' 54 | alias_method :contract, :build_contract 55 | 56 | def validate(state, with: nil) 57 | if auto_wire && contract_options.any? 58 | with ||= contract_options.zip(contract_options).to_h 59 | end 60 | opts = Hash(with).map { |to, from| [to, state[from]] }.to_h 61 | validate_with(state[:input], **opts) 62 | .then { |params| state.update(params:) } 63 | end 64 | 65 | def validate_with(input, **) 66 | result = contract(**).call(input) 67 | 68 | result.success? ? wrap(result.values.to_h) : error(:validation, details: result.errors.to_h) 69 | end 70 | end 71 | 72 | def self.apply(operation, auto_wire: false) 73 | operation.auto_wire = auto_wire 74 | operation.contract_class = Dry::Validation::Contract 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/plugins/auto_deconstruct_state_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Pathway 6 | module Plugins 7 | describe 'AutoDeconstructState' do 8 | class KwargsOperation < Operation 9 | plugin :auto_deconstruct_state 10 | 11 | context :validator, :name_repo, :email_repo, :notifier 12 | 13 | process do 14 | step :custom_validate 15 | step :fetch_and_set_name, with: :id 16 | set :fetch_email, to: :email 17 | set :create_model 18 | step :notify 19 | end 20 | 21 | def custom_validate(state) 22 | state[:params] = @validator.call(state[:input]) 23 | end 24 | 25 | def fetch_and_set_name(state, with:) 26 | state[:name] = @name_repo.call(state[:params][with]) 27 | end 28 | 29 | def fetch_email(name:, **, &_) 30 | @email_repo.call(name) 31 | end 32 | 33 | def create_model(name:, email:, **) 34 | UserModel.new(name, email) 35 | end 36 | 37 | def notify(s) 38 | s.u do |value:| 39 | @notifier.call(value) 40 | end 41 | end 42 | end 43 | 44 | UserModel = Struct.new(:name, :email) 45 | 46 | describe "#call" do 47 | subject(:operation) { KwargsOperation.new(ctx) } 48 | 49 | let(:ctx) { { validator: validator, name_repo: name_repo, email_repo: email_repo, notifier: notifier } } 50 | let(:name) { 'Paul Smith' } 51 | let(:email) { 'psmith@email.com' } 52 | let(:input) { { id: 99 } } 53 | 54 | let(:validator) do 55 | double.tap do |val| 56 | allow(val).to receive(:call) do |input_arg| 57 | expect(input_arg).to eq(input) 58 | 59 | input 60 | end 61 | end 62 | end 63 | 64 | let(:name_repo) do 65 | double.tap do |repo| 66 | allow(repo).to receive(:call).with(99).and_return(name) 67 | end 68 | end 69 | 70 | let(:email_repo) do 71 | double.tap do |repo| 72 | allow(repo).to receive(:call).with(name).and_return(email) 73 | end 74 | end 75 | 76 | let(:notifier) do 77 | double.tap do |repo| 78 | allow(repo).to receive(:call) do |value| 79 | expect(value).to be_a(UserModel).and(have_attributes(name: name, email: email)) 80 | end 81 | end 82 | end 83 | 84 | it 'destructure arguments on steps with only kwargs', :aggregate_failures do 85 | operation.call(input) 86 | end 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/pathway/rspec/matchers/fail_on.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pathway/rspec/matchers/list_helpers' 4 | 5 | RSpec::Matchers.define :fail_on do |input| 6 | match do |operation| 7 | @operation, @input = operation, input 8 | 9 | failure? && type_matches? && message_matches? && details_matches? 10 | end 11 | 12 | match_when_negated do |operation| 13 | raise NotImplementedError, '`expect().not_to fail_on(input).with_type()` is not supported.' if @type 14 | raise NotImplementedError, '`expect().not_to fail_on(input).with_message()` is not supported.' if @message 15 | raise NotImplementedError, '`expect().not_to fail_on(input).with_details()` is not supported.' if @details 16 | @operation, @input = operation, input 17 | 18 | !failure? 19 | end 20 | 21 | chain :type do |type| 22 | @type = type 23 | end 24 | 25 | alias_method :with_type, :type 26 | alias_method :and_type, :type 27 | 28 | chain :message do |message| 29 | @message = message 30 | end 31 | 32 | alias_method :with_message, :message 33 | alias_method :and_message, :message 34 | 35 | chain :details do |details| 36 | @details = details 37 | end 38 | 39 | alias_method :with_details, :details 40 | alias_method :and_details, :details 41 | 42 | description do 43 | 'fail' + (@type ? " with :#@type error" : '') 44 | end 45 | 46 | failure_message do 47 | if !failure? 48 | "Expected operation to fail but it didn't" 49 | else 50 | 'Expected failed operation to ' + 51 | as_sentence(failure_descriptions, connector: '; ', last_connector: '; and ') 52 | end 53 | end 54 | 55 | failure_message_when_negated do 56 | 'Did not expected operation to fail but it did' 57 | end 58 | 59 | def failure? 60 | result.failure? 61 | end 62 | 63 | def type_matches? 64 | @type.nil? || @type == error.type 65 | end 66 | 67 | def message_matches? 68 | @message.nil? || values_match?(@message, error.message) 69 | end 70 | 71 | def details_matches? 72 | @details.nil? || values_match?(@details, error.details) 73 | end 74 | 75 | def result 76 | @result ||= @operation.call(@input) 77 | end 78 | 79 | def error 80 | result.error 81 | end 82 | 83 | def failure_descriptions 84 | [type_failure_description, message_failure_description, details_failure_description].compact 85 | end 86 | 87 | def type_failure_description 88 | type_matches? ? nil : "have type :#@type but instead was :#{error.type}" 89 | end 90 | 91 | def message_failure_description 92 | message_matches? ? nil : "have message like #{description_of(@message)} but instead got #{description_of(error.message)}" 93 | end 94 | 95 | def details_failure_description 96 | details_matches? ? nil : "have details like #{description_of(@details)} but instead got #{description_of(error.details)}" 97 | end 98 | 99 | include Pathway::Rspec::ListHelpers 100 | end 101 | -------------------------------------------------------------------------------- /lib/pathway/rspec/matchers/form_schema_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pathway/rspec/matchers/field_list_helpers' 4 | 5 | module Pathway 6 | module Rspec 7 | module FormSchemaHelpers 8 | include FieldListHelpers 9 | 10 | if defined?(::Dry::Validation::Contract) 11 | def rules 12 | @form.schema.rules 13 | end 14 | else 15 | def rules 16 | @form.rules 17 | end 18 | end 19 | 20 | def not_defined_list 21 | "#{as_list(not_defined)} #{were_was(not_defined)} not defined" if not_defined.any? 22 | end 23 | 24 | def accepting_null_list 25 | "#{as_list(null_value_allowed)} #{were_was(null_value_allowed)} accepting null values" if @not_allowing_null_values && null_value_allowed.any? 26 | end 27 | 28 | def not_accepting_null_list 29 | "#{as_list(null_value_disallowed)} #{were_was(null_value_disallowed)} not accepting null values" if @allowing_null_values && null_value_disallowed.any? 30 | end 31 | 32 | def required 33 | @required ||= @fields.select { |field| required?(field) } 34 | end 35 | 36 | def optional 37 | @optional ||= @fields.select { |field| optional?(field) } 38 | end 39 | 40 | def null_value_allowed 41 | @null_value_allowed ||= @fields.select { |field| null_value_allowed?(field) } 42 | end 43 | 44 | def null_value_disallowed 45 | @null_value_disallowed ||= @fields.select { |field| null_value_disallowed?(field) } 46 | end 47 | 48 | def not_required 49 | @not_required ||= defined - required 50 | end 51 | 52 | def not_optional 53 | @not_optional ||= defined - optional 54 | end 55 | 56 | def not_defined 57 | @not_defined ||= @fields - defined 58 | end 59 | 60 | def defined 61 | @defined ||= @fields & rules.keys 62 | end 63 | 64 | def optional?(field) 65 | if rules[field]&.type == :implication 66 | left = rules[field].left 67 | 68 | left.type == :predicate && left.name == :key? && left.args.first == field 69 | end 70 | end 71 | 72 | def required?(field) 73 | if rules[field]&.type == :and 74 | left = rules[field].left 75 | 76 | left.type == :predicate && left.name == :key? && left.args.first == field 77 | end 78 | end 79 | 80 | def allowing_null_values_matches? 81 | @allowing_null_values ? @fields.all? { |field| null_value_allowed?(field) } : true 82 | end 83 | 84 | def not_allowing_null_values_matches? 85 | @not_allowing_null_values ? @fields.all? { |field| null_value_disallowed?(field) } : true 86 | end 87 | 88 | def null_value_allowed?(field) 89 | rule = rules[field]&.right&.rule 90 | predicate = rule&.left 91 | predicate.present? && predicate.type == :not && predicate.rules&.first&.name == :nil? 92 | end 93 | 94 | def null_value_disallowed?(field) 95 | rule = rules[field]&.right&.rule 96 | predicate = rule&.left 97 | predicate.present? && predicate.type == :predicate && predicate.name == :filled? 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN) 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../Gemfile", __dir__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, ".locked") 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_requirement 64 | @bundler_requirement ||= 65 | env_var_version || 66 | cli_arg_version || 67 | bundler_requirement_for(lockfile_version) 68 | end 69 | 70 | def bundler_requirement_for(version) 71 | return "#{Gem::Requirement.default}.a" unless version 72 | 73 | bundler_gem_version = Gem::Version.new(version) 74 | 75 | bundler_gem_version.approximate_recommendation 76 | end 77 | 78 | def load_bundler! 79 | ENV["BUNDLE_GEMFILE"] ||= gemfile 80 | 81 | activate_bundler 82 | end 83 | 84 | def activate_bundler 85 | gem_error = activation_error_handling do 86 | gem "bundler", bundler_requirement 87 | end 88 | return if gem_error.nil? 89 | require_error = activation_error_handling do 90 | require "bundler/version" 91 | end 92 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 93 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 94 | exit 42 95 | end 96 | 97 | def activation_error_handling 98 | yield 99 | nil 100 | rescue StandardError, LoadError => e 101 | e 102 | end 103 | end 104 | 105 | m.load_bundler! 106 | 107 | if m.invoked_as_script? 108 | load Gem.bin_path("bundler", "bundle") 109 | end 110 | -------------------------------------------------------------------------------- /spec/plugins/responder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Pathway 6 | module Plugins 7 | describe 'Responder' do 8 | class RespOperation < Operation 9 | plugin :responder 10 | 11 | context :with 12 | 13 | def call(_) 14 | with 15 | end 16 | end 17 | 18 | let(:input) { {} } 19 | let(:context) { { with: passed_result} } 20 | 21 | describe ".call" do 22 | context "when no block is given" do 23 | let(:passed_result) { Result.success("VALUE") } 24 | let(:result) { RespOperation.(context, input) } 25 | 26 | it "instances an operation an executes 'call'", :aggregate_failures do 27 | expect(result).to be_kind_of(Pathway::Result) 28 | expect(result.value).to eq("VALUE") 29 | end 30 | end 31 | 32 | context "when a block is given" do 33 | context "provided with a single 'failure' block," do 34 | let(:result) do 35 | RespOperation.call(context, input) do 36 | success { |value| "Returning: " + value } 37 | failure { |error| error} 38 | end 39 | end 40 | 41 | context "and the result is succesfull" do 42 | let(:passed_result) { Result.success("VALUE") } 43 | 44 | it "executes the success block" do 45 | expect(result).to eq("Returning: VALUE") 46 | end 47 | end 48 | 49 | context "and the result is unsuccesfull" do 50 | let(:passed_result) { Result.failure("AN ERROR!") } 51 | 52 | it "executes the failure block" do 53 | expect(result).to eq("AN ERROR!") 54 | end 55 | end 56 | end 57 | 58 | context "provided with many 'failure' blocks," do 59 | let(:result) do 60 | RespOperation.call(context, input) do 61 | success { |value| "Returning: " + value } 62 | failure(:forbidden) { |error| "Forbidden" } 63 | failure(:validation) { |error| "Invalid: " + error.details.join(", ") } 64 | failure { |error| "Other: " + error.details.join(" ") } 65 | end 66 | end 67 | 68 | context "and the result is succesfull" do 69 | let(:passed_result) { Result.success("VALUE") } 70 | 71 | it "executes the success block" do 72 | expect(result).to eq("Returning: VALUE") 73 | end 74 | end 75 | 76 | context "and the result is an error of a specified type" do 77 | let(:passed_result) do 78 | Result.failure(Error.new(type: :validation, details: ['name missing', 'email missing'])) 79 | end 80 | 81 | it "executes the right failure type block" do 82 | expect(result).to eq("Invalid: name missing, email missing") 83 | end 84 | end 85 | 86 | context "and the result is an error of an unspecified type" do 87 | let(:passed_result) { Result.failure(Error.new(type: :misc, details: %w[some errors])) } 88 | 89 | it "executes the general failure block " do 90 | expect(result).to eq("Other: some errors") 91 | end 92 | end 93 | end 94 | end 95 | end 96 | 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/state_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Pathway 6 | describe State do 7 | class SimpleOp < Operation 8 | context :foo, bar: 10 9 | result_at :the_result 10 | end 11 | 12 | let(:operation) { SimpleOp.new(foo: 20) } 13 | let(:values) { { input: 'some value' } } 14 | subject(:state) { State.new(operation, values) } 15 | 16 | describe '#initialize' do 17 | it 'initialize its variables from the operation context and values argument' do 18 | expect(state.to_hash).to eq(foo: 20, bar: 10, input: 'some value') 19 | end 20 | end 21 | 22 | describe '#to_hash' do 23 | let(:result) { state.update(foobar: 25).to_hash } 24 | it 'returns a hash with its internal values' do 25 | expect(result).to be_a(Hash) 26 | .and eq(foo: 20, bar: 10, input: 'some value', foobar: 25) 27 | end 28 | end 29 | 30 | describe '#use' do 31 | before { state.update(val: 'RESULT', foo: 99, bar: 131) } 32 | it 'fails if no block is provided' do 33 | expect { state.use }.to raise_error('a block must be provided') 34 | end 35 | 36 | context 'when a block is provided' do 37 | it 'passes specified values using only keyword params', :aggregate_failures do 38 | expect(state.use {|val:| val }).to eq('RESULT') 39 | expect(state.use {|foo:| foo }).to eq(99) 40 | expect(state.use {|val:, bar:| [val, bar] }) 41 | .to eq(['RESULT', 131]) 42 | end 43 | 44 | it 'passes no arguments if no keyword or positional params are defined' do 45 | expect(state.use { 77 }).to eq(77) 46 | end 47 | 48 | it 'passes specified values using only positional params', :aggregate_failures do 49 | expect(state.use {|val| val }).to eq('RESULT') 50 | expect(state.use {|foo| foo }).to eq(99) 51 | expect(state.use {|val, bar| [val, bar] }) 52 | end 53 | 54 | it 'fails if positional and keyword params are both defined', :aggregate_failures do 55 | expect { state.use {|pos, input:| } } 56 | .to raise_error('cannot mix positional and keyword arguments') 57 | end 58 | 59 | it 'fails if using rest param', :aggregate_failures do 60 | expect { state.use {|*input| } } 61 | .to raise_error('rest arguments are not supported') 62 | expect { state.use {|input, *args| args } } 63 | .to raise_error('rest arguments are not supported') 64 | end 65 | 66 | it 'fails if using keyrest param', :aggregate_failures do 67 | expect { state.use {|**kargs| kargs } } 68 | .to raise_error('rest arguments are not supported') 69 | expect { state.use {|input:, **kargs| kargs } } 70 | .to raise_error('rest arguments are not supported') 71 | end 72 | 73 | context 'that takes a block argument' do 74 | it 'fails if it has positional and keyword params' do 75 | expect { state.use {|input, val:, &bl| } } 76 | .to raise_error('cannot mix positional and keyword arguments') 77 | end 78 | 79 | it 'does not fails if only has keyword params', :aggregate_failures do 80 | expect(state.use {|val:, &bl| val }).to eq('RESULT') 81 | expect(state.use {|val:, &_| val }).to eq('RESULT') 82 | expect(state.use {|&_| 77 }).to eq(77) 83 | end 84 | 85 | it 'does not fails if only has positional params', :aggregate_failures do 86 | expect(state.use {|val, &bl| val }).to eq('RESULT') 87 | expect(state.use {|val, &_| val }).to eq('RESULT') 88 | end 89 | end 90 | 91 | end 92 | end 93 | 94 | describe '#update' do 95 | let(:result) { state.update(qux: 33, quz: 11) } 96 | it 'returns and updated state with the passed values' do 97 | expect(result.to_hash).to include(qux: 33, quz: 11) 98 | end 99 | end 100 | 101 | describe '#result' do 102 | let(:result) { state.update(the_result: 99).result } 103 | it 'returns the value corresponding to the result key' do 104 | expect(result).to eq(99) 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/pathway/plugins/sequel_models.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'sequel/model' 4 | 5 | module Pathway 6 | module Plugins 7 | module SequelModels 8 | module DSLMethods 9 | def transaction(step_name = nil, if: nil, unless: nil, &steps) 10 | _with_db_steps(steps, step_name, *_opts_if_unless(binding)) do |runner| 11 | db.transaction(savepoint: true) do 12 | raise Sequel::Rollback if runner.call.failure? 13 | end 14 | end 15 | end 16 | 17 | def after_commit(step_name = nil, if: nil, unless: nil, &steps) 18 | _with_db_steps(steps, step_name, *_opts_if_unless(binding)) do |runner, state| 19 | dsl_copy = _dsl_for(state) 20 | db.after_commit { runner.call(dsl_copy) } 21 | end 22 | end 23 | 24 | def after_rollback(step_name = nil, if: nil, unless: nil, &steps) 25 | _with_db_steps(steps, step_name, *_opts_if_unless(binding)) do |runner, state| 26 | dsl_copy = _dsl_for(state) 27 | db.after_rollback(savepoint: true) { runner.call(dsl_copy) } 28 | end 29 | end 30 | 31 | private 32 | 33 | def _opts_if_unless(bg) = %i[if unless].map { bg.local_variable_get(_1) } 34 | 35 | def _with_db_steps(steps, step_name=nil, if_cond=nil, unless_cond=nil, &db_logic) 36 | raise ArgumentError, 'options :if and :unless are mutually exclusive' if if_cond && unless_cond 37 | raise ArgumentError, 'must provide either a step or a block but not both' if !!step_name == !!steps 38 | steps ||= proc { step step_name } 39 | 40 | if if_cond 41 | if_true(if_cond) { _with_db_steps(steps, &db_logic) } 42 | elsif unless_cond 43 | if_false(unless_cond) { _with_db_steps(steps, &db_logic) } 44 | else 45 | around(db_logic, &steps) 46 | end 47 | end 48 | end 49 | 50 | module ClassMethods 51 | attr_accessor :model_class, :search_field, :model_not_found 52 | 53 | def model(model_class, search_by: model_class.primary_key, set_result_key: true, set_context_param: true, error_message: nil) 54 | self.model_class = model_class 55 | self.search_field = search_by 56 | self.result_key = Inflector.underscore(Inflector.demodulize(model_class.name)).to_sym if set_result_key 57 | self.model_not_found = error_message || "#{Inflector.humanize(Inflector.underscore(Inflector.demodulize(model_class.name)))} not found".freeze 58 | 59 | self.context(result_key => Contextualizer::OPTIONAL) if set_result_key && set_context_param 60 | end 61 | 62 | def inherited(subclass) 63 | super 64 | subclass.model_class = model_class 65 | subclass.search_field = search_field 66 | subclass.model_not_found = model_not_found 67 | end 68 | end 69 | 70 | module InstanceMethods 71 | extend Forwardable 72 | delegate %i[model_class search_field model_not_found] => 'self.class' 73 | delegate :db => :model_class 74 | 75 | def fetch_model(state, from: model_class, search_by: search_field, using: search_by, to: result_key, overwrite: false, error_message: nil) 76 | error_message ||= if (from == model_class) 77 | model_not_found 78 | elsif from.respond_to?(:name) || from.respond_to?(:model) 79 | from_name = (from.respond_to?(:name) ? from : from.model).name 80 | Inflector.humanize(Inflector.underscore(Inflector.demodulize(from_name))) + ' not found' 81 | end 82 | 83 | if state[to].nil? || overwrite 84 | wrap_if_present(state[:input][using], message: error_message) 85 | .then { |key| find_model_with(key, from, search_by, error_message) } 86 | .then { |model| state.update(to => model) } 87 | else 88 | state 89 | end 90 | end 91 | 92 | def find_model_with(key, dataset = model_class, column = search_field, error_message = nil) 93 | wrap_if_present(dataset.first(column => key), message: error_message) 94 | end 95 | end 96 | 97 | def self.apply(operation, model: nil, **kwargs) 98 | operation.model(model, **kwargs) if model 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /spec/result_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Pathway 6 | describe Result do 7 | 8 | describe ".success" do 9 | let(:result) { Result.success("VALUE") } 10 | it "returns a Success object with passed value", :aggregate_failures do 11 | expect(result).to be_a(Result::Success) 12 | expect(result.value).to eq("VALUE") 13 | end 14 | end 15 | 16 | describe ".failure" do 17 | let(:result) { Result.failure("ERROR!") } 18 | it "returns a Failure object with passed value", :aggregate_failures do 19 | expect(result).to be_a(Result::Failure) 20 | expect(result.error).to eq("ERROR!") 21 | end 22 | end 23 | 24 | describe ".result" do 25 | let(:result) { Result.result(object) } 26 | context "when passing a Result object" do 27 | let(:object) { Result.failure(:something_went_wrong) } 28 | it "returns the object itsef" do 29 | expect(result).to eq(object) 30 | end 31 | end 32 | 33 | context "when passing a regular object" do 34 | let(:object) { "SOME VALUE" } 35 | it "returns the object wrapped in a result", :aggregate_failures do 36 | expect(result).to be_a(Result::Success) 37 | expect(result.value).to eq("SOME VALUE") 38 | end 39 | end 40 | end 41 | 42 | context "when is a success" do 43 | subject(:prev_result) { Result.success("VALUE") } 44 | describe "#success?" do 45 | it { expect(prev_result.success?).to be true } 46 | end 47 | 48 | describe "#failure?" do 49 | it { expect(prev_result.failure?).to be false } 50 | end 51 | 52 | describe "#then" do 53 | let(:callable) { double } 54 | let(:next_result) { Result.success("NEW VALUE")} 55 | before { expect(callable).to receive(:call).with("VALUE").and_return(next_result) } 56 | 57 | it "if a block is given it executes it and returns the new result" do 58 | expect(prev_result.then { |prev| callable.call(prev) }).to eq(next_result) 59 | end 60 | 61 | it "if a callable is given it executes it and returns the new result" do 62 | expect(prev_result.then(callable)).to eq(next_result) 63 | end 64 | end 65 | 66 | describe "#tee" do 67 | let(:callable) { double } 68 | let(:next_result) { Result.success("NEW VALUE")} 69 | before { expect(callable).to receive(:call).with("VALUE").and_return(next_result) } 70 | 71 | it "if a block is given it executes it and keeps the previous result" do 72 | expect(prev_result.tee { |prev| callable.call(prev) }).to eq(prev_result) 73 | end 74 | 75 | context "when a block wich returns an unwrapped result is given" do 76 | let(:next_result) { "NEW VALUE" } 77 | it "it executes it and keeps the previous result" do 78 | expect(prev_result.tee { |prev| callable.call(prev) }).to eq(prev_result) 79 | end 80 | end 81 | 82 | it "if a callable is given it executes it and keeps the previous result" do 83 | expect(prev_result.tee(callable)).to eq(prev_result) 84 | end 85 | end 86 | end 87 | 88 | context "when is a failure" do 89 | subject(:prev_result) { Result.failure(:something_wrong) } 90 | describe "#success?" do 91 | it { expect(prev_result.success?).to be false } 92 | end 93 | 94 | describe "#failure?" do 95 | it { expect(prev_result.failure?).to be true } 96 | end 97 | 98 | describe "#tee" do 99 | let(:callable) { double } 100 | before { expect(callable).to_not receive(:call) } 101 | 102 | it "if a block is given it ignores it and returns itself" do 103 | expect(prev_result.tee { |prev| callable.call(prev) }).to eq(prev_result) 104 | end 105 | 106 | it "if a callable is given it ignores it and returns itself" do 107 | expect(prev_result.tee(callable)).to eq(prev_result) 108 | end 109 | end 110 | 111 | describe "#then" do 112 | let(:callable) { double } 113 | before { expect(callable).to_not receive(:call) } 114 | 115 | it "if a block is given it ignores it and returns itself" do 116 | expect(prev_result.then { |prev| callable.call(prev) }).to eq(prev_result) 117 | end 118 | 119 | it "if a callable is given it ignores it and returns itself" do 120 | expect(prev_result.then(callable)).to eq(prev_result) 121 | end 122 | end 123 | end 124 | 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /spec/plugins/simple_auth_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Pathway 6 | module Plugins 7 | describe 'SimpleAuth' do 8 | class AuthOperation < Operation 9 | plugin :simple_auth 10 | 11 | context :user 12 | 13 | authorization { user.role == :admin } 14 | 15 | process do 16 | step :authorize 17 | end 18 | end 19 | 20 | class AuthOperationParam < Operation 21 | plugin :simple_auth 22 | 23 | context value: :RESULT 24 | 25 | authorization { |value| value == :RESULT } 26 | 27 | process do 28 | step :authorize 29 | end 30 | end 31 | 32 | class AuthOperationMultiParam < Operation 33 | plugin :simple_auth 34 | 35 | context :value1, :value2 36 | 37 | authorization { |first, second| first == 10 && second == 20 } 38 | 39 | process do 40 | step :authorize, using: %i[value1 value2] 41 | end 42 | end 43 | 44 | 45 | class AuthOperationWithArray < Operation 46 | plugin :simple_auth 47 | 48 | context :values 49 | 50 | authorization { |values| values.size.even? } 51 | 52 | process do 53 | step :authorize, using: :values 54 | end 55 | end 56 | 57 | describe "#authorize" do 58 | subject(:operation) { AuthOperationParam.new } 59 | 60 | context "with no options" do 61 | it "passes the current result to the authorization block to authorize", :aggregate_failures do 62 | expect(operation.authorize({value: :RESULT})).to be_a_success 63 | end 64 | end 65 | 66 | context "with :using argument" do 67 | it "passes then value for :key from the context to the authorization block to authorize", :aggregate_failures do 68 | expect(operation.authorize({foo: :RESULT}, using: :foo)).to be_a_success 69 | expect(operation.authorize({foo: :ELSE}, using: :foo)).to be_a_failure 70 | end 71 | end 72 | end 73 | 74 | describe "#call" do 75 | context "when the authorization blocks expects no params" do 76 | subject(:operation) { AuthOperation.new(context) } 77 | let(:context) { { user: double(role: role) } } 78 | 79 | context "and calling with proper authorization" do 80 | let(:role) { :admin } 81 | it "returns a successful result", :aggregate_failures do 82 | expect(operation).to succeed_on({}) 83 | end 84 | end 85 | 86 | context "and calling with without proper authorization" do 87 | let(:role) { :user } 88 | it "returns a failed result", :aggregate_failures do 89 | expect(operation).to fail_on({}).with_type(:forbidden) 90 | end 91 | end 92 | end 93 | 94 | context "when the authorization blocks expects a single param" do 95 | context "and calling with proper authorization" do 96 | subject(:operation) { AuthOperationParam.new } 97 | it "returns a successful result", :aggregate_failures do 98 | expect(operation).to succeed_on({}) 99 | end 100 | end 101 | 102 | context "and calling without proper authorization" do 103 | subject(:operation) { AuthOperationParam.new(value: :OTHER) } 104 | it "returns a failed result", :aggregate_failures do 105 | expect(operation).to fail_on({}).with_type(:forbidden) 106 | end 107 | end 108 | end 109 | 110 | context "when the authorization blocks expects multiple params" do 111 | context "and calling with proper authorization" do 112 | subject(:operation) { AuthOperationMultiParam.new(value1: 10, value2: 20) } 113 | it "returns a successful result", :aggregate_failures do 114 | expect(operation).to succeed_on({}) 115 | end 116 | end 117 | 118 | context "and calling without proper authorization" do 119 | subject(:operation) { AuthOperationMultiParam.new(value1: -11, value2: 99) } 120 | it "returns a failed result", :aggregate_failures do 121 | expect(operation).to fail_on({}).with_type(:forbidden) 122 | end 123 | end 124 | end 125 | 126 | context "when the authorization blocks expects an array as param" do 127 | context "and calling with proper authorization" do 128 | subject(:operation) { AuthOperationWithArray.new(values: [3, 5]) } 129 | it "returns a successful result", :aggregate_failures do 130 | expect(operation).to succeed_on({}) 131 | end 132 | end 133 | 134 | context "and calling without proper authorization" do 135 | subject(:operation) { AuthOperationWithArray.new(values: [3, 4, 5]) } 136 | it "returns a failed result", :aggregate_failures do 137 | expect(operation).to fail_on({}).with_type(:forbidden) 138 | end 139 | end 140 | end 141 | end 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /spec/operation_call_pattern_matching_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Pathway 6 | class Result 7 | module Mixin 8 | describe 'Operation call with pattern matching' do 9 | class RespOperation < Operation 10 | context :with 11 | 12 | def call(_) 13 | with 14 | end 15 | end 16 | 17 | let(:input) { {} } 18 | let(:context) { { with: passed_result } } 19 | 20 | context "when calling operation using 'case'" do 21 | context "providing a single variable name as pattern" do 22 | let(:result) do 23 | case RespOperation.call(context, input) 24 | in Success(value) then "Returning: " + value 25 | in Failure(error) then error.message 26 | end 27 | end 28 | 29 | context "and the result is succesfull" do 30 | let(:passed_result) { Result.success("VALUE") } 31 | 32 | it "returns the success result" do 33 | expect(result).to eq("Returning: VALUE") 34 | end 35 | end 36 | 37 | context "and the result is a failure" do 38 | let(:passed_result) { Result.failure(Error.new(type: :error, message: "AN ERROR!")) } 39 | 40 | it "returns the failure result" do 41 | expect(result).to eq("AN ERROR!") 42 | end 43 | end 44 | end 45 | 46 | 47 | context "providing Hash based patterns," do 48 | context "and the underlying result does not support Hash based patterns" do 49 | let(:passed_result) { Result.success("VALUE") } 50 | 51 | it "raises a non matching error" do 52 | expect { 53 | case RespOperation.call(context, input) 54 | in Success(value:) then value 55 | in Failure(error) then error 56 | end 57 | }.to raise_error(NoMatchingPatternError) 58 | end 59 | end 60 | 61 | let(:result) do 62 | case RespOperation.call(context, input) 63 | in Success(value) then "Returning: " + value 64 | in Failure(type: :forbidden) then "Forbidden" 65 | in Failure(type: :validation, details:) then "Invalid: " + details.join(", ") 66 | in Failure(details:) then "Other: " + details.join(" ") 67 | end 68 | end 69 | 70 | context "and the result is succesfull" do 71 | let(:passed_result) { Result.success("VALUE") } 72 | 73 | it "returns the success result" do 74 | expect(result).to eq("Returning: VALUE") 75 | end 76 | end 77 | 78 | context "the result is a failure" do 79 | context "and the pattern is Failure with only :type specified" do 80 | let(:passed_result) do 81 | Result.failure(Error.new(type: :forbidden)) 82 | end 83 | 84 | it "returns the result according to :type" do 85 | expect(result).to eq("Forbidden") 86 | end 87 | end 88 | 89 | context "and the pattern is Failure with :type and :details specified" do 90 | let(:passed_result) do 91 | Result.failure(Error.new(type: :validation, details: ['name missing', 'email missing'])) 92 | end 93 | 94 | it "returns the result according to :type" do 95 | expect(result).to eq("Invalid: name missing, email missing") 96 | end 97 | end 98 | 99 | context "and the pattern is Failure with no specified :type" do 100 | let(:passed_result) { Result.failure(Error.new(type: :misc, details: %w[some errors])) } 101 | 102 | it "executes the least specific pattern" do 103 | expect(result).to eq("Other: some errors") 104 | end 105 | end 106 | end 107 | end 108 | 109 | context "providing Array based patterns," do 110 | let(:result) do 111 | case RespOperation.call(context, input) 112 | in Success(value) then "Returning: " + value 113 | in Failure([:forbidden,]) then "Forbidden" 114 | in Failure([:validation, _, details]) then "Invalid: " + details.join(", ") 115 | in Failure(type: :validation, details:) then "Invalid: " + details.join(", ") 116 | in Failure([*, details]) then "Other: " + details.join(" ") 117 | end 118 | end 119 | 120 | context "and the result is succesfull" do 121 | let(:passed_result) { Result.success("VALUE") } 122 | 123 | it "returns the success result" do 124 | expect(result).to eq("Returning: VALUE") 125 | end 126 | end 127 | 128 | context "the result is a failure" do 129 | context "and the pattern is Failure with only :type specified" do 130 | let(:passed_result) do 131 | Result.failure(Error.new(type: :forbidden)) 132 | end 133 | 134 | it "returns the result according to :type" do 135 | expect(result).to eq("Forbidden") 136 | end 137 | end 138 | 139 | context "and the pattern is Failure with :type and :details specified" do 140 | let(:passed_result) do 141 | Result.failure(Error.new(type: :validation, details: ['name missing', 'email missing'])) 142 | end 143 | 144 | it "returns the result according to :type" do 145 | expect(result).to eq("Invalid: name missing, email missing") 146 | end 147 | end 148 | 149 | context "and the pattern is Failure with no specified :type" do 150 | let(:passed_result) { Result.failure(Error.new(type: :misc, details: %w[some errors])) } 151 | 152 | it "executes the least specific pattern" do 153 | expect(result).to eq("Other: some errors") 154 | end 155 | end 156 | end 157 | end 158 | end 159 | end 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.2.0] - 2025-11-16 2 | ### Deprecated 3 | - Deprecate passing a block to the step method using `DSLMethods#step` 4 | - Deprecate passing a block to the step method using `DSLMethods#set` 5 | - Deprecate `DSLMethods#map` 6 | ### Changed 7 | - Removed deprecated `:auto_wire_options` option from `:dry_validation` plugin 8 | 9 | ## [1.1.0] - 2025-05-30 10 | ### Added 11 | - Added `:if` and `:unless` options for `:transaction` and `:after_commit` methods at `:sequel_models` plugin 12 | - Added `:after_rollback` method at `:sequel_models` plugin 13 | ### Fixed 14 | - Fixed bug where setting a callback inside an `around` block could unexpectedly change the operation's result 15 | ### Changed 16 | - Removed support for `Ruby` 3.1 17 | 18 | ## [1.0.0] - 2025-05-19 19 | ### Changed 20 | - Removed support for `Ruby` versions older than 3.0 21 | - Removed support for `dry-validation` versions older than 1.0 22 | 23 | ## [0.12.3] - 2024-08-13 24 | ### Changed 25 | - Renamed config option `:auto_wire_options` to `:auto_wire` at `:dry_validation` plugin 26 | - Updated `Pathway::State#use` to accept block with postional parameters 27 | - Updated `Pathway::State#use` to raise an `ArgumentError` exception on invalid arguments 28 | ### Added 29 | - Provide alias `Pathway::State#use` to `Pathway::State#unwrap` 30 | 31 | ## [0.12.2] - 2024-08-06 32 | ### Added 33 | - Add `Pathway::State#unwrap` and `Pathway::State#u` to access internal state 34 | 35 | ## [0.12.1] - 2024-06-23 36 | ### Added 37 | - Add support for pattern matching on `Result`, `State` and `Error` instances 38 | - Add `Pathway::Result::Mixin` to allow easy constant lookup for `Result::Success` and `Result::Failure` 39 | 40 | ## [0.12.0] - 2022-05-31 41 | ### Changed 42 | - Improve compatibility with Ruby 3.0 43 | ### Added 44 | - Add plugin `:auto_deconstruct_state` to help migrating old apps to Ruby 3.0 45 | 46 | ## [0.11.3] - 2020-07-22 47 | ### Changed 48 | - Use default error message on `:fetch_model` step, at `:sequel_models` plugin, if model type cannot be determined 49 | 50 | ## [0.11.2] - 2020-07-22 51 | ### Changed 52 | - Improve `from:` option for `:fetch_model` step, at `:sequel_models` plugin, to also accept a Sequel Dataset 53 | 54 | ## [0.11.1] - 2020-01-09 55 | ### Changed 56 | - Improve custom `rspec` matchers for testing field presence on schemas 57 | 58 | ## [0.11.0] - 2020-01-02 59 | ### Added 60 | - Add support for `dry-validation` 1.0 and above 61 | 62 | ## [0.10.0] - 2019-10-06 63 | ### Changed 64 | - Restrict support for `dry-validation` from 0.11.0 up to (excluding) 1.0.0 65 | - Changed behavior for `:transaction` step wrapper, on `:sequel_models` plugin, to allow to take a single step name instead of block. 66 | - Changed behavior for `:after_commit` step wrapper, on `:sequel_models` plugin, to allow to take a single step name instead of block. 67 | 68 | ## [0.9.1] - 2019-02-18 69 | ### Changed 70 | - Various improvements on documentation and gemspec. 71 | 72 | ## [0.9.0] - 2019-02-04 73 | ### Changed 74 | - Changed behavior for `:after_commit` step wrapper, on `:sequel_models` plugin, to capture current state and reuse it later when executing. 75 | 76 | ### Fixed 77 | - Allow invoking `call` directly on an operation class even if the `:responder` plugin is not loaded. 78 | 79 | ## [0.8.0] - 2018-10-01 80 | ### Changed 81 | - Added support for `dry-validation` 0.12.x 82 | - Renamed DSL method `sequence` to `around`. Keep `sequence` as an alias, although it may be deprecated on a future mayor release. 83 | - Renamed DSL method `guard` to `if_true`. Keep `guard` as an alias, although it may be deprecated on a future mayor release. 84 | - Added DSL method `if_false`, which behaves like `if_true` but checks if the passed predicate is false instead. 85 | - Moved `Responder` class inside the `responder` plugin module. 86 | 87 | ## [0.7.0] - 2018-09-25 88 | ### Changed 89 | - `sequel_models` plugin now automatically adds an optional context parameter to preload the model and avoid hitting the db on `:fetch_model` when the model is already available. 90 | - Added `:set_context_param` option for `sequel_models` plugin to prevent trying to preload the model from the context. 91 | - Allow `authorization` block to take multiple parameters on `simple_auth` plugin. 92 | 93 | ## [0.6.2] - 2018-05-19 94 | ### Fixed 95 | - Allow `:error_message` option for `sequel_models` plugin to propagate down inherited classes 96 | 97 | ## [0.6.1] - 2018-03-16 98 | ### Changed 99 | - Updated default error message for `:fetch_model` step, at `sequel_models` plugin, to indicate the model's name 100 | - Added `:error_message` option for `sequel_models` plugin initializer to set the default error message 101 | - Added `:error_message` option for `:fetch_model` step to override the default error message 102 | 103 | ## [0.6.0] - 2018-03-01 104 | ### Changed 105 | - Replaced unmaintained `inflecto` gem with `dry-inflector` 106 | 107 | ## [0.5.1] - 2017-12-18 108 | ### Changed 109 | - Changed behavior for `:fetch_model` step option `search_by:` to override both the search column and the input key (combine it with `using:` if you need a different value for the input key as well) 110 | - `:fetch_model` step will no longer hit the database if the input key is nil and just return a `:not_found` error instead 111 | 112 | ## [0.5.0] - 2017-11-06 113 | ### Changed 114 | - Changed base class for `Pathway::Error` from `StandardError` to `Object` 115 | 116 | ## [0.4.0] - 2017-10-31 117 | ### Changed 118 | - Renamed `:authorization` plugin to `:simple_auth` 119 | 120 | ### Removed 121 | - Removed `build_model_with` method from `:sequel_models` plugin 122 | 123 | ### Added 124 | - New documentation for core functionality and plugins 125 | 126 | ## [0.3.0] - 2017-10-31 [YANKED] 127 | 128 | ## [0.2.0] - 2017-10-31 [YANKED] 129 | 130 | ## [0.1.0] - 2017-10-31 [YANKED] 131 | 132 | ## [0.0.20] - 2017-10-17 133 | ### Changed 134 | - Renamed options `key:` and `column:` to `using:` and `search_by:`, for `:fetch_model` step, at `:sequel_models` plugin 135 | 136 | ### Added 137 | - Added new option `to:` for overriding where to store the result, for `:fetch_model` step, at `:sequel_models` plugin 138 | 139 | ## [0.0.19] - 2017-10-17 140 | ### Removed 141 | - Removed `Error#error_type` (use `Error#type` instead) 142 | - Removed `Error#error_message` (use `Error#message` instead) 143 | - Removed `Error#errors` (use `Error#details` instead) 144 | 145 | ## [0.0.18] - 2017-10-08 146 | ### Changed 147 | - Changed `:sequel_models` default value for `search_by:` option from `:id` to the model's primary key 148 | -------------------------------------------------------------------------------- /lib/pathway.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'forwardable' 4 | require 'dry/inflector' 5 | require 'contextualizer' 6 | require 'pathway/version' 7 | require 'pathway/result' 8 | 9 | module Pathway 10 | Inflector = Dry::Inflector.new 11 | class Operation 12 | class << self 13 | def plugin(name,...) 14 | require "pathway/plugins/#{Inflector.underscore(name)}" if name.is_a?(Symbol) 15 | 16 | plugin = name.is_a?(Module) ? name : Plugins.const_get(Inflector.camelize(name)) 17 | 18 | self.extend plugin::ClassMethods if plugin.const_defined? :ClassMethods 19 | self.include plugin::InstanceMethods if plugin.const_defined? :InstanceMethods 20 | self::DSL.include plugin::DSLMethods if plugin.const_defined? :DSLMethods 21 | 22 | plugin.apply(self,...) if plugin.respond_to?(:apply) 23 | end 24 | 25 | def inherited(subclass) 26 | super 27 | subclass.const_set :DSL, Class.new(self::DSL) 28 | end 29 | end 30 | 31 | class DSL 32 | end 33 | end 34 | 35 | class Error 36 | attr_reader :type, :message, :details 37 | singleton_class.send :attr_accessor, :default_messages 38 | 39 | @default_messages = {} 40 | 41 | def initialize(type:, message: nil, details: nil) 42 | @type = type.to_sym 43 | @message = message || default_message_for(type) 44 | @details = details || {} 45 | end 46 | 47 | def deconstruct = [type, message, details] 48 | def deconstruct_keys(_) = { type:, message:, details: } 49 | 50 | private 51 | 52 | def default_message_for(type) 53 | self.class.default_messages[type] || Inflector.humanize(type) 54 | end 55 | end 56 | 57 | class State 58 | extend Forwardable 59 | delegate %i([] []= fetch store include? values_at deconstruct_keys) => :@hash 60 | 61 | def initialize(operation, values = {}) 62 | @hash = operation.context.merge(values) 63 | @result_key = operation.result_key 64 | end 65 | 66 | def update(kargs) 67 | @hash.update(kargs) 68 | self 69 | end 70 | 71 | def result = @hash[@result_key] 72 | def to_hash = @hash 73 | 74 | def use(&bl) 75 | raise ArgumentError, 'a block must be provided' if !block_given? 76 | if bl.parameters in [*, [:rest|:keyrest,], *] 77 | raise ArgumentError, 'rest arguments are not supported' 78 | end 79 | 80 | keys = bl.parameters.select { _1 in :key|:keyreq, }.map(&:last) 81 | names = bl.parameters.select { _1 in :req|:opt, }.map(&:last) 82 | 83 | if keys.any? && names.any? 84 | raise ArgumentError, 'cannot mix positional and keyword arguments' 85 | elsif keys.any? 86 | bl.call(**to_hash.slice(*keys)) 87 | else 88 | bl.call(*to_hash.values_at(*names)) 89 | end 90 | end 91 | 92 | alias_method :to_h, :to_hash 93 | alias_method :u, :use 94 | alias_method :unwrap, :use 95 | end 96 | 97 | module Plugins 98 | module Base 99 | module ClassMethods 100 | attr_accessor :result_key 101 | 102 | alias_method :result_at, :result_key= 103 | 104 | def process(&steps) 105 | define_method(:call) do |input| 106 | _dsl_for(input:) 107 | .run(&steps) 108 | .then(&:result) 109 | end 110 | end 111 | 112 | def call(ctx,...) = new(ctx).call(...) 113 | 114 | def inherited(subclass) 115 | super 116 | subclass.result_key = result_key 117 | end 118 | end 119 | 120 | module InstanceMethods 121 | extend Forwardable 122 | 123 | delegate :result_key => 'self.class' 124 | delegate %i[result success failure] => Result 125 | 126 | alias_method :wrap, :result 127 | 128 | def call(*) = raise 'must implement at subclass' 129 | 130 | def error(type, message: nil, details: nil) 131 | failure(Error.new(type:, message:, details:)) 132 | end 133 | 134 | def wrap_if_present(value, type: :not_found, message: nil, details: {}) 135 | value.nil? ? error(type, message:, details:) : success(value) 136 | end 137 | 138 | private 139 | 140 | def _dsl_for(vals) = self.class::DSL.new(State.new(self, vals), self) 141 | end 142 | 143 | def self.apply(klass) 144 | klass.extend Contextualizer 145 | klass.result_key = :value 146 | end 147 | 148 | module DSLMethods 149 | def initialize(state, operation) 150 | @result, @operation = wrap(state), operation 151 | end 152 | 153 | def run(&steps) 154 | instance_eval(&steps) 155 | @result 156 | end 157 | 158 | # Execute step and preserve the former state 159 | def step(callable,...) 160 | #:nocov: 161 | if block_given? 162 | warn "[DEPRECATION] Passing a block to the step method using `DSLMethods#step` is deprecated" 163 | end 164 | #:nocov: 165 | bl = _callable(callable) 166 | @result = @result.tee { |state| bl.call(state,...) } 167 | end 168 | 169 | # Execute step and modify the former state setting the key 170 | def set(callable, *args, to: @operation.result_key, **kwargs, &bl) 171 | #:nocov: 172 | if block_given? 173 | warn "[DEPRECATION] Passing a block to the step method using `DSLMethods#set` is deprecated" 174 | end 175 | #:nocov: 176 | bl = _callable(callable) 177 | 178 | @result = @result.then do |state| 179 | wrap(bl.call(state, *args, **kwargs, &bl)) 180 | .then { |value| state.update(to => value) } 181 | end 182 | end 183 | 184 | # Execute step and replace the current state completely 185 | def map(callable,...) 186 | #:nocov: 187 | warn "[DEPRECATION] `Pathway::DSLMethods#map` has been deprecated, use `step` instead" 188 | #:nocov: 189 | bl = _callable(callable) 190 | @result = @result.then { |state| bl.call(state,...) } 191 | end 192 | 193 | def around(execution_strategy, &steps) 194 | @result.then do |state| 195 | steps_runner = ->(dsl = self) { dsl.run(&steps) } 196 | 197 | _callable(execution_strategy).call(steps_runner, state) 198 | end 199 | end 200 | 201 | def if_true(cond, &steps) 202 | cond = _callable(cond) 203 | around(->(runner, state) { runner.call if cond.call(state) }, &steps) 204 | end 205 | 206 | def if_false(cond, &steps) 207 | if_true(_callable(cond) >> :!.to_proc, &steps) 208 | end 209 | 210 | alias_method :sequence, :around 211 | alias_method :guard, :if_true 212 | 213 | private 214 | 215 | def wrap(obj) = Result.result(obj) 216 | 217 | def _callable(callable) 218 | case callable 219 | when Proc # unless (callable.binding rescue nil)&.receiver == @operation 220 | ->(*args, **kwargs) { @operation.instance_exec(*args, **kwargs, &callable) } 221 | when Symbol 222 | ->(*args, **kwargs) { @operation.send(callable, *args, **kwargs) } 223 | else 224 | callable 225 | end 226 | end 227 | end 228 | end 229 | end 230 | 231 | Operation.plugin Plugins::Base 232 | end 233 | -------------------------------------------------------------------------------- /spec/plugins/dry_validation_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Pathway 6 | module Plugins 7 | describe 'DryValidation' do 8 | class SimpleOperation < Operation 9 | plugin :dry_validation 10 | 11 | context :user, :repository 12 | 13 | params do 14 | required(:name).filled(:string) 15 | optional(:email).maybe(:string) 16 | end 17 | 18 | process do 19 | step :validate 20 | set :fetch_profile, to: :profile 21 | set :create_model 22 | end 23 | 24 | private 25 | 26 | def fetch_profile(state) 27 | wrap_if_present(repository.fetch(state[:params])) 28 | end 29 | 30 | def create_model(state) 31 | params, profile = state.values_at(:params, :profile) 32 | SimpleModel.new(*params.values, user.role, profile) 33 | end 34 | end 35 | 36 | SimpleModel = Struct.new(:name, :email, :role, :profile) 37 | 38 | class SimpleContract < Dry::Validation::Contract 39 | params do 40 | required(:age).filled(:integer) 41 | end 42 | end 43 | 44 | class OperationWithOpt < Operation 45 | plugin :dry_validation 46 | 47 | context :quz 48 | 49 | contract do 50 | option :foo 51 | 52 | params do 53 | required(:qux).filled(:string) 54 | end 55 | 56 | rule(:qux) do 57 | key.failure('not equal to :foo') unless value == foo 58 | end 59 | end 60 | 61 | process do 62 | step :validate, with: { foo: :quz } 63 | end 64 | end 65 | 66 | class OperationWithAutoWire < Operation 67 | plugin :dry_validation, auto_wire: true 68 | 69 | context :baz 70 | 71 | contract do 72 | option :baz 73 | 74 | params do 75 | required(:qux).filled(:string) 76 | end 77 | 78 | rule(:qux) do 79 | key.failure('not equal to :foo') unless value == baz 80 | end 81 | end 82 | 83 | process do 84 | step :validate 85 | end 86 | end 87 | 88 | describe ".contract_class" do 89 | subject(:operation_class) { Class.new(Operation) { plugin :dry_validation } } 90 | 91 | context "when no contract's been setup" do 92 | it "returns a default empty contract" do 93 | expect(operation_class.contract_class).to eq(Dry::Validation::Contract) 94 | end 95 | end 96 | 97 | context "when a contract's been set" do 98 | it "returns the contract" do 99 | operation_class.contract_class = SimpleContract 100 | expect(operation_class.contract_class).to eq(SimpleContract) 101 | end 102 | end 103 | end 104 | 105 | describe ".build_contract" do 106 | let(:contract) { OperationWithOpt.build_contract(foo: "XXXXX") } 107 | 108 | it "uses passed the option from the context to the contract" do 109 | expect(contract.call(qux: "XXXXX")).to be_a_success 110 | end 111 | end 112 | 113 | describe ".contract_options" do 114 | it "returns the option names defined for the contract" do 115 | expect(SimpleOperation.contract_options).to eq([]) 116 | expect(OperationWithOpt.contract_options).to eq([:foo]) 117 | end 118 | end 119 | 120 | describe ".contract" do 121 | context "when called with a contract" do 122 | subject(:operation_class) do 123 | Class.new(Operation) do 124 | plugin :dry_validation 125 | contract SimpleContract 126 | end 127 | end 128 | 129 | it "uses the passed contract's class" do 130 | expect(operation_class.contract_class).to eq(SimpleContract) 131 | end 132 | 133 | context "and a block" do 134 | subject(:operation_class) do 135 | Class.new(Operation) do 136 | plugin :dry_validation 137 | contract(SimpleContract) do 138 | params do 139 | required(:gender).filled(:string) 140 | end 141 | end 142 | end 143 | end 144 | 145 | it "extend from the contract's class" do 146 | expect(operation_class.contract_class).to be < SimpleContract 147 | end 148 | 149 | it "extends the contract rules with the block's rules" do 150 | expect(operation_class.contract_class.schema.rules.keys) 151 | .to include(:age, :gender) 152 | end 153 | end 154 | end 155 | 156 | context "when called with a block" do 157 | subject(:operation_class) do 158 | Class.new(Operation) do 159 | plugin :dry_validation 160 | contract do 161 | params do 162 | required(:gender).filled(:string) 163 | end 164 | end 165 | end 166 | end 167 | 168 | it "extends from the default contract class" do 169 | expect(operation_class.contract_class).to be < Dry::Validation::Contract 170 | end 171 | 172 | it "uses the rules defined at the passed block" do 173 | expect(operation_class.contract_class.schema.rules.keys) 174 | .to include(:gender) 175 | end 176 | end 177 | 178 | context "when called with no block nor contract" do 179 | subject(:opr_class) { Class.new(Operation) { plugin :dry_validation } } 180 | 181 | it 'raises an error' do 182 | expect { opr_class.contract } 183 | .to raise_error(ArgumentError, 'Either a contract class or a block must be provided') 184 | end 185 | end 186 | 187 | context 'when the operation is inherited' do 188 | let(:opr_class) { OperationWithAutoWire } 189 | subject(:opr_subclass) { Class.new(OperationWithAutoWire) } 190 | 191 | it "sets 'contract_class' and 'auto_wire' from the superclass", :aggregate_failures do 192 | expect(opr_subclass.auto_wire).to eq(opr_class.auto_wire) 193 | expect(opr_subclass.contract_class).to eq(opr_class.contract_class) 194 | end 195 | end 196 | end 197 | 198 | describe "#call" do 199 | subject(:operation) { SimpleOperation.new(ctx) } 200 | 201 | let(:ctx) { { user: double("User", role: role), repository: repository } } 202 | let(:role) { :root } 203 | let(:params) { { name: "Paul Smith", email: "psmith@email.com" } } 204 | let(:repository) { double.tap { |repo| allow(repo).to receive(:fetch).and_return(double) } } 205 | 206 | context "when calling with valid params" do 207 | it "returns a successful result", :aggregate_failures do 208 | expect(operation).to succeed_on(params).returning(anything) 209 | end 210 | end 211 | 212 | context "when finding model fails" do 213 | let(:repository) { double.tap { |repo| allow(repo).to receive(:fetch).and_return(nil) } } 214 | it "returns a a failed result", :aggregate_failures do 215 | expect(operation).to fail_on(params).with_type(:not_found) 216 | end 217 | end 218 | 219 | context "when calling with invalid params" do 220 | let(:params) { { email: "psmith@email.com" } } 221 | it "returns a failed result", :aggregate_failures do 222 | expect(operation).to fail_on(params).with_details(name: ['is missing']) 223 | end 224 | end 225 | 226 | context "when contract requires options for validation" do 227 | subject(:operation) { OperationWithOpt.new(quz: 'XXXXX') } 228 | 229 | it "sets then passing a hash through the :with argument" do 230 | expect(operation.call(qux: 'XXXXX')).to be_a_success 231 | expect(operation.call(qux: 'OTHER')).to be_a_failure 232 | end 233 | 234 | context "and is using auto_wire: true" do 235 | subject(:operation) { OperationWithAutoWire.new(baz: 'XXXXX') } 236 | 237 | it "sets the options directly from the context using the keys with the same name" do 238 | expect(operation.call(qux: 'XXXXX')).to be_a_success 239 | expect(operation.call(qux: 'OTHER')).to be_a_failure 240 | end 241 | end 242 | end 243 | end 244 | end 245 | end 246 | end 247 | -------------------------------------------------------------------------------- /spec/plugins/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Pathway 6 | module Plugins 7 | describe Base do 8 | 9 | class OperationWithSteps < Operation 10 | context :validator, :back_end, :notifier, :cond 11 | result_at :result_value 12 | 13 | process do 14 | step :custom_validate 15 | map :add_misc 16 | set :get_value 17 | set :get_aux_value, to: :aux_value 18 | around(-> run, st { run.call if cond.call(st) }) do 19 | set ->_ { 99 }, to: :aux_value 20 | set ->_ { :UPDATED } 21 | end 22 | around(:if_zero) do 23 | set ->_ { :ZERO } 24 | end 25 | if_true(:negative?) do 26 | set ->_ { :NEGATIVE } 27 | end 28 | if_false(:small?) do 29 | set ->_ { :BIG } 30 | end 31 | step :notify 32 | end 33 | 34 | def custom_validate(state) 35 | state[:params] = @validator.call(state) 36 | end 37 | 38 | def add_misc(state) 39 | State.new(self, state.to_h.merge(misc: -1)) 40 | end 41 | 42 | def get_value(state) 43 | @back_end.call(state[:params]) 44 | end 45 | 46 | def get_aux_value(state) 47 | state[result_key] 48 | end 49 | 50 | def if_zero(run, state) 51 | run.call if state[:result_value] == 0 52 | end 53 | 54 | def negative?(state) 55 | state[:result_value].is_a?(Numeric) && state[:result_value].negative? 56 | end 57 | 58 | def small?(state) 59 | !state[:result_value].is_a?(Numeric) || state[:result_value].abs < 1_000_000 60 | end 61 | 62 | def notify(state) 63 | @notifier.call(state) 64 | end 65 | end 66 | 67 | let(:validator) { double } 68 | let(:back_end) { double } 69 | let(:notifier) { double } 70 | let(:cond) { double } 71 | 72 | let(:ctx) { { validator: validator, back_end: back_end, notifier: notifier, cond: cond } } 73 | subject(:operation) { OperationWithSteps.new(ctx) } 74 | 75 | before do 76 | allow(validator).to receive(:call) do |state| 77 | input = state[:input] 78 | input.key?(:foo) ? input : Result.failure(:validation) 79 | end 80 | 81 | allow(back_end).to receive(:call).and_return(123456) 82 | allow(cond).to receive(:call).and_return(false) 83 | 84 | allow(notifier).to receive(:call) 85 | end 86 | 87 | let(:valid_input) { { foo: 'FOO' } } 88 | 89 | describe ".process" do 90 | it "defines a 'call' method wich saves operation argument into the :input key" do 91 | expect(validator).to receive(:call) do |state| 92 | expect(state).to respond_to(:to_hash) 93 | expect(state.to_hash).to include(input: :my_input_test_value) 94 | 95 | Result.failure(:validation) 96 | end 97 | 98 | operation.call(:my_input_test_value) 99 | end 100 | 101 | let(:result) { operation.call(valid_input) } 102 | 103 | it "defines a 'call' method which returns a value using the key specified by 'result_at'" do 104 | expect(back_end).to receive(:call).and_return(:SOME_RETURN_VALUE) 105 | 106 | expect(result).to be_a(Result) 107 | expect(result).to be_a_success 108 | expect(result.value).to eq(:SOME_RETURN_VALUE) 109 | end 110 | end 111 | 112 | describe ".call" do 113 | let(:result) { OperationWithSteps.call(ctx, valid_input) } 114 | it "creates a new instance an invokes the 'call' method on it" do 115 | expect(back_end).to receive(:call).and_return(:SOME_RETURN_VALUE) 116 | 117 | expect(result).to be_a_success 118 | expect(result.value).to eq(:SOME_RETURN_VALUE) 119 | end 120 | end 121 | 122 | describe "#map" do 123 | it "defines a step that replaces the current state" do 124 | old_state = nil 125 | 126 | expect(validator).to receive(:call) do |state| 127 | expect(state.to_h).to_not include(:misc) 128 | 129 | old_state = state 130 | state[:input] 131 | end 132 | 133 | expect(notifier).to receive(:call) do |state| 134 | expect(state.to_h).to include(misc: -1) 135 | expect(state).to_not be_equal(old_state) 136 | end 137 | 138 | operation.call(valid_input) 139 | end 140 | end 141 | 142 | describe "#set" do 143 | it "defines an updating step which sets the result key if no key is specified" do 144 | expect(back_end).to receive(:call).and_return(:SOME_VALUE) 145 | 146 | expect(notifier).to receive(:call) do |state| 147 | expect(state).to respond_to(:to_hash) 148 | expect(state.to_hash).to include(result_value: :SOME_VALUE) 149 | end 150 | 151 | operation.call(valid_input) 152 | end 153 | 154 | it "defines an updating step which sets the specified key" do 155 | expect(back_end).to receive(:call) do |state| 156 | expect(state).to respond_to(:to_hash).and exclude(:aux_value) 157 | 158 | :RETURN_VALUE 159 | end 160 | 161 | expect(notifier).to receive(:call) do |state| 162 | expect(state.to_hash).to include(aux_value: :RETURN_VALUE) 163 | end 164 | 165 | operation.call(valid_input) 166 | end 167 | end 168 | 169 | 170 | let(:result) { operation.call(valid_input) } 171 | 172 | describe "#step" do 173 | it "defines an non updating step" do 174 | expect(notifier).to receive(:call) { { result_value: 0 } } 175 | 176 | expect(result.value).to eq(123456) 177 | end 178 | end 179 | 180 | describe "#around" do 181 | it "provides the steps and state as the block parameter" do 182 | expect(cond).to receive(:call) do |state| 183 | expect(state.to_hash).to include(result_value: 123456, aux_value: 123456) 184 | 185 | true 186 | end 187 | 188 | expect(result.value).to eq(:UPDATED) 189 | end 190 | 191 | it "transfers inner steps' state to the outer steps" do 192 | expect(cond).to receive(:call).and_return(true) 193 | 194 | expect(notifier).to receive(:call) do |state| 195 | expect(state.to_hash).to include(aux_value: 99, result_value: :UPDATED) 196 | end 197 | 198 | expect(result.value).to eq(:UPDATED) 199 | end 200 | 201 | it "is skipped altogether on a failure state" do 202 | allow(back_end).to receive(:call).and_return(Result.failure(:not_available)) 203 | expect(cond).to_not receive(:call) 204 | 205 | expect(result).to be_a_failure 206 | end 207 | 208 | it "accepts a method name as a parameter" do 209 | allow(back_end).to receive(:call).and_return(0) 210 | 211 | expect(result.value).to eq(:ZERO) 212 | end 213 | 214 | context "when running callbacks after the operation has failled" do 215 | let(:logger) { double} 216 | let(:operation) { OperationWithCallbacks.new(logger: logger) } 217 | let(:operation_class) do 218 | Class.new(Operation) do 219 | context :logger 220 | 221 | process do 222 | around(:cleanup_callback_context) do 223 | around(:put_steps_in_callback) do 224 | set -> _ { :SHOULD_NOT_BE_SET } 225 | step -> _ { logger.log("calling back from callback") } 226 | end 227 | step :failing_step 228 | end 229 | end 230 | 231 | def failing_step(_) = error(:im_a_failure!) 232 | 233 | def put_steps_in_callback(runner, st) 234 | st[:callbacks] << -> { runner.call(_dsl_for(st)) } 235 | end 236 | 237 | def cleanup_callback_context(runner, st) 238 | st[:callbacks] = [] 239 | runner.call 240 | st[:callbacks].each(&:call) 241 | end 242 | end 243 | end 244 | 245 | before { stub_const("OperationWithCallbacks", operation_class) } 246 | 247 | it "does not alter the operation result when callback runs after failure" do 248 | expect(logger).to receive(:log).with("calling back from callback") 249 | 250 | expect(operation).to fail_on(valid_input).with_type(:im_a_failure!) 251 | end 252 | end 253 | end 254 | 255 | describe "#if_true" do 256 | before { allow(back_end).to receive(:call).and_return(77) } 257 | 258 | it "runs the inner steps when the condition is meet" do 259 | allow(back_end).to receive(:call).and_return(-77) 260 | expect(result.value).to eq(:NEGATIVE) 261 | end 262 | 263 | it "skips the inner steps when the condition is not meet" do 264 | expect(result.value).to eq(77) 265 | end 266 | end 267 | 268 | describe "#if_false" do 269 | before { allow(back_end).to receive(:call).and_return(77) } 270 | 271 | it "runs the inner steps when the condition not is meet" do 272 | allow(back_end).to receive(:call).and_return(77_000_000) 273 | expect(result.value).to eq(:BIG) 274 | end 275 | 276 | it "skips the inner steps when the condition is meet" do 277 | expect(result.value).to eq(77) 278 | end 279 | end 280 | 281 | end 282 | end 283 | end 284 | -------------------------------------------------------------------------------- /spec/plugins/sequel_models_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Pathway 6 | module Plugins 7 | describe 'SequelModels' do 8 | DB = Sequel.mock 9 | MyModel = Class.new(Sequel::Model(DB[:foo])) { set_primary_key :pk } 10 | 11 | class PkOperation < Operation 12 | plugin :sequel_models, model: MyModel 13 | end 14 | 15 | class MyOperation < Operation 16 | plugin :sequel_models 17 | 18 | context mailer: nil 19 | 20 | model MyModel, search_by: :email 21 | 22 | process do 23 | step :fetch_model 24 | end 25 | end 26 | 27 | class MailerOperation < MyOperation 28 | process do 29 | transaction do 30 | step :fetch_model 31 | after_commit do 32 | step :send_emails 33 | end 34 | end 35 | step :as_hash 36 | end 37 | 38 | def as_hash(state) 39 | state[:my_model] = { model: state[:my_model] } 40 | end 41 | 42 | def send_emails(state) 43 | @mailer.send_emails(state[:my_model]) if @mailer 44 | end 45 | end 46 | 47 | class ChainedOperation < MyOperation 48 | result_at :result 49 | 50 | process do 51 | transaction do 52 | set :chain_operation, to: :result 53 | end 54 | end 55 | 56 | def chain_operation(state) 57 | MailerOperation.call(context, state[:input]) 58 | end 59 | end 60 | 61 | describe 'DSL' do 62 | let(:params) { { email: 'asd@fgh.net' } } 63 | let(:model) { double } 64 | 65 | let(:mailer) { double.tap { |d| allow(d).to receive(:send_emails) } } 66 | 67 | describe '#transaction' do 68 | context 'when providing a block' do 69 | let(:operation) { MailerOperation.new(mailer: mailer) } 70 | before { allow(DB).to receive(:transaction).and_call_original } 71 | 72 | it 'returns the result state provided by the inner transaction when successful' do 73 | allow(MyModel).to receive(:first).with(params).and_return(model) 74 | 75 | expect(operation).to succeed_on(params).returning(model: model) 76 | end 77 | 78 | it "returns the error state provided by the inner transaction when there's a failure" do 79 | allow(MyModel).to receive(:first).with(params).and_return(nil) 80 | 81 | expect(operation).to fail_on(params).with_type(:not_found) 82 | end 83 | 84 | context 'a conditional,' do 85 | class IfConditionalOperation < PkOperation 86 | context :should_run 87 | 88 | process do 89 | transaction(if: :should_run?) do 90 | step :fetch_model 91 | end 92 | end 93 | 94 | private 95 | def should_run?(state)= state[:should_run] 96 | end 97 | 98 | let(:operation) { IfConditionalOperation.new(should_run: should_run) } 99 | let(:params) { { pk: 77 } } 100 | 101 | context 'when the condition is true' do 102 | let(:should_run) { true } 103 | 104 | it 'executes the transaction' do 105 | expect(DB).to receive(:transaction).once.and_call_original 106 | expect(MyModel).to receive(:first).with(params).and_return(model) 107 | 108 | expect(operation).to succeed_on(params).returning(model) 109 | end 110 | end 111 | 112 | context 'when the condition is false' do 113 | let(:should_run) { false } 114 | 115 | it 'skips the transaction' do 116 | expect(MyModel).to_not receive(:first) 117 | expect(DB).to_not receive(:transaction) 118 | 119 | expect(operation).to succeed_on(params) 120 | end 121 | end 122 | end 123 | end 124 | 125 | context 'when providing a step' do 126 | class FetchStepOperation < MyOperation 127 | process do 128 | transaction :fetch_model 129 | end 130 | end 131 | 132 | let(:operation) { FetchStepOperation.new(mailer: mailer) } 133 | before { allow(DB).to receive(:transaction).and_call_original } 134 | 135 | it 'returns the result state provided by the inner transaction when successful' do 136 | allow(MyModel).to receive(:first).with(params).and_return(model) 137 | 138 | expect(operation).to succeed_on(params).returning(model) 139 | end 140 | 141 | it "returns the error state provided by the inner transaction when there's a failure" do 142 | allow(MyModel).to receive(:first).with(params).and_return(nil) 143 | 144 | expect(operation).to fail_on(params).with_type(:not_found) 145 | end 146 | 147 | context 'and conditional' do 148 | class UnlessConditionalOperation < PkOperation 149 | context :should_skip 150 | 151 | process do 152 | transaction :create_model, unless: :should_skip? 153 | end 154 | 155 | def create_model(state) 156 | state[result_key] = model_class.create(state[:input]) 157 | end 158 | 159 | private 160 | def should_skip?(state)= state[:should_skip] 161 | end 162 | 163 | let(:operation) { UnlessConditionalOperation.new(should_skip: should_skip) } 164 | let(:params) { { pk: 99 } } 165 | 166 | context 'if the condition is true' do 167 | let(:should_skip) { false } 168 | 169 | it 'executes the transaction' do 170 | expect(DB).to receive(:transaction).once.and_call_original 171 | expect(MyModel).to receive(:create).with(params).and_return(model) 172 | 173 | expect(operation).to succeed_on(params).returning(model) 174 | end 175 | end 176 | 177 | context 'if the condition is false' do 178 | let(:should_skip) { true } 179 | 180 | it 'skips the transaction' do 181 | expect(DB).to_not receive(:transaction) 182 | expect(MyModel).to_not receive(:create) 183 | 184 | expect(operation).to succeed_on(params) 185 | end 186 | end 187 | end 188 | end 189 | 190 | context 'when both an :if and :unless conditional' do 191 | class InvalidUseOfCondOperation < MyOperation 192 | process do 193 | transaction :perform_db_action, if: :is_good?, unless: :is_bad? 194 | end 195 | end 196 | 197 | let(:operation) { InvalidUseOfCondOperation.new } 198 | 199 | it 'raises an error' do 200 | expect { operation.call(params) }.to raise_error. 201 | with_message('options :if and :unless are mutually exclusive') 202 | end 203 | end 204 | 205 | context 'when providing a block and a step' do 206 | class AmbivalentTransactOperation < MyOperation 207 | process do 208 | transaction :perform_db_action do 209 | step :perform_other_db_action 210 | end 211 | end 212 | end 213 | 214 | let(:operation) { AmbivalentTransactOperation.new } 215 | 216 | it 'raises an error' do 217 | expect { operation.call(params) }.to raise_error 218 | .with_message('must provide either a step or a block but not both') 219 | end 220 | end 221 | 222 | context 'when not providing a block nor a step' do 223 | class EmptyTransacOperation < MyOperation 224 | process do 225 | transaction 226 | end 227 | end 228 | 229 | let(:operation) { EmptyTransacOperation.new } 230 | 231 | it 'raises an error' do 232 | expect { operation.call(params) }.to raise_error. 233 | with_message('must provide either a step or a block but not both') 234 | end 235 | end 236 | end 237 | 238 | describe '#after_commit' do 239 | context 'when providing a block' do 240 | let(:operation) { MailerOperation.new(mailer: mailer) } 241 | 242 | it 'calls after_commit block when transaction is successful' do 243 | expect(DB).to receive(:transaction).and_call_original 244 | allow(MyModel).to receive(:first).with(params).and_return(model) 245 | expect(DB).to receive(:after_commit).and_call_original 246 | expect(mailer).to receive(:send_emails).with(model) 247 | 248 | expect(operation).to succeed_on(params) 249 | end 250 | 251 | it 'does not call after_commit block when transaction fails' do 252 | expect(DB).to receive(:transaction).and_call_original 253 | allow(MyModel).to receive(:first).with(params).and_return(nil) 254 | expect(DB).to_not receive(:after_commit).and_call_original 255 | expect(mailer).to_not receive(:send_emails) 256 | 257 | expect(operation).to fail_on(params) 258 | end 259 | 260 | context 'and the execution state is changed bellow the after_commit callback' do 261 | let(:operation) { ChainedOperation.new(mailer: mailer) } 262 | 263 | it 'ignores any state changes that took place following the after_commit block' do 264 | allow(MyModel).to receive(:first).with(params).and_return(model) 265 | expect(mailer).to receive(:send_emails).with(model) 266 | 267 | expect(operation).to succeed_on(params).returning(model: model) 268 | end 269 | end 270 | end 271 | 272 | context 'when providing a step' do 273 | class SendEmailStepOperation < MyOperation 274 | process do 275 | transaction do 276 | step :fetch_model 277 | after_commit :send_emails 278 | end 279 | end 280 | 281 | def send_emails(state) 282 | @mailer.send_emails(state[:my_model]) if @mailer 283 | end 284 | end 285 | 286 | let(:operation) { SendEmailStepOperation.new(mailer: mailer) } 287 | before { expect(DB).to receive(:transaction).and_call_original } 288 | 289 | it 'calls after_commit block when transaction is successful' do 290 | allow(MyModel).to receive(:first).with(params).and_return(model) 291 | expect(DB).to receive(:after_commit).and_call_original 292 | expect(mailer).to receive(:send_emails).with(model) 293 | 294 | expect(operation).to succeed_on(params) 295 | end 296 | 297 | it 'does not call after_commit block when transaction fails' do 298 | allow(MyModel).to receive(:first).with(params).and_return(nil) 299 | expect(DB).to_not receive(:after_commit).and_call_original 300 | expect(mailer).to_not receive(:send_emails) 301 | 302 | expect(operation).to fail_on(params) 303 | end 304 | end 305 | 306 | context 'with conditional execution' do 307 | context 'using :if with and a block' do 308 | class IfConditionalAfterCommitOperation < MyOperation 309 | context :should_run 310 | 311 | process do 312 | transaction do 313 | step :fetch_model 314 | after_commit(if: :should_run?) do 315 | step :send_emails 316 | end 317 | end 318 | end 319 | 320 | def send_emails(state) 321 | @mailer.send_emails(state[:my_model]) if @mailer 322 | end 323 | 324 | private 325 | def should_run?(state) = state[:should_run] 326 | end 327 | 328 | let(:operation) { IfConditionalAfterCommitOperation.new(mailer: mailer, should_run: should_run) } 329 | let(:params) { { email: 'asd@fgh.net' } } 330 | 331 | before { allow(MyModel).to receive(:first).with(params).and_return(model) } 332 | 333 | context 'when the condition is true' do 334 | let(:should_run) { true } 335 | 336 | it 'executes the after_commit block' do 337 | expect(DB).to receive(:after_commit).and_call_original 338 | expect(mailer).to receive(:send_emails).with(model) 339 | 340 | expect(operation).to succeed_on(params) 341 | end 342 | end 343 | 344 | context 'when the condition is false' do 345 | let(:should_run) { false } 346 | 347 | it 'skips the after_commit block' do 348 | expect(DB).to_not receive(:after_commit) 349 | expect(mailer).to_not receive(:send_emails) 350 | 351 | expect(operation).to succeed_on(params) 352 | end 353 | end 354 | end 355 | 356 | context 'using :unless and a block' do 357 | class UnlessConditionalAfterCommitOperation < MyOperation 358 | context :should_skip 359 | 360 | process do 361 | transaction do 362 | step :fetch_model 363 | after_commit(unless: :should_skip?) do 364 | step :send_emails 365 | end 366 | end 367 | end 368 | 369 | def send_emails(state) 370 | @mailer.send_emails(state[:my_model]) if @mailer 371 | end 372 | 373 | private 374 | def should_skip?(state) = state[:should_skip] 375 | end 376 | 377 | let(:operation) { UnlessConditionalAfterCommitOperation.new(mailer: mailer, should_skip: should_skip) } 378 | let(:params) { { email: 'asd@fgh.net' } } 379 | 380 | before { allow(MyModel).to receive(:first).with(params).and_return(model) } 381 | 382 | context 'when the condition is false' do 383 | let(:should_skip) { false } 384 | 385 | it 'executes the after_commit block' do 386 | expect(DB).to receive(:after_commit).and_call_original 387 | expect(mailer).to receive(:send_emails).with(model) 388 | 389 | expect(operation).to succeed_on(params) 390 | end 391 | end 392 | 393 | context 'when the condition is true' do 394 | let(:should_skip) { true } 395 | 396 | it 'skips the after_commit block' do 397 | expect(DB).to_not receive(:after_commit) 398 | expect(mailer).to_not receive(:send_emails) 399 | 400 | expect(operation).to succeed_on(params) 401 | end 402 | end 403 | end 404 | 405 | context 'using :if with step name' do 406 | class IfStepConditionalAfterCommitOperation < MyOperation 407 | context :should_run 408 | 409 | process do 410 | transaction do 411 | step :fetch_model 412 | after_commit :send_emails, if: :should_run? 413 | end 414 | end 415 | 416 | def send_emails(state) 417 | @mailer.send_emails(state[:my_model]) if @mailer 418 | end 419 | 420 | private 421 | def should_run?(state) = state[:should_run] 422 | end 423 | 424 | before { allow(MyModel).to receive(:first).with(email: 'asd@fgh.net').and_return(model) } 425 | let(:operation) { IfStepConditionalAfterCommitOperation.new(mailer: mailer, should_run: should_run) } 426 | 427 | context 'when the condition is true' do 428 | let(:should_run) { true } 429 | 430 | it 'executes the after_commit step' do 431 | expect(DB).to receive(:after_commit).and_call_original 432 | expect(mailer).to receive(:send_emails).with(model) 433 | 434 | expect(operation).to succeed_on(params) 435 | end 436 | end 437 | 438 | context 'when the condition is false' do 439 | let(:should_run) { false } 440 | 441 | it 'skips the after_commit step' do 442 | expect(DB).to_not receive(:after_commit) 443 | expect(mailer).to_not receive(:send_emails) 444 | 445 | expect(operation).to succeed_on(params) 446 | end 447 | end 448 | end 449 | 450 | context 'when both :if and :unless are provided' do 451 | class InvalidConditionalAfterCommitOperation < MyOperation 452 | process do 453 | transaction do 454 | after_commit :send_emails, if: :is_good?, unless: :is_bad? 455 | end 456 | end 457 | end 458 | 459 | let(:operation) { InvalidConditionalAfterCommitOperation.new } 460 | 461 | it 'raises an error' do 462 | expect { operation.call(params) }.to raise_error 463 | .with_message('options :if and :unless are mutually exclusive') 464 | end 465 | end 466 | end 467 | 468 | context 'when providing a block and a step' do 469 | class AmbivalentAfterCommitOperation < MyOperation 470 | process do 471 | transaction do 472 | after_commit :perform_db_action do 473 | step :perform_other_db_action 474 | end 475 | end 476 | end 477 | end 478 | 479 | let(:operation) { AmbivalentAfterCommitOperation.new } 480 | 481 | it 'raises an error' do 482 | expect { operation.call(params) }.to raise_error 483 | .with_message('must provide either a step or a block but not both') 484 | end 485 | end 486 | 487 | context 'when not providing a block nor a step' do 488 | class InvalidAfterCommitOperation < MyOperation 489 | process do 490 | transaction do 491 | after_commit 492 | end 493 | end 494 | end 495 | 496 | let(:operation) { InvalidAfterCommitOperation.new } 497 | 498 | it 'raises an error' do 499 | expect { operation.call(params) }.to raise_error. 500 | with_message('must provide either a step or a block but not both') 501 | end 502 | end 503 | end 504 | 505 | describe '#after_rollback' do 506 | class LoggerOperation < MyOperation 507 | context :logger 508 | 509 | process do 510 | transaction do 511 | after_rollback do 512 | step :log_error 513 | end 514 | 515 | step :fetch_model 516 | end 517 | end 518 | 519 | def log_error(_) 520 | @logger.log("Ohhh noes!!!!") 521 | end 522 | end 523 | 524 | let(:logger) { double } 525 | 526 | context 'when providing a block' do 527 | class RollbackWithBlockOperation < LoggerOperation 528 | process do 529 | transaction do 530 | after_rollback do 531 | step :log_error 532 | end 533 | 534 | step :fetch_model 535 | end 536 | end 537 | end 538 | 539 | let(:operation) { RollbackWithBlockOperation.new(logger: logger) } 540 | before { expect(DB).to receive(:transaction).and_call_original } 541 | 542 | it 'calls after_rollback block when transaction fails' do 543 | expect(MyModel).to receive(:first).with(params).and_return(nil) 544 | expect(logger).to receive(:log) 545 | 546 | expect(operation).to fail_on(params) 547 | end 548 | 549 | it 'does not call after_rollback block when transaction succeeds' do 550 | expect(MyModel).to receive(:first).with(params).and_return(model) 551 | expect(logger).to_not receive(:log) 552 | 553 | expect(operation).to succeed_on(params) 554 | end 555 | end 556 | 557 | context 'when providing a step' do 558 | class RollbackStepOperation < LoggerOperation 559 | process do 560 | transaction do 561 | after_rollback :log_error 562 | step :fetch_model 563 | end 564 | end 565 | end 566 | 567 | let(:operation) { RollbackStepOperation.new(logger: logger) } 568 | before { expect(DB).to receive(:transaction).and_call_original } 569 | 570 | it 'calls after_rollback step when transaction fails' do 571 | expect(MyModel).to receive(:first).with(params).and_return(nil) 572 | expect(logger).to receive(:log) 573 | 574 | expect(operation).to fail_on(params) 575 | end 576 | 577 | it 'does not call after_rollback step when transaction succeeds' do 578 | expect(MyModel).to receive(:first).with(params).and_return(model) 579 | expect(logger).to_not receive(:log) 580 | 581 | expect(operation).to succeed_on(params) 582 | end 583 | end 584 | 585 | context 'with conditional execution' do 586 | context 'using :if with a block' do 587 | class IfConditionalAfterRollbackOperation < LoggerOperation 588 | context :should_run 589 | 590 | process do 591 | transaction do 592 | after_rollback(if: :should_run?) do 593 | step :log_error 594 | end 595 | step :fetch_model 596 | end 597 | end 598 | 599 | private 600 | def should_run?(state) = state[:should_run] 601 | end 602 | 603 | let(:operation) { IfConditionalAfterRollbackOperation.new(logger: logger, should_run: should_run) } 604 | let(:params) { { email: 'asd@fgh.net' } } 605 | 606 | before { allow(MyModel).to receive(:first).with(params).and_return(nil) } 607 | 608 | context 'when the condition is true' do 609 | let(:should_run) { true } 610 | 611 | it 'executes the after_rollback block' do 612 | expect(logger).to receive(:log) 613 | 614 | expect(operation).to fail_on(params) 615 | end 616 | end 617 | 618 | context 'when the condition is false' do 619 | let(:should_run) { false } 620 | 621 | it 'skips the after_rollback block' do 622 | expect(DB).to_not receive(:after_rollback) 623 | expect(logger).to_not receive(:log) 624 | 625 | expect(operation).to fail_on(params) 626 | end 627 | end 628 | end 629 | 630 | context 'using :unless with a block' do 631 | class UnlessConditionalAfterRollbackOperation < LoggerOperation 632 | context :should_skip 633 | 634 | process do 635 | transaction do 636 | after_rollback(unless: :should_skip?) do 637 | step :log_error 638 | end 639 | step :fetch_model 640 | end 641 | end 642 | 643 | private 644 | def should_skip?(state) = state[:should_skip] 645 | end 646 | 647 | let(:operation) { UnlessConditionalAfterRollbackOperation.new(logger: logger, should_skip: should_skip) } 648 | let(:params) { { email: 'asd@fgh.net' } } 649 | 650 | before { allow(MyModel).to receive(:first).with(params).and_return(nil) } 651 | 652 | context 'when the condition is false' do 653 | let(:should_skip) { false } 654 | 655 | it 'executes the after_rollback block' do 656 | expect(logger).to receive(:log) 657 | 658 | expect(operation).to fail_on(params) 659 | end 660 | end 661 | 662 | context 'when the condition is true' do 663 | let(:should_skip) { true } 664 | 665 | it 'skips the after_rollback block' do 666 | expect(DB).to_not receive(:after_rollback) 667 | expect(logger).to_not receive(:log) 668 | 669 | expect(operation).to fail_on(params) 670 | end 671 | end 672 | end 673 | 674 | context 'using :if with step name' do 675 | class IfStepConditionalAfterRollbackOperation < LoggerOperation 676 | context :should_run 677 | 678 | process do 679 | transaction do 680 | after_rollback :log_error, if: :should_run? 681 | step :fetch_model 682 | end 683 | end 684 | 685 | private 686 | def should_run?(state) = state[:should_run] 687 | end 688 | 689 | before { allow(MyModel).to receive(:first).with(email: 'asd@fgh.net').and_return(nil) } 690 | let(:operation) { IfStepConditionalAfterRollbackOperation.new(logger: logger, should_run: should_run) } 691 | 692 | context 'when the condition is true' do 693 | let(:should_run) { true } 694 | 695 | it 'executes the after_rollback step' do 696 | expect(logger).to receive(:log) 697 | 698 | expect(operation).to fail_on(params) 699 | end 700 | end 701 | 702 | context 'when the condition is false' do 703 | let(:should_run) { false } 704 | 705 | it 'skips the after_rollback step' do 706 | expect(DB).to_not receive(:after_rollback) 707 | expect(logger).to_not receive(:log) 708 | 709 | expect(operation).to fail_on(params) 710 | end 711 | end 712 | end 713 | 714 | context 'when both :if and :unless are provided' do 715 | class InvalidConditionalAfterRollbackOperation < LoggerOperation 716 | process do 717 | transaction do 718 | after_rollback :log_error, if: :is_good?, unless: :is_bad? 719 | end 720 | end 721 | end 722 | 723 | let(:operation) { InvalidConditionalAfterRollbackOperation.new(logger: logger) } 724 | 725 | it 'raises an error' do 726 | expect { operation.call(params) }.to raise_error 727 | .with_message('options :if and :unless are mutually exclusive') 728 | end 729 | end 730 | end 731 | 732 | context 'when providing a block and a step' do 733 | class AmbivalentAfterRollbackOperation < MyOperation 734 | process do 735 | transaction do 736 | after_rollback :perform_db_action do 737 | step :perform_other_db_action 738 | end 739 | end 740 | end 741 | end 742 | 743 | let(:operation) { AmbivalentAfterRollbackOperation.new } 744 | 745 | it 'raises an error' do 746 | expect { operation.call(params) }.to raise_error 747 | .with_message('must provide either a step or a block but not both') 748 | end 749 | end 750 | 751 | context 'when not providing a block nor a step' do 752 | class InvalidAfterRollbackOperation < MyOperation 753 | process do 754 | transaction do 755 | after_rollback 756 | end 757 | end 758 | end 759 | 760 | let(:operation) { InvalidAfterRollbackOperation.new } 761 | 762 | it 'raises an error' do 763 | expect { operation.call(params) }.to raise_error 764 | .with_message('must provide either a step or a block but not both') 765 | end 766 | end 767 | 768 | context 'when nesting operations with rollback callbacks' do 769 | class InnerFailingOperation < MyOperation 770 | context :notifier 771 | 772 | process do 773 | transaction do 774 | after_rollback :notify_inner_rollback 775 | step :fail_step 776 | end 777 | step :after_transaction_step 778 | end 779 | 780 | def fail_step(state) 781 | @notifier.inner_fail_step 782 | error(:inner_operation_failed) 783 | end 784 | 785 | def notify_inner_rollback(state)= @notifier.inner_rollback 786 | def after_transaction_step(state)= @notifier.inner_after_transaction 787 | end 788 | 789 | class OuterOperationWithRollback < MyOperation 790 | context :notifier 791 | 792 | process do 793 | transaction do 794 | after_rollback :notify_outer_rollback 795 | step :call_inner_operation 796 | step :after_inner_call_step 797 | step :fail_again 798 | end 799 | step :final_step 800 | end 801 | 802 | def call_inner_operation(state) 803 | state[:inner_result] = InnerFailingOperation.call({ notifier: @notifier }, state[:input]) 804 | state 805 | end 806 | 807 | def fail_again(state) 808 | @notifier.outter_fail_step 809 | 810 | error(:outter_operation_failed) if state[:inner_result].failure? 811 | end 812 | 813 | def notify_outer_rollback(state)= @notifier.outer_rollback 814 | def after_inner_call_step(state)= @notifier.after_inner_call_step 815 | def final_step(state)= @notifier.final_step 816 | end 817 | 818 | let(:notifier) { spy } 819 | let(:operation) { OuterOperationWithRollback.new(notifier: notifier) } 820 | 821 | it 'executes rollback callbacks in the correct order when inner operation fails' do 822 | expect(notifier).to receive(:inner_fail_step) 823 | expect(notifier).to receive(:inner_rollback) 824 | expect(notifier).to receive(:after_inner_call_step) 825 | expect(notifier).to receive(:outter_fail_step) 826 | expect(notifier).to receive(:outer_rollback) 827 | 828 | # Verify calls that should NOT happen 829 | expect(notifier).to_not receive(:inner_after_transaction) 830 | expect(notifier).to_not receive(:final_step) 831 | 832 | expect(operation).to fail_on(zzz: :XXXXXXXXXXXXX) 833 | .with_type(:outter_operation_failed) 834 | end 835 | end 836 | end 837 | end 838 | 839 | let(:operation) { MyOperation.new } 840 | 841 | describe '.model' do 842 | it "sets the 'result_key' using the model class name" do 843 | expect(operation.result_key).to eq(:my_model) 844 | end 845 | 846 | it "sets the 'model_class' using the first parameter" do 847 | expect(operation.model_class).to eq(MyModel) 848 | end 849 | 850 | context 'when a :search_field option is specified' do 851 | it "sets the 'search_field' with the provided value" do 852 | expect(operation.search_field).to eq(:email) 853 | end 854 | end 855 | 856 | context 'when no :search_field option is specified' do 857 | let(:operation) { PkOperation.new } 858 | 859 | it "sets the 'search_field' from the model's pk" do 860 | expect(operation.search_field).to eq(:pk) 861 | end 862 | end 863 | 864 | context 'when the operation is inherited' do 865 | let(:opr_class) { MyOperation } 866 | subject(:opr_subclass) { Class.new(opr_class) } 867 | 868 | it "sets 'result_key', 'search_field', 'model_class' and 'model_not_found' from the superclass", :aggregate_failures do 869 | expect(opr_subclass.result_key).to eq(opr_class.result_key) 870 | expect(opr_subclass.search_field).to eq(opr_class.search_field) 871 | expect(opr_subclass.model_class).to eq(opr_class.model_class) 872 | expect(opr_subclass.model_not_found).to eq(opr_class.model_not_found) 873 | end 874 | end 875 | end 876 | 877 | describe '#db' do 878 | it 'returns the current db form the model class' do 879 | expect(operation.db).to eq(DB) 880 | end 881 | end 882 | 883 | let(:key) { 'some@email.com' } 884 | let(:params) { { foo: 3, bar: 4} } 885 | 886 | describe '#find_model_with' do 887 | it "queries the db through the 'model_class'" do 888 | expect(MyModel).to receive(:first).with(email: key) 889 | 890 | operation.find_model_with(key) 891 | end 892 | end 893 | 894 | describe '#fetch_model' do 895 | let(:other_model) { double(name: 'OtherModel') } 896 | let(:dataset) { double(model: other_model) } 897 | let(:object) { double } 898 | 899 | it "fetches an instance through 'model_class' into result key" do 900 | expect(MyModel).to receive(:first).with(email: key).and_return(object) 901 | 902 | expect(operation.fetch_model({input: {email: key}}).value[:my_model]).to eq(object) 903 | end 904 | 905 | context "when proving and external repository through 'from:'" do 906 | it "fetches an instance through 'model_class' and sets result key using an overrided search column, input key and 'from' model class" do 907 | expect(other_model).to receive(:first).with(pk: 'foo').and_return(object) 908 | expect(MyModel).to_not receive(:first) 909 | 910 | state = { input: { myid: 'foo' } } 911 | result = operation 912 | .fetch_model(state, from: other_model, using: :myid, search_by: :pk) 913 | .value[:my_model] 914 | 915 | expect(result).to eq(object) 916 | end 917 | 918 | it "fetches an instance through 'model_class' and sets result key using an overrided search column, input key and 'from' dataset" do 919 | expect(dataset).to receive(:first).with(pk: 'foo').and_return(object) 920 | expect(MyModel).to_not receive(:first) 921 | 922 | state = { input: { myid: 'foo' } } 923 | result = operation 924 | .fetch_model(state, from: dataset, using: :myid, search_by: :pk) 925 | .value[:my_model] 926 | 927 | expect(result).to eq(object) 928 | end 929 | end 930 | 931 | it "fetches an instance through 'model_class' and sets result key using an overrided search column and input key with only :search_by is provided" do 932 | expect(MyModel).to receive(:first).with(name: 'foobar').and_return(object) 933 | 934 | state = { input: { email: 'other@email.com', name: 'foobar' } } 935 | result = operation 936 | .fetch_model(state, search_by: :name) 937 | .value[:my_model] 938 | 939 | expect(result).to eq(object) 940 | end 941 | 942 | it "fetches an instance through 'model_class' and sets result key using an overrided input key with but not search column when only :using is provided" do 943 | expect(MyModel).to receive(:first).with(email: 'foobar@mail.com').and_return(object) 944 | 945 | state = { input: { email: 'other@email.com', first_email: 'foobar@mail.com' } } 946 | result = operation 947 | .fetch_model(state, using: :first_email) 948 | .value[:my_model] 949 | 950 | expect(result).to eq(object) 951 | end 952 | 953 | it 'returns an error when no instance is found', :aggregate_failures do 954 | expect(MyModel).to receive(:first).with(email: key).and_return(nil) 955 | 956 | result = operation.fetch_model({input: {email: key}}) 957 | 958 | expect(result).to be_an(Result::Failure) 959 | expect(result.error).to be_an(Pathway::Error) 960 | expect(result.error.type).to eq(:not_found) 961 | expect(result.error.message).to eq('My model not found') 962 | end 963 | 964 | it 'returns an error without hitting the database when search key is nil', :aggregate_failures do 965 | expect(MyModel).to_not receive(:first) 966 | 967 | result = operation.fetch_model({input: {email: nil}}) 968 | 969 | expect(result).to be_an(Result::Failure) 970 | expect(result.error).to be_an(Pathway::Error) 971 | expect(result.error.type).to eq(:not_found) 972 | expect(result.error.message).to eq('My model not found') 973 | end 974 | end 975 | 976 | describe '#call' do 977 | let(:operation) { MyOperation.new(ctx) } 978 | let(:result) { operation.call(email: 'an@email.com') } 979 | let(:fetched_model) { MyModel.new } 980 | 981 | context 'when the model is not present at the context' do 982 | let(:ctx) { {} } 983 | 984 | it "doesn't include the model's key on the operation's context" do 985 | expect(operation.context).to_not include(:my_model) 986 | end 987 | it 'fetchs the model from the DB' do 988 | expect(MyModel).to receive(:first).with(email: 'an@email.com').and_return(fetched_model) 989 | 990 | expect(result.value).to be(fetched_model) 991 | end 992 | end 993 | 994 | context 'when the model is already present in the context' do 995 | let(:existing_model) { MyModel.new } 996 | let(:ctx) { { my_model: existing_model } } 997 | 998 | it "includes the model's key on the operation's context" do 999 | expect(operation.context).to include(my_model: existing_model) 1000 | end 1001 | it 'uses the model from the context and avoid querying the DB' do 1002 | expect(MyModel).to_not receive(:first) 1003 | 1004 | expect(result.value).to be(existing_model) 1005 | end 1006 | 1007 | context 'but :fetch_model step specifies overwrite: true' do 1008 | class OwOperation < MyOperation 1009 | process do 1010 | step :fetch_model, overwrite: true 1011 | end 1012 | end 1013 | 1014 | let(:operation) { OwOperation.new(ctx) } 1015 | 1016 | it 'fetches the model from the DB anyway' do 1017 | expect(MyModel).to receive(:first).with(email: 'an@email.com').and_return(fetched_model) 1018 | 1019 | expect(operation.context).to include(my_model: existing_model) 1020 | expect(operation.my_model).to be(existing_model) 1021 | expect(result.value).to be(fetched_model) 1022 | end 1023 | end 1024 | end 1025 | end 1026 | 1027 | end 1028 | end 1029 | end 1030 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pathway 2 | 3 | [![Gem Version](https://badge.fury.io/rb/pathway.svg)](https://badge.fury.io/rb/pathway) 4 | [![Tests](https://github.com/pabloh/pathway/workflows/Tests/badge.svg)](https://github.com/oabloh/pathway/actions?query=workflow%3ATests) 5 | [![Coverage Status](https://coveralls.io/repos/github/pabloh/pathway/badge.svg?branch=master)](https://coveralls.io/github/pabloh/pathway?branch=master) 6 | 7 | Pathway encapsulates your business logic into simple operation objects (AKA application services on the [DDD](https://en.wikipedia.org/wiki/Domain-driven_design) lingo). 8 | 9 | ## Installation 10 | 11 | $ gem install pathway 12 | 13 | ## Description 14 | 15 | Pathway helps you separate your business logic from the rest of your application; regardless of is an HTTP backend, a background processing daemon, etc. 16 | The main concept Pathway relies upon to build domain logic modules is the operation, this important concept will be explained in detail in the following sections. 17 | 18 | Pathway also aims to be easy to use, stay lightweight and extensible (by the use of plugins), avoid unnecessary dependencies, keep the core classes clean from monkey patching and help yield an organized and uniform codebase. 19 | 20 | 25 | 26 | ## Usage 27 | 28 | ### Main concepts and API 29 | 30 | As mentioned earlier the operation is an essential concept Pathway is built around. Operations not only structure your code (using steps as will be explained later) but also express meaningful business actions. Operations can be thought of as use cases too: they represent an activity -to be performed by an actor interacting with the system- which should be understandable by anyone familiar with the business regardless of their technical expertise. 31 | 32 | Operations shouldn't ideally contain any business rules but instead, orchestrate and delegate to other more specific subsystems and services. The only logic present then should be glue code or any data transformations required to make interactions with the inner system layers possible. 33 | 34 | #### Function object protocol (the `call` method) 35 | 36 | Operations work as function objects, they are callable and hold no state, as such, any object that responds to `call` and returns a result object can be a valid operation and that's the minimal protocol they need to follow. 37 | The result object must follow its protocol as well (and a helper class is provided for that end) but we'll talk about that in a minute. 38 | 39 | Let's see an example: 40 | 41 | ```ruby 42 | class MyFirstOperation 43 | def call(input) 44 | result = Repository.create(input) 45 | 46 | if result.valid? 47 | Pathway::Result.success(result) 48 | else 49 | Pathway::Result.failure(:create_error) 50 | end 51 | end 52 | end 53 | 54 | result = MyFirstOperation.new.call(foo: 'foobar') 55 | if result.success? 56 | puts result.value.inspect 57 | else 58 | puts "Error: #{result.error}" 59 | end 60 | ``` 61 | 62 | Note first, we are not inheriting from any class nor including any module. This won't be the case in general as `pathway` provides classes to help build your operations, but it serves to illustrate how little is needed to implement one. 63 | 64 | Also, let's ignore the specifics about `Repository.create(...)`, we just need to know that is some backend service from which a value is returned. 65 | 66 | 67 | We then define a `call` method for the class. It only checks if the result is available and then wraps it into a successful `Result` object when is ok, or a failing one when is not. 68 | And basically, that's all is needed, you can then call the operation object, check whether it was completed correctly with `success?` and get the resulting value. 69 | 70 | By following this protocol, you will be able to uniformly apply the same pattern on every HTTP endpoint (or whatever means your app has to communicate with the outside world). The upper layer of the application will offload all the domain logic to the operation and only will need to focus on the HTTP transmission details. 71 | 72 | Maintaining always the same operation protocol will also be very useful when composing them. 73 | 74 | #### Operation result 75 | 76 | As should be evident by now an operation should always return either a successful or failed result. These concepts are represented by following a simple protocol, which `Pathway::Result` subclasses comply with. 77 | 78 | As we've seen before, by querying `success?` on the result we can see if the operation we just ran went well, or call to `failure?` to see if it failed. 79 | 80 | The actual result value produced by the operation is accessible at the `value` method and the error description (if there's any) at `error` when the operation fails. 81 | To return wrapped values or errors from your operation you must call `Pathway::Result.success(value)` or `Pathway::Result.failure(error)`. 82 | 83 | It is worth mentioning that when you inherit from `Pathway::Operation` you'll have helper methods at your disposal to create result objects easily. For instance, the previous section's example could be written as follows: 84 | 85 | ```ruby 86 | class MyFirstOperation < Pathway::Operation 87 | def call(input) 88 | result = Repository.create(input) 89 | 90 | result.valid? ? success(result) : failure(:create_error) 91 | end 92 | end 93 | ``` 94 | 95 | #### Error objects 96 | 97 | `Pathway::Error` is a helper class to represent the error description from a failed operation execution (and also supports pattern matching as we'll see later). 98 | Its use is completely optional but provides you with a basic schema to communicate what went wrong. You can instantiate it by calling `new` on the class itself or using the helper method `error` provided by the operation class: 99 | 100 | ```ruby 101 | class CreateNugget < Pathway::Operation 102 | def call(input) 103 | validation = Validator.call(input) 104 | 105 | if validation.ok? 106 | success(Nugget.create(validation.values)) 107 | else 108 | error(:validation, message: 'Invalid input', details: validation.errors) 109 | end 110 | end 111 | end 112 | ``` 113 | 114 | As you can see `error(...)` expects the `type` as the first parameter (and only the mandatory) then `message:` and `details` keyword arguments; these 2 last ones can be omitted and have default values. The type parameter must be a `Symbol`, `message:` a `String` and `details:` can be a `Hash` or any other structure you see fit. 115 | 116 | Finally, the `Error` object have three accessors available to get the values back: 117 | 118 | ```ruby 119 | result = CreateNugget.new.call(foo: 'foobar') 120 | if result.failure? 121 | puts "#{result.error.type} error: #{result.error.message}" 122 | puts "Error details: #{result.error.details}" 123 | end 124 | 125 | ``` 126 | 127 | Mind you, `error(...)` creates an `Error` object wrapped into a `Pathway::Failure` so you don't have to do it yourself. 128 | If you decide to use `Pathway::Error.new(...)` directly, you will have to pass all the arguments as keywords (including `type:`), and you will have to wrap the object before returning it. 129 | 130 | #### Initialization context 131 | 132 | It was previously mentioned that operations should work like functions, that is, they don't hold state and you should be able to execute the same instance all the times you need, on the other hand, there will be some values that won't change during the operation lifetime and won't make sense to pass as `call` parameters, you can provide these values on initialization as context data. 133 | 134 | Context data can be thought of as 'request data' on an HTTP endpoint, values that aren't global but won't change during the execution of the request. Examples of this kind of data are the current user, the current device, a CSRF token, other configuration parameters, etc. You will want to pass these values on initialization, and probably pass them along to other operations down the line. 135 | 136 | You must define your initializer to accept a `Hash` with these values, which is what every operation is expected to do, but as before, when inheriting from `Operation` you have the helper class method `context` handy to make it easier for you: 137 | 138 | ```ruby 139 | class CreateNugget < Pathway::Operation 140 | context :current_user, notify: false 141 | 142 | def call(input) 143 | validation = Validator.call(input) 144 | 145 | if validation.valid? 146 | nugget = Nugget.create(owner: current_user, **validation.values) 147 | 148 | Notifier.notify(:new_nugget, nugget) if @notify 149 | success(nugget) 150 | else 151 | error(:validation, message: 'Invalid input', details: validation.errors) 152 | end 153 | end 154 | end 155 | 156 | 157 | op = CreateNugget.new(current_user: user) 158 | op.call(foo: 'foobar') 159 | ``` 160 | 161 | In the example above `context` is defining `:current_user` as a mandatory argument (it will raise an error if not provided) and `:notify` as an optional config argument, since it has a default value. Note that any extra non-defined value provided will be simply ignored. 162 | 163 | Both of these parameters are available through accessors (and instance variables) inside the operation. Also, there is a `context` private method you use to get all the initialization values as a frozen hash, in order to pass them along easily. 164 | 165 | #### Alternative invocation syntax 166 | 167 | If you don't care about keeping the operation instance around you can execute the operation directly on the class. To do so, use `call` with the initialization context first and then the remaining parameters: 168 | 169 | ```ruby 170 | user = User.first(session[:current_user_id]) 171 | context = { current_user: user } 172 | 173 | CreateNugget.call(context, params[:nugget]) # Using 'call' on the class 174 | ``` 175 | Also, you have Ruby's alternative syntax to invoke the `call` method: `CreateNugget.(context, params[:nugget])`. In both cases, you'll get the operation result like when invoking `call` on the operation's instance. 176 | 177 | Mind you that a context must always be provided for this syntax, if you don't need any initialization use an empty hash. 178 | 179 | There's also a third way to execute an operation, made available through a plugin, that will be explained later. 180 | 181 | #### Steps 182 | 183 | Finally, the steps are the heart of the `Operation` class and the main reason you will want to inherit your own classes from `Pathway::Operation`. 184 | 185 | So far we know that every operation needs to implement a `call` method and return a valid result object, `pathway` provides another option: the `process` block DSL, this method will define `call` behind the scenes for us, while also providing a way to define a business-oriented set of steps to describe our operation's behavior. 186 | 187 | Every step should be cohesive and focused on a single responsibility, ideally by offloading work to other subsystems. Designing steps this way is the developer's responsibility but is made much simpler by the use of custom steps provided by plugins as we'll see later. 188 | 189 | ##### Process DSL 190 | 191 | Let's start by showing some actual code: 192 | 193 | ```ruby 194 | # ... 195 | # Inside an operation class body... 196 | process do 197 | step :authorize 198 | step :validate 199 | set :create_nugget, to: :nugget 200 | step :notify 201 | end 202 | # ... 203 | ``` 204 | 205 | To define your `call` method using the DSL just call to `process` and pass a block, inside it, the DSL will be available. 206 | Each `step` (or `set`) call is referring to a method inside the operation class, superclasses, or available through a plugin, these methods will be eventually invoked by `call`. 207 | All of the steps constitute the operation use case and follow a series of conventions in order to carry the process state along the execution process. 208 | 209 | When you run the `call` method, the auto-generated code will save the provided argument at the `input` key within the execution state. Subsequent steps will receive this state and will be able to modify it, setting the result or auxiliary values, in order to communicate with the next steps on the execution path. 210 | 211 | Each step (as the operation as a whole) can succeed or fail, when the latter happens execution is halted, and the operation `call` method returns immediately. 212 | To signal a failure you must return a `failure(...)` or `error(...)` in the same fashion as when defining `call` directly. 213 | 214 | If you return a `success(...)` or anything that's not a failure the execution carries on but the value is ignored. If you want to save the result value, you must use `set` instead of `step` at the process block, which will save your wrapped value, into the key provided at `to:`. 215 | Also, non-failure return values inside steps are automatically wrapped so you can use `success` for clarity's sake but it's optional. 216 | If you omit the `to:` keyword argument when defining a `set` step, the result key will be used by default, but we'll explain more on that later. 217 | 218 | ##### Operation execution state 219 | 220 | To operate with the execution state, every step method receives a structure representing the current state. This structure is similar to a `Hash` and responds to its main methods (`:[]`, `:[]=`, `:fetch`, `:store`, `:include?` and `to_hash`). 221 | 222 | When an operation is executed, before running the first step, an initial state is created by copying all the values from the initialization context (and also including `input`). 223 | Note that these values can be replaced in later steps but it won't mutate the context object itself since is always frozen. 224 | 225 | A state object can be splatted on method definition in the same fashion as a `Hash`, thus, allowing us to cherry-pick the attributes we are interested in any given step: 226 | 227 | ```ruby 228 | # ... 229 | # This step only takes the values it needs and doesn't change the state. 230 | def send_emails(state) 231 | user, report = state[:user], state[:report] 232 | ReportMailer.send_report(user.email, report) 233 | end 234 | # ... 235 | ``` 236 | 241 | 242 | ##### Successful operation result 243 | 244 | On each step, you can access or change the result the operation will produce on a successful execution. 245 | The value will be stored at one of the attributes within the state. 246 | By default, the state's key `:value` will hold the result, but if you prefer to use another name you can specify it through the `result_at` operation class method. 247 | 248 | ##### Full example 249 | 250 | Let's now go through a fully defined operation using steps: 251 | 252 | ```ruby 253 | class CreateNugget < Pathway::Operation 254 | context :current_user 255 | 256 | process do 257 | step :authorize 258 | step :validate 259 | set :create_nugget 260 | step :notify 261 | end 262 | 263 | result_at :nugget 264 | 265 | def authorize(*) 266 | unless current_user.can? :create, Nugget 267 | error(:forbidden) 268 | end 269 | end 270 | 271 | def validate(state) 272 | validation = NuggetForm.call(state[:input]) 273 | 274 | if validation.ok? 275 | state[:params] = validation.values 276 | else 277 | error(:validation, details: validation.errors) 278 | end 279 | end 280 | 281 | def create_nugget(state) 282 | Nugget.create(owner: current_user, **state[:params]) 283 | end 284 | 285 | def notify(state) 286 | Notifier.notify(:new_nugget, state[:nugget]) 287 | end 288 | end 289 | ``` 290 | 291 | In the example above the operation will produce a nugget (whatever that is...). 292 | 293 | As you can see in the code, we are using the previously mentioned methods to indicate we need the current user to be present on initialization: `context: current_user`, a `call` method (defined by `process do ... end`), and the result value should be stored at the `:nugget` key (`result_at :nugget`). 294 | 295 | Let's delve into the `process` block: it defines three steps using the `step` method and `create_nugget` using `set`, as we said before, this last step will set the result key (`:nugget`) since the `to:` keyword argument is absent. 296 | 297 | Now, for each of the step methods: 298 | 299 | - `:authorize` doesn't need the state so just ignores it, then checks if the current user is allowed to run the operation and halts the execution by returning a `:forbidden` error type if is not, otherwise does nothing and the execution goes on. 300 | - `:validate` gets the state, checks the validity of the `:input` value which as we said is just the `call` method input, returns an `error(...)` when there's a problem, and if the validation is correct it updates the state but saving the sanitized values in `:params`. Note that on success the return value is `state[:params]`, but is ignored like on `:authorize`, since this method was also specified using `step`. 301 | - `:create_nugget` first takes the `:params` attribute from the state, and calls `create` on the `Nugget` model with the sanitized params and the current user. The return value is saved to the result key (`:nugget` in this case) as the step is defined using `step` without `to:`. 302 | - `:notify` grabs the `:nugget` from the state, and simply emits a notification with it, it has no meaningful return value, so is ignored. 303 | 304 | The previous example goes through all the essential concepts needed for defining an operation class. If you can grasp it, you already have a good understanding on how to implement one. There are still some very important bits to cover (like testing), and we'll tackle them in the latter sections. 305 | 306 | On a final note, you may be thinking the code could be a bit less verbose; also, shouldn't very common stuff like validation or authorization be simpler to use? and why always specify the result key name? maybe is possible to infer it from the surrounding code. We will address all those issues in the next section using plugins, `pathway`'s extension mechanism. 307 | 308 | ### Plugins 309 | 310 | Pathway operations can be extended with plugins. They are very similar to the ones found in [Roda](http://roda.jeremyevans.net/) or [Sequel](http://sequel.jeremyevans.net/). So if you are already familiar with any of those gems you shouldn't have any problem with `pathway`'s plugin system. 311 | 312 | To activate a plugin just call the `plugin` method on the operation class: 313 | 314 | ```ruby 315 | class BaseOperation < Pathway::Operation 316 | plugin :foobar, qux: 'quz' 317 | end 318 | 319 | class SomeOperation < BaseOperation 320 | # The :foobar plugin will also be activated here 321 | end 322 | ``` 323 | 324 | The plugin name must be specified as a `Symbol` (or also as the `Module` where is implemented, but more on that later), and it can take parameters next to the plugin's name. 325 | When activated it will enrich your operations with new instance and class methods plus extra customs step for the `process` DSL. 326 | 327 | Mind you, if you wish to activate a plugin for a number of operations you can activate it for all of them directly on the `Pathway::Operation` class, or you can create your own base operation and all its descendants will inherit the base class' plugins. 328 | 329 | #### `DryValidation` plugin 330 | 331 | This plugin provides integration with the [dry-validation](http://dry-rb.org/gems/dry-validation/) gem. I won't explain in detail how to use this library since is already extensively documented on its official website, but instead, I'll assume certain knowledge of it, nonetheless, as you'll see in a moment, its API is pretty self-explanatory. 332 | 333 | `dry-validation` provides a very simple way to define contract objects (conceptually very similar to form objects) to process and validate input. The provided custom `:validate` step allows you to run your input through a contract to check if your data is valid before carrying on. When the input is invalid it will return an error object of type `:validation` and the reasons the validation failed will be available at the `details` attribute. Is usually the first step an operation runs. 334 | 335 | When using this plugin we can provide an already defined contract to the step to use or we can also define it within the operation. 336 | Let's see a few examples: 337 | 338 | ```ruby 339 | class NuggetContract < Dry::Validation::Contract 340 | params do 341 | required(:owner).filled(:string) 342 | required(:price).filled(:integer) 343 | end 344 | end 345 | 346 | class CreateNugget < Pathway::Operation 347 | plugin :dry_validation 348 | 349 | contract NuggetContract 350 | 351 | process do 352 | step :validate 353 | step :create_nugget 354 | end 355 | 356 | # ... 357 | end 358 | ``` 359 | 360 | As is shown above, the contract is defined first, then configured to be used by the operation by calling `contract NuggetContract`, and validate the input at the process block by placing the step `step :validate` inside the `process` block. 361 | 362 | ```ruby 363 | class CreateNugget < Pathway::Operation 364 | plugin :dry_validation 365 | 366 | contract do 367 | params do 368 | required(:owner).filled(:string) 369 | required(:price).filled(:integer) 370 | end 371 | end 372 | 373 | process do 374 | step :validate 375 | step :create_nugget 376 | end 377 | 378 | # ... 379 | end 380 | ``` 381 | 382 | Now, this second example is equivalent to the first one, but here we call `contract` with a block instead of an object parameter; this block will be used as the definition body for a contract class that will be stored internally. Thus keeping the contract and operation code in the same place, this is convenient when you have a rather simpler contract and don't need to reuse it. 383 | 384 | One interesting nuance to keep in mind regarding the inline block contract is that, when doing operation inheritance, if the parent operation already has a contract, the child operation will define a new one inheriting from the parent's. This is very useful to share validation logic among related operations in the same class hierarchy. 385 | 386 | As a side note, if your contract is simple enough and has parameters and not extra validations rules, you can call the `params` method directly instead, the following code is essentially equivalent to the previous example: 387 | 388 | ```ruby 389 | class CreateNugget < Pathway::Operation 390 | plugin :dry_validation 391 | 392 | params do 393 | required(:owner).filled(:string) 394 | required(:price).filled(:integer) 395 | end 396 | 397 | process do 398 | step :validate 399 | step :create_nugget 400 | end 401 | 402 | # ... 403 | end 404 | ``` 405 | 406 | ##### Contract options 407 | 408 | If you are familiar with `dry-validation` you probably know it provides a way to [inject options](https://dry-rb.org/gems/dry-validation/1.4/external-dependencies/) before calling the contract. 409 | 410 | In those scenarios, you must either set the `auto_wire: true` plugin argument or specify how to map options from the execution state to the contract when calling `step :validate`. 411 | Lets see and example for the first case: 412 | 413 | ```ruby 414 | class CreateNugget < Pathway::Operation 415 | plugin :dry_validation, auto_wire: true 416 | 417 | context :user_name 418 | 419 | contract do 420 | option :user_name 421 | 422 | params do 423 | required(:owner).filled(:string) 424 | required(:price).filled(:integer) 425 | end 426 | 427 | rule(:owner) do 428 | key.failure("invalid owner") unless user_name == values[:owner] 429 | end 430 | end 431 | 432 | process do 433 | step :validate 434 | step :create_nugget 435 | end 436 | 437 | # ... 438 | end 439 | ``` 440 | 441 | Here the defined contract needs a `:user_name` option, so we tell the operation to grab the attribute with the same name from the state by activating `:auto_wire`, afterwards, when the validation runs, the contract will already have the user name available. 442 | 443 | Mind you, this option is `false` by default, so be sure to set it to `true` at `Pathway::Operation` if you'd rather have it enabled for all your operations. 444 | 445 | On the other hand, if for some reason the name of the contract's option and state attribute don't match, we can just pass `with: {...}` when calling to `step :validate`, indicating how to wire the attributes, the following example illustrates just that: 446 | 447 | ```ruby 448 | class CreateNugget < Pathway::Operation 449 | plugin :dry_validation 450 | 451 | context :current_user_name 452 | 453 | contract do 454 | option :user_name 455 | 456 | params do 457 | required(:owner).filled(:string) 458 | required(:price).filled(:integer) 459 | end 460 | 461 | rule(:owner) do 462 | key.failure("invalid owner") unless user_name == values[:owner] 463 | end 464 | end 465 | 466 | process do 467 | step :validate, with: { user_name: :current_user_name } # Inject :user_name to the contract object with the state's :current_user_name 468 | step :create_nugget 469 | end 470 | 471 | # ... 472 | end 473 | ``` 474 | 475 | The `with:` parameter can always be specified for `step :validate`, and allows you to override the default mapping regardless if auto-wiring is active or not. 476 | 477 | #### `SimpleAuth` plugin 478 | 479 | This very simple plugin adds a custom step called `:authorize`, that can be used to check for permissions and halt the operation with a `:forbidden` error when they aren't fulfilled. 480 | 481 | In order to use it you must define a boolean predicate to check for permissions, by passing a block to the `authorization` method: 482 | 483 | ```ruby 484 | class MyOperation < Pathway::Operation 485 | plugin :simple_auth 486 | 487 | context :current_user 488 | authorization { current_user.is_admin? } 489 | 490 | process do 491 | step :authorize 492 | step :perform_some_action 493 | end 494 | 495 | # ... 496 | end 497 | ``` 498 | 499 | #### `SequelModels` plugin 500 | 501 | The `sequel_models` plugin helps integrate operations with the [Sequel](http://sequel.jeremyevans.net/) ORM, by adding a few custom steps. 502 | 503 | This plugin expects you to be using `Sequel` model classes to access your DB. In order to exploit it, you need to indicate which model your operation is going to work with, hence you must specify said model when activating the plugin with the `model:` keyword argument, or later using the `model` class method. 504 | This configuration will then be used on the operation class and all its descendants. 505 | 506 | ```ruby 507 | class MyOperation < Pathway::Operation 508 | plugin :sequel_models, model: Nugget, search_by: :name, set_result_key: false 509 | end 510 | 511 | # Or... 512 | 513 | class MyOperation < Pathway::Operation 514 | plugin :sequel_models 515 | 516 | # This is useful when using inheritance and you need different models per operation 517 | model Nugget, search_by: :name, set_result_key: false 518 | 519 | process do 520 | step :authorize 521 | step :perform_some_action 522 | end 523 | end 524 | ``` 525 | 526 | As you can see above you can also customize the search field (`:search_by`) and indicate if you want to override or not the result key (`:set_result_key`) when calling the `model` method. 527 | These two options aren't mandatory, and by default, Pathway will set the search field to the class model primary key, and override the result key to a snake-cased version of the model name (ignoring namespaces if contained inside a class or module). 528 | 529 | Let's now take a look at the provided extensions: 530 | 531 | ##### `:fetch_model` step 532 | 533 | This step will fetch a model from the DB, by extracting the search field from the `call` method input parameter stored at `:input` in the execution state. If the model cannot be fetched from the DB it will halt the execution with a `:not_found` error, otherwise it will simply save the model into the result key (which will be `:nugget` for the example below). 534 | You can later access the fetched model from that attribute and if the operation finishes successfully, it will be used as its result. 535 | 536 | ```ruby 537 | class UpdateNugget < Pathway::Operation 538 | plugin :sequel_models, model: Nugget 539 | 540 | process do 541 | step :validate 542 | step :fetch_model 543 | step :fetch_model, from: User, using: :user_id, search_by: :pk, to: :user # Even the default class can also be overrided with 'from:' 544 | step :update_nugget 545 | end 546 | 547 | # ... 548 | end 549 | ``` 550 | 551 | As a side note, and as shown in the 3rd step, `:fetch_model` allows you to override the search column (`search_by:`), the input parameter to extract from `input` (`using:`), the attribute to store the result (`to:`) and even the default search class (`from:`). If the current defaults don't fit your needs and you'll have these options available. This is commonly useful when you need some extra object, besides the main one, to execute your operation. 552 | 553 | ##### `transaction` and `after_commit` 554 | 555 | These two are a bit special since they aren't actually custom steps but just new methods that extend the process DSL itself. 556 | These methods will take a block as an argument within which you can define inner steps. 557 | Keeping all that in mind the only thing `transaction` and `after_commit` really do is surround the inner steps with `SEQUEL_DB.transaction { ... }` and `SEQUEL_DB.after_commit { ... }` blocks, respectively. 558 | 559 | ```ruby 560 | class CreateNugget < Pathway::Operation 561 | plugin :sequel_models, model: Nugget 562 | 563 | process do 564 | step :validate 565 | transaction do 566 | step :create_nugget 567 | step :attach_history_note 568 | after_commit do 569 | step :send_emails 570 | end 571 | end 572 | end 573 | 574 | # ... 575 | end 576 | ``` 577 | 578 | When won't get into the details for each step in the example above, but the important thing to take away is that `:create_nugget` and `:attach_history_note` will exists within a single transaction and `send_mails` (and any steps you add in the `after_commit` block) will only run after the transaction has finished successfully. 579 | 580 | Another nuance to take into account is that calling `transaction` will start a new savepoint, since, in case you're already inside a transaction, it will be able to properly notify that the transaction failed by returning an error object when that happens. 581 | 582 | #### `Responder` plugin 583 | 584 | This plugin extends the `call` class method on the operation to accept a block. You can then use this block for flow control on success, failure, and also different types of failures. 585 | 586 | There are two ways to use this plugin: by discriminating between success and failure, and also discriminating according to the specific failure type. 587 | 588 | In each case you must provide the action to execute for every outcome using blocks: 589 | 590 | ```ruby 591 | MyOperation.plugin :responder # 'plugin' is actually a public method 592 | 593 | MyOperation.(context, params) do 594 | success { |value| r.halt(200, value.to_json) } # BTW: 'r.halt' is a Roda request method used to exemplify 595 | failure { |error| r.halt(403) } 596 | end 597 | ``` 598 | 606 | 607 | In the example above we provide a block for both the success and the failure case. On each block, the result value or the error object error will be provided at the blocks' argument, and the result of the corresponding block will be the result of the whole expression. 608 | 609 | Lets now show an example with the error type specified: 610 | 611 | ```ruby 612 | MyOperation.plugin :responder 613 | 614 | MyOperation.(context, params) do 615 | success { |value| r.halt(200, value.to_json) } 616 | failure(:forbidden) { |error| r.halt(403) } 617 | failure(:validation) { |error| r.halt(422, error.details.to_json) } 618 | failure(:not_found) { |error| r.halt(404) } 619 | end 620 | ``` 621 | 631 | 632 | As you can see is almost identical to the previous example only that this time you provide the error type on each `failure` call. 633 | 634 | 639 | ### Plugin architecture 640 | 641 | Going a bit deeper now, we'll explain how to implement your own plugins. As was mentioned before `pathway` follows a very similar approach to the [Roda](http://roda.jeremyevans.net/) or [Sequel](http://sequel.jeremyevans.net/) plugin systems, which is reflected at its implementation. 642 | 643 | Each plugin must be defined in a file placed within the `pathway/plugins/` directory of your gem or application, so `pathway` can require the file; and must be implemented as a module inside the `Pathway::Plugins` namespace module. Inside your plugin module, three extra modules can be defined to extend the operation API `ClassMethods`, `InstanceMethods` and `DSLMethods`; plus a class method `apply` for plugin initialization when needed. 644 | 645 | If you are familiar with the aforementioned plugin mechanism (or others as well), the function of each module is probably starting to feel evident: `ClassMethods` will be used to extend the operation class, so any class methods should be defined here; `InstanceMethods` will be included on the operation so all the instance methods you need to add to the operation should be here, this includes every custom step you need to add; and finally `DSLMethods` will be included on the `Operation::DSL` class, which holds all the DSL methods like `step` or `set`. 646 | The `apply` method will simply be run whenever the plugin is included, taking the operation class on the first argument and all then arguments the call to `plugin` received (excluding the plugin name). 647 | 648 | Let's explain with more detail using a complete example: 649 | 650 | ```ruby 651 | # lib/pathway/plugins/active_record.rb 652 | 653 | module Pathway 654 | module Plugins 655 | module ActiveRecord 656 | module ClassMethods 657 | attr_accessor :model, :pk 658 | 659 | def inherited(subclass) 660 | super 661 | subclass.model = self.model 662 | subclass.pk = self.pk 663 | end 664 | end 665 | 666 | module InstanceMethods 667 | delegate :model, :pk, to: :class 668 | 669 | # This method will conflict with :sequel_models so you mustn't load both plugins in the same operation 670 | def fetch_model(state, column: pk) 671 | current_pk = state[:input][column] 672 | result = model.first(column => current_pk) 673 | 674 | if result 675 | state.update(result_key => result) 676 | else 677 | error(:not_found) 678 | end 679 | end 680 | end 681 | 682 | module DSLMethods 683 | # This method also conflicts with :sequel_models, so don't use them at once 684 | def transaction(&steps) 685 | transactional_seq = -> seq, _state do 686 | ActiveRecord::Base.transaction do 687 | raise ActiveRecord::Rollback if seq.call.failure? 688 | end 689 | end 690 | 691 | around(transactional_seq, &steps) 692 | end 693 | end 694 | 695 | def self.apply(operation, model: nil, pk: nil) 696 | operation.model = model 697 | opertaion.pk = pk || model&.primary_key 698 | end 699 | end 700 | end 701 | end 702 | ``` 703 | 704 | The code above implements a plugin to provide basic interaction with the [ActiveRecord](http://guides.rubyonrails.org/active_record_basics.html) gem. 705 | Even though is a very simple plugin, it shows all the essentials to develop more complex ones. 706 | 707 | As is pointed out in the code, some of the methods implemented here (`fetch_model` and `transmission`) collide with methods defined for `:sequel_models`, so as a consequence, these two plugins are not compatible with each other and cannot be activated for the same operation (although you can still do it for different operations within the same application). 708 | You must be mindful about colliding method names when mixing plugins since `Pathway` can't bookkeep compatibility among every plugin that exists or will ever exist. 709 | Is a good practice to document known incompatibilities on the plugin definition itself when they are known. 710 | 711 | The whole plugin is completely defined within the `ActiveRecord` module inside the `Pathway::Plugins` namespace, also the file is placed at the load path in `pathway/plugin/active_record.rb` (assuming `lib/` is listed in `$LOAD_PATH`). This will ensure when calling `plugin :active_record` inside an operation, the correct file will be loaded and the correct plugin module will be applied to the current operation. 712 | 713 | Moving on to the `ClassMethods` module, we can see the accessors `model` and `pk` are defined for the operation's class to allow configuration. 714 | Also, the `inherited` hook is defined, this will simply be another class method at the operation and as such will be executed normally when the operation class is inherited. In our implementation, we just call to `super` (which is extremely important since other modules or parent classes could be using this hook) and then copy the `model` and `pk` options from the parent to the subclass in order to propagate the configuration downwards. 715 | 716 | At the end of the `ActiveRecord` module definition, you can see the `apply` method. It will receive the operation class and the parameters passed when the `plugin` method is invoked. This method is usually used for loading dependencies or just setting up config parameters as we do in this particular example. 717 | 718 | `InstanceMethods` first defines a few delegator methods to the class itself for later use. 719 | Then the `fetch_model` step is defined (remember steps are but operation instance methods). Its first parameter is the state itself, as in the other steps we've seen before, and the remaining parameters are the options we can pass when calling `step :fetch_model` (mind you, this is also valid for steps defined in operations classes). Here we only take a single keyword argument: `column: pk`, with a default value; this will allow us to change the look-up column when using the step and is the only parameter we can use, passing other keyword arguments or extra positional parameters when invoking the step will raise errors. 720 | 721 | Let's now examine the `fetch_model` step body, it's not really that much different from other steps, here we extract the model primary key from `state[:input][column]` and use it to perform a search. If nothing is found an error is returned, otherwise the state is updated in the result key, to hold the model that was just fetched from the DB. 722 | 723 | We finally see a `DSLMethods` module defined to extend the process DSL. 724 | For this plugin, we'll define a way to group steps within an `ActiveRecord` transaction, much in the same way the `:sequel_models` plugin already does for `Sequel`. 725 | To this end, we define a `transaction` method to expect a steps block and pass it down to the `around` helper below which expects a callable (like a `Proc`) and a step list block. As you can see the lambda we pass on the first parameter makes sure the steps are being run inside a transaction or aborts the transaction if the intermediate result is a failure. 726 | 727 | The `around` method is a low-level tool available to help extend the process DSL and it may seem a bit daunting at first glance but its usage is quite simple, the block is just a step list like the ones we find inside the `process` call; and the parameter is a callable (usually a lambda), that will take 2 arguments, an object from which we can run the step list by invoking `call` (and is the only thing it can do), and the current state. From here we can examine the state and decide upon whether to run the steps, how many times (if any), or run some code before and/or after doing so, like what we need to do in our example to surround the steps within a DB transaction. 728 | 729 | ### Testing tools 730 | 731 | As of right now, only `rspec` is supported, that is, you can obviously test your operations with any framework you want, but all the provided matchers are designed for `rspec`. 732 | 733 | #### Rspec config 734 | 735 | In order to load Pathway's operation matchers you must add the following line to your `spec_helper.rb` file, after loading `rspec`: 736 | 737 | ```ruby 738 | require 'pathway/rspec' 739 | ``` 740 | 741 | #### Rspec matchers 742 | 743 | Pathway provides a few matchers in order to test your operation easier. 744 | Let's go through a full example: 745 | 746 | ```ruby 747 | # create_nugget.rb 748 | 749 | class CreateNugget < Pathway::Operation 750 | plugin :dry_validation 751 | 752 | params do 753 | required(:owner).filled(:string) 754 | required(:price).filled(:integer) 755 | optional(:disabled).maybe(:bool) 756 | end 757 | 758 | process do 759 | step :validate 760 | set :create_nugget 761 | end 762 | 763 | def create_nugget(state) 764 | Nugget.create(state[:params]) 765 | end 766 | end 767 | 768 | 769 | # create_nugget_spec.rb 770 | 771 | describe CreateNugget do 772 | describe '#call' do 773 | subject(:operation) { CreateNugget.new } 774 | 775 | context 'when the input is valid' do 776 | let(:input) { owner: 'John Smith', value: '11230' } 777 | 778 | it { is_expected.to succeed_on(input).returning(an_instace_of(Nugget)) } 779 | end 780 | 781 | context 'when the input is invalid' do 782 | let(:input) { owner: '', value: '11230' } 783 | 784 | it { is_expected.to fail_on(input). 785 | with_type(:validation). 786 | message('Is not valid'). 787 | and_details(owner: ['must be present']) } 788 | end 789 | end 790 | 791 | describe '.contract' do 792 | subject(:contract) { CreateNugget.build_contract } 793 | 794 | it { is_expected.to require_fields(:owner, :price) } 795 | it { is_expected.to accept_optional_field(:disabled) } 796 | end 797 | end 798 | ``` 799 | 800 | ##### `succeed_on` matcher 801 | 802 | This first matcher works on the operation itself and that's why we could set `subject` with the operation instance and use `is_expected.to succeed_on(...)` on the example. 803 | The assertion it performs is simply that the operation was successful, also you can optionally chain `returning(...)` if you want to test the returning value, this method allows nesting matchers as is the case in the example. 804 | 805 | ##### `fail_on` matcher 806 | 807 | This second matcher is analog to `succeed_on` but it asserts that operation execution was a failure instead. Also if you return an error object, and you need to, you can assert the error type using the `type` chain method (aliased as `and_type` and `with_type`); the error message (`and_message`, `with_message` or `message`); and the error details (`and_details`, `with_details` or `details`). Mind you, the chain methods for the message and details accept nested matchers while the `type` chain can only test by equality. 808 | 809 | ##### contract/form matchers 810 | 811 | Finally, we can see that we are also testing the operation's contract (or form), implemented here with the `dry-validation` gem. 812 | 813 | Two more matchers are provided: `require_fields` (aliased `require_field`) to test when a contract is expected to define a required set of fields, and `accept_optional_fields` (aliased `accept_optional_field`) to test when a contract must define a certain set of optional fields, both the contract class (at operation class method `contract_class`) or an instance (operation class method `build_contract`) can be provided. 814 | 815 | These matchers are only useful when using `dry-validation` (on every version newer or equal to `0.11.0`) and will probably be extracted to their own gem in the future. 816 | 817 | ## Development 818 | 819 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 820 | 821 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 822 | 823 | ## Contributing 824 | 825 | Bug reports and pull requests are welcome on GitHub at https://github.com/pabloh/pathway. 826 | 827 | ## License 828 | 829 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 830 | --------------------------------------------------------------------------------