├── .rspec ├── lib ├── operatic │ ├── version.rb │ ├── errors.rb │ ├── data.rb │ └── result.rb └── operatic.rb ├── Gemfile ├── Rakefile ├── .github ├── dependabot.yml └── workflows │ └── ruby.yml ├── bin ├── setup └── console ├── .gitignore ├── spec ├── spec_helper.rb └── lib │ ├── operatic │ ├── result_spec.rb │ └── data_spec.rb │ └── operatic_spec.rb ├── LICENSE.txt ├── operatic.gemspec ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/operatic/version.rb: -------------------------------------------------------------------------------- 1 | module Operatic 2 | VERSION = '0.7.0' 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in operatic.gemspec 4 | gemspec 5 | 6 | gem 'yard' 7 | -------------------------------------------------------------------------------- /lib/operatic/errors.rb: -------------------------------------------------------------------------------- 1 | module Operatic 2 | OperaticError = Class.new(StandardError) 3 | FailureError = Class.new(OperaticError) 4 | end 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: :spec 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: "/" 6 | schedule: 7 | interval: daily 8 | -------------------------------------------------------------------------------- /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 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | 13 | /Gemfile.lock 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'operatic' 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 'irb' 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'operatic' 3 | 4 | RSpec.configure do |config| 5 | # Enable flags like --only-failures and --next-failure 6 | config.example_status_persistence_file_path = '.rspec_status' 7 | 8 | # Disable RSpec exposing methods globally on `Module` and `main` 9 | config.disable_monkey_patching! 10 | 11 | config.expect_with :rspec do |c| 12 | c.syntax = :expect 13 | end 14 | 15 | config.order = :random 16 | Kernel.srand(config.seed) 17 | end 18 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: push 4 | 5 | jobs: 6 | rspec: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | ruby: 11 | - '2.7' 12 | - '3.0' 13 | - '3.1' 14 | - '3.2' 15 | - '3.3' 16 | name: Ruby ${{ matrix.ruby }} RSpec 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: ruby/setup-ruby@v1 20 | with: 21 | bundler-cache: true 22 | ruby-version: ${{ matrix.ruby }} 23 | - run: bundle exec rspec 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Ben Pickles 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 | -------------------------------------------------------------------------------- /operatic.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'operatic/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'operatic' 7 | spec.version = Operatic::VERSION 8 | spec.authors = ['Ben Pickles'] 9 | spec.email = ['spideryoung@gmail.com'] 10 | 11 | spec.summary = 'A minimal standard interface for your operations' 12 | spec.description = spec.summary 13 | spec.homepage = 'https://github.com/benpickles/operatic' 14 | spec.license = 'MIT' 15 | 16 | spec.metadata = { 17 | 'changelog_uri' => 'https://github.com/benpickles/operatic/blob/main/CHANGELOG.md', 18 | 'documentation_uri' => 'https://rubydoc.info/gems/operatic', 19 | 'rubygems_mfa_required' => 'true', 20 | 'source_code_uri' => 'https://github.com/benpickles/operatic', 21 | } 22 | 23 | spec.required_ruby_version = '>= 2.7.0' 24 | 25 | # Specify which files should be added to the gem when it is released. 26 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 27 | spec.files = Dir.chdir(__dir__) do 28 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 29 | end 30 | spec.bindir = 'exe' 31 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 32 | spec.require_paths = ['lib'] 33 | 34 | spec.add_development_dependency 'bundler' 35 | spec.add_development_dependency 'rake' 36 | spec.add_development_dependency 'rspec', '~> 3.0' 37 | end 38 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## Version 0.7.0 - 2024-05-12 4 | 5 | - Data within an operation is now gathered on a separate `#data` object that's passed to a concrete `Operatic::Success`/`Operatic::Failure` result instance on completion. Convenience data accessors can be defined on the `Data` object (via the renamed `Operatic.data_attr`) but remain available on the result using the magic of `Result#method_missing`. 6 | - Require Ruby version 2.7+. 7 | - Support pattern matching solely against a Result's data. 8 | 9 | ## Version 0.6.0 - 2022-08-22 10 | 11 | - Support pattern matching a Result (in Ruby 2.7+). 12 | 13 | ## Version 0.5.0 - 2022-06-23 14 | 15 | - Support custom initialize method to aid compatibility with other libraries. 16 | - Rename to `Operatic.result_attr` to be more specific about its functionality. 17 | - Get and set Result data with `#[]` / `#[]=`. 18 | 19 | ## Version 0.4.0 - 2022-05-25 20 | 21 | - Switch to keyword arguments. 22 | 23 | ## Version 0.3.1 - 2020-01-05 24 | 25 | - Less specific Rake dependency. 26 | 27 | ## Version 0.3.0 - 2019-11-27 28 | 29 | - Implement `#to_h` not `#to_hash`. 30 | 31 | ## Version 0.2.0 - 2019-11-23 32 | 33 | First official version hosted on [RubyGems.org](https://rubygems.org/gems/operatic). 34 | -------------------------------------------------------------------------------- /lib/operatic/data.rb: -------------------------------------------------------------------------------- 1 | module Operatic 2 | class Data 3 | # Generate a subclass of {Data} with named +attrs+ accessors. This wouldn't 4 | # normally be called directly, see {ClassMethods#data_attr} for example 5 | # usage. 6 | # 7 | # @param attrs [Array] a list of convenience data accessors. 8 | def self.define(*attrs) 9 | Class.new(self) do 10 | attrs.each do |name| 11 | define_method name do 12 | self[name] 13 | end 14 | 15 | define_method "#{name}=" do |value| 16 | self[name] = value 17 | end 18 | end 19 | end 20 | end 21 | 22 | # @param kwargs [Hash] 23 | def initialize(**kwargs) 24 | @data = kwargs 25 | end 26 | 27 | # Return the value for +key+. 28 | # 29 | # @param key [Symbol] 30 | # 31 | # @return [anything] 32 | def [](key) 33 | @data[key] 34 | end 35 | 36 | # Set data on the result. 37 | # 38 | # @param key [Symbol] 39 | # @param value [anything] 40 | def []=(key, value) 41 | @data[key] = value 42 | end 43 | 44 | # @return [self] 45 | def freeze 46 | @data.freeze 47 | super 48 | end 49 | 50 | # @param hash [Hash] 51 | # 52 | # @return [Data] 53 | def merge(hash) 54 | self.class.new.tap { |other| 55 | other.set_data(@data) 56 | other.set_data(hash) 57 | } 58 | end 59 | 60 | # @return [Hash] 61 | def to_h 62 | @data 63 | end 64 | 65 | protected 66 | def set_data(data) 67 | data.each do |key, value| 68 | @data[key] = value 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/lib/operatic/result_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Operatic::Result do 2 | describe '#[]' do 3 | subject { described_class.new(data) } 4 | 5 | let(:data) { Operatic::Data.new(a: 1, b: 2) } 6 | 7 | it 'reads the key from its data object' do 8 | expect(subject[:a]).to be(1) 9 | expect(subject[:b]).to be(2) 10 | end 11 | end 12 | 13 | describe '#deconstruct (pattern matching)' do 14 | subject { Operatic::Success.new(data) } 15 | 16 | let(:data) { Operatic::Data.new(a: 1, b: 2) } 17 | 18 | it 'returns a tuple of itself and its data' do 19 | deconstructed = case subject 20 | in [Operatic::Success, { a: }] 21 | [true, a] 22 | end 23 | 24 | expect(deconstructed).to eql([true, 1]) 25 | end 26 | end 27 | 28 | describe '#deconstruct_keys (pattern matching)' do 29 | subject { described_class.new(data) } 30 | 31 | let(:data) { Operatic::Data.new(a: 1, b: 2, c: 3) } 32 | 33 | it 'matches against its data' do 34 | deconstructed = case subject 35 | in a:, c: 36 | [a, c] 37 | end 38 | 39 | expect(deconstructed).to eql([1, 3]) 40 | end 41 | end 42 | 43 | describe '#method_missing / #respond_to?' do 44 | subject { described_class.new(data) } 45 | 46 | let(:data) { Operatic::Data.define(:a).new(a: 1, b: 2) } 47 | 48 | it 'forwards to methods defined on its data object' do 49 | expect(subject.respond_to?(:a)).to be(true) 50 | expect(subject.a).to eql(1) 51 | 52 | expect(subject.respond_to?(:b)).to be(false) 53 | expect { subject.b }.to raise_error(NoMethodError) 54 | end 55 | 56 | it 'forwards all arguments' do 57 | def data.foo(*args, **kwargs, &block) 58 | yield(args, kwargs) 59 | end 60 | 61 | returned = subject.foo(1, 2, a: 1, b: 2) do |args, kwargs| 62 | [args, kwargs] 63 | end 64 | 65 | expect(returned).to eql([ 66 | [1, 2], 67 | { a: 1, b: 2 }, 68 | ]) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/lib/operatic/data_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Operatic::Data do 2 | describe '.define' do 3 | let(:data) { klass.new } 4 | let(:klass) { described_class.define(:stuff, :nonsense) } 5 | 6 | it 'creates a subclass with specified data accessors' do 7 | data.stuff = 1 8 | data.nonsense = 2 9 | 10 | expect(data.nonsense).to eql(2) 11 | expect(data.stuff).to eql(1) 12 | expect(data.to_h).to eql({ nonsense: 2, stuff: 1 }) 13 | 14 | expect(data).not_to respond_to(:foo) 15 | data[:foo] = 3 16 | expect(data.to_h).to eql({ foo: 3, nonsense: 2, stuff: 1 }) 17 | end 18 | end 19 | 20 | describe '#[]= / #[]' do 21 | let(:data) { described_class.new } 22 | 23 | it 'sets and gets data' do 24 | data[:a] = 1 25 | data[:b] = 2 26 | 27 | expect(data[:a]).to be(1) 28 | expect(data[:b]).to be(2) 29 | expect(data.to_h).to eql({ a: 1, b: 2 }) 30 | end 31 | end 32 | 33 | describe '#merge' do 34 | context 'when passed a hash' do 35 | let(:data) { described_class.new(a: 1, b: 2) } 36 | 37 | it 'returns a new instance with the merged data' do 38 | other = data.merge({ b: 3, c: 4 }) 39 | 40 | expect(data[:b]).to eql(2) 41 | expect(data[:c]).to be_nil 42 | 43 | expect(other).to be_a(described_class) 44 | expect(other).not_to be(data) 45 | expect(other[:a]).to eql(1) 46 | expect(other[:b]).to eql(3) 47 | expect(other[:c]).to eql(4) 48 | end 49 | end 50 | 51 | context 'when called on a subclass' do 52 | let(:data) { klass.new(a: 1) } 53 | let(:klass) { described_class.define(:b) } 54 | 55 | it 'returns an object of the same class' do 56 | data.b = 2 57 | 58 | other = data.merge({ b: 3, c: 4 }) 59 | 60 | expect(data.b).to eql(2) 61 | expect(data[:c]).to be_nil 62 | 63 | expect(other).to be_a(klass) 64 | expect(other).not_to be(data) 65 | expect(other.b).to eql(3) 66 | expect(other[:a]).to eql(1) 67 | expect(other[:b]).to eql(3) 68 | expect(other[:c]).to eql(4) 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/operatic/result.rb: -------------------------------------------------------------------------------- 1 | module Operatic 2 | class Result 3 | # @return [Data] 4 | attr_reader :data 5 | 6 | # @param data [Data] 7 | def initialize(data) 8 | @data = data 9 | end 10 | 11 | # Convenience proxy to read the +key+ from its {#data} object. 12 | # 13 | # @param key [Symbol] 14 | # 15 | # @return [anything] 16 | def [](key) 17 | data[key] 18 | end 19 | 20 | # Returns a tuple of self and {#to_h} allowing you to pattern match across 21 | # both the result's status and its data. 22 | # 23 | # @example 24 | # class SayHello 25 | # include Operatic 26 | # 27 | # def call 28 | # data[:message] = 'Hello world' 29 | # end 30 | # end 31 | # 32 | # case SayHello.call 33 | # in [Operatic::Success, { message: }] 34 | # # Result is a success, do something with the `message` variable. 35 | # in [Operatic::Failure, _] 36 | # # Result is a failure, do something else. 37 | # end 38 | # 39 | # @return [Array(self, Hash)] 40 | def deconstruct 41 | [self, to_h] 42 | end 43 | 44 | # Pattern match against the result's data via {#to_h}. 45 | # 46 | # @example 47 | # class SayHello 48 | # include Operatic 49 | # 50 | # def call 51 | # data[:message] = 'Hello world' 52 | # end 53 | # end 54 | # 55 | # case SayHello.call 56 | # in message: 57 | # # Result has the `message` key, do something with the variable. 58 | # else 59 | # # Do something else. 60 | # end 61 | # 62 | # @return [Hash] 63 | def deconstruct_keys(keys = nil) 64 | to_h 65 | end 66 | 67 | # @return [self] 68 | def freeze 69 | data.freeze 70 | super 71 | end 72 | 73 | # Forwards unknown methods to its {#data} object allowing convenience 74 | # accessors defined via {Data.define} to be available directly on the 75 | # {Result}. 76 | def method_missing(name, *args, **kwargs, &block) 77 | return data.public_send(name, *args, **kwargs, &block) if data.respond_to?(name) 78 | super 79 | end 80 | 81 | def respond_to?(...) 82 | super || data.respond_to?(...) 83 | end 84 | 85 | # Convenience proxy to {Data#to_h}. 86 | # 87 | # @return [Hash] 88 | def to_h 89 | data.to_h 90 | end 91 | end 92 | 93 | class Success < Result 94 | # @return [false] 95 | def failure? 96 | false 97 | end 98 | 99 | # @return [true] 100 | def success? 101 | true 102 | end 103 | end 104 | 105 | class Failure < Result 106 | # @return [true] 107 | def failure? 108 | true 109 | end 110 | 111 | # @return [false] 112 | def success? 113 | false 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at spideryoung@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Operatic 2 | 3 | [![GitHub Actions status](https://github.com/benpickles/operatic/workflows/Ruby/badge.svg)](https://github.com/benpickles/operatic) 4 | 5 | A minimal standard interface for your operations. 6 | 7 | ## Installation 8 | 9 | Add Operatic to your application's Gemfile and run `bundle install`. 10 | 11 | ```ruby 12 | gem 'operatic' 13 | ``` 14 | 15 | ## Usage 16 | 17 | An Operatic class encapsulates an operation and communicates its status via a result object. As well as being a `#success?` or `#failure?` data can also be attached to the result via `#success!`, `#failure!`, or during the operation's execution. 18 | 19 | ```ruby 20 | class SayHello 21 | include Operatic 22 | 23 | # Readers for instance variables defined in `.call`. 24 | attr_reader :name 25 | 26 | # Declare convenience data accessors. 27 | data_attr :message 28 | 29 | def call 30 | # Exit the method and mark the operation as a failure. 31 | return failure! unless name 32 | 33 | # Mark the operation as a success and attach further data. 34 | success!(message: "Hello #{name}") 35 | end 36 | end 37 | 38 | result = SayHello.call(name: 'Dave') 39 | result.class # => Operatic::Success 40 | result.failure? # => false 41 | result.success? # => true 42 | result.message # => "Hello Dave" 43 | result[:message] # => "Hello Dave" 44 | result.to_h # => {:message=>"Hello Dave"} 45 | 46 | result = SayHello.call 47 | result.class # => Operatic::Failure 48 | result.failure? # => true 49 | result.success? # => false 50 | result.message # => nil 51 | result[:message] # => nil 52 | result.to_h # => {} 53 | ``` 54 | 55 | A Rails controller might use Operatic like this: 56 | 57 | ```ruby 58 | class HellosController < ApplicationController 59 | def create 60 | result = SayHello.call(name: params[:name]) 61 | 62 | if result.success? 63 | render plain: result.message 64 | else 65 | render :new 66 | end 67 | end 68 | end 69 | ``` 70 | 71 | ## Pattern matching 72 | 73 | An Operatic result also supports pattern matching allowing you to match over a tuple of the result class and its data: 74 | 75 | ```ruby 76 | case SayHello.call(name: 'Dave') 77 | in [Operatic::Success, { message: }] 78 | # Result is a success, do something with the `message` variable. 79 | in [Operatic::Failure, _] 80 | # Result is a failure, do something else. 81 | end 82 | ``` 83 | 84 | Or match solely against its data: 85 | 86 | ```ruby 87 | case SayHello.call(name: 'Dave') 88 | in message: 89 | # Result has the `message` key, do something with the variable. 90 | else 91 | # Do something else. 92 | end 93 | ``` 94 | 95 | Which might be consumed in Rails like this: 96 | 97 | ```ruby 98 | class HellosController < ApplicationController 99 | def create 100 | case SayHello.call(name: params[:name]) 101 | in [Operatic::Success, { message: }] 102 | render plain: message 103 | in [Operatic::Failure, _] 104 | render :new 105 | end 106 | end 107 | end 108 | ``` 109 | 110 | ## Development 111 | 112 | Run the tests with: 113 | 114 | ``` 115 | bundle exec rspec 116 | ``` 117 | 118 | Generate Yard documentation with: 119 | 120 | ``` 121 | bundle exec yardoc 122 | ``` 123 | 124 | ## License 125 | 126 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 127 | 128 | ## Code of Conduct 129 | 130 | Everyone interacting in the Operatic project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/benpickles/operatic/blob/main/CODE_OF_CONDUCT.md). 131 | -------------------------------------------------------------------------------- /lib/operatic.rb: -------------------------------------------------------------------------------- 1 | require 'operatic/data' 2 | require 'operatic/errors' 3 | require 'operatic/result' 4 | require 'operatic/version' 5 | 6 | module Operatic 7 | # @!visibility private 8 | def self.included(base) 9 | base.extend(ClassMethods) 10 | end 11 | 12 | module ClassMethods 13 | # The main way to call an operation. This initializes the class with the 14 | # supplied +attrs+ keyword arguments and calls {Operatic#call} returning a 15 | # frozen {Result} instance. 16 | # 17 | # @param attrs [Hash] 18 | # 19 | # @return [Failure, Success] 20 | def call(**attrs) 21 | operation = new(**attrs) 22 | operation.call 23 | operation.result || Success.new(operation.data).freeze 24 | end 25 | 26 | # The same as {#call} but raises {FailureError} if the returned {#result} is 27 | # a {Failure} - useful for things like background jobs, rake tasks, test 28 | # setups, etc. 29 | # 30 | # @param attrs [Hash] 31 | # 32 | # @return [Success] 33 | # 34 | # @raise [FailureError] if the operation is not a {Success} 35 | def call!(**attrs) 36 | call(**attrs).tap { |result| 37 | raise FailureError if result.failure? 38 | } 39 | end 40 | 41 | # Define a class-specific {Data} subclass with the named accessors added via 42 | # {Data.define}. 43 | # 44 | # @example 45 | # class SayHello 46 | # include Operatic 47 | # 48 | # data_attr :message 49 | # 50 | # def call 51 | # success!(message: "Hello #{@name}") 52 | # end 53 | # end 54 | # 55 | # result = SayHello.call(name: 'Dave') 56 | # result.class # => Operatic::Success 57 | # result.message # => "Hello Dave" 58 | # result[:message] # => "Hello Dave" 59 | # result.to_h # => {:message=>"Hello Dave"} 60 | # 61 | # @param attrs [Array] a list of convenience data accessors to 62 | # define on the {Result}. 63 | def data_attr(*attrs) 64 | @data_class = Data.define(*attrs) 65 | end 66 | 67 | # @return [Class] 68 | def data_class 69 | @data_class || Data 70 | end 71 | end 72 | 73 | # @return [Success, Failure] 74 | attr_reader :result 75 | 76 | # @param attrs [Hash] 77 | def initialize(**attrs) 78 | attrs.each do |key, value| 79 | instance_variable_set("@#{key}", value) 80 | end 81 | end 82 | 83 | # Override this method with your implementation. Use {#success!}/{#failure!} 84 | # to define the status of the result {Success}/{Failure} and attach data. 85 | # 86 | # @example 87 | # class SayHello 88 | # include Operatic 89 | # 90 | # def call 91 | # return failure! unless @name 92 | # success!(message: "Hello #{@name}") 93 | # end 94 | # end 95 | # 96 | # result = SayHello.call(name: 'Dave') 97 | # result.class # => Operatic::Success 98 | # result.failure? # => false 99 | # result.success? # => true 100 | # result[:message] # => "Hello Dave" 101 | # result.to_h # => {:message=>"Hello Dave"} 102 | # 103 | # result = SayHello.call 104 | # result.class # => Operatic::Failure 105 | # result.failure? # => true 106 | # result.success? # => false 107 | # result.to_h # => {} 108 | def call 109 | end 110 | 111 | # Any data to be communicated via the operation's result should be added to 112 | # this {Data} object. 113 | # 114 | # *Note*: This will be frozen when returned from an operation. 115 | # 116 | # @example 117 | # class SayHello 118 | # include Operatic 119 | # 120 | # def call 121 | # data[:message] = "Hello #{@name}" 122 | # end 123 | # end 124 | # 125 | # result = SayHello.call(name: 'Dave') 126 | # result.data.to_h # => {:message=>"Dave"} 127 | # result.data.frozen? # => true 128 | # 129 | # @return [Data] 130 | def data 131 | @data ||= self.class.data_class.new 132 | end 133 | 134 | # Mark the operation as a failure and prevent further modification to the 135 | # operation, its result, and its data. 136 | # 137 | # @param kwargs [Hash] 138 | # 139 | # @raise [FrozenError] if called more than once 140 | def failure!(**kwargs) 141 | @result = Failure.new(data.merge(kwargs)) 142 | freeze 143 | end 144 | 145 | # @return [self] 146 | def freeze 147 | @result.freeze 148 | super 149 | end 150 | 151 | # Mark the operation as a success and prevent further modification to the 152 | # operation, its result, and its data. 153 | # 154 | # @param kwargs [Hash] 155 | # 156 | # @raise [FrozenError] if called more than once 157 | def success!(**kwargs) 158 | @result = Success.new(data.merge(kwargs)) 159 | freeze 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /spec/lib/operatic_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Operatic do 2 | describe '.call' do 3 | context 'when neither #failure! or #success! are explicitly called' do 4 | let(:klass) { 5 | Class.new do 6 | include Operatic 7 | end 8 | } 9 | 10 | it 'returns a frozen Success result' do 11 | result = klass.call 12 | 13 | expect(result).to be_a(Operatic::Success) 14 | expect(result).not_to be_failure 15 | expect(result).to be_frozen 16 | expect(result).to be_success 17 | expect(result.data).to be_frozen 18 | expect(result.to_h).to eql({}) 19 | expect(result.to_h).to be_frozen 20 | end 21 | end 22 | 23 | context 'when called with something other than kwargs' do 24 | let(:klass) { 25 | Class.new do 26 | include Operatic 27 | end 28 | } 29 | 30 | it 'raises ArgumentError', :aggregate_failures do 31 | expect { klass.call('Dave') }.to raise_error(ArgumentError) 32 | expect { klass.call(['Dave']) }.to raise_error(ArgumentError) 33 | 34 | if RUBY_VERSION >= '3.1' 35 | expect { klass.call({ name: 'Dave' }) }.to raise_error(ArgumentError) 36 | end 37 | end 38 | end 39 | 40 | context 'when an error is raised' do 41 | let(:klass) { 42 | Class.new do 43 | include Operatic 44 | 45 | def call 46 | raise 'foo' 47 | end 48 | end 49 | } 50 | 51 | it 'is left for the consumer to deal with' do 52 | expect { 53 | klass.call 54 | }.to raise_error(RuntimeError, 'foo') 55 | end 56 | end 57 | end 58 | 59 | describe '.call!' do 60 | let(:klass) { 61 | Class.new do 62 | include Operatic 63 | 64 | attr_reader :oh_no 65 | 66 | def call 67 | failure! if oh_no 68 | end 69 | end 70 | } 71 | 72 | context 'when the operation succeeds' do 73 | it do 74 | expect(klass.call!).to be_a(Operatic::Success) 75 | end 76 | end 77 | 78 | context 'when the operation fails' do 79 | it do 80 | expect { 81 | klass.call!(oh_no: true) 82 | }.to raise_error(Operatic::FailureError) 83 | end 84 | end 85 | end 86 | 87 | describe '.data_attr' do 88 | let(:klass) { 89 | Class.new do 90 | include Operatic 91 | 92 | data_attr :a, :b 93 | 94 | def call 95 | data.a = 1 96 | 97 | success!( 98 | b: 2, 99 | c: 3, 100 | ) 101 | end 102 | end 103 | } 104 | 105 | it 'makes the defined attributes available to read on the result' do 106 | result = klass.call 107 | 108 | expect(result).to be_a(Operatic::Success) 109 | expect(result).to be_frozen 110 | expect(result.a).to eql(1) 111 | expect(result.b).to eql(2) 112 | expect(result).not_to respond_to(:c) 113 | expect(result.to_h).to eql({ a: 1, b: 2, c: 3 }) 114 | expect(result.to_h).to be_frozen 115 | end 116 | 117 | it 'does not affect other result classes' do 118 | another_klass = Class.new do 119 | include Operatic 120 | end 121 | 122 | klass.call 123 | another_result = another_klass.call 124 | 125 | expect(another_result).not_to respond_to(:a) 126 | end 127 | end 128 | 129 | describe 'overriding #initialize' do 130 | let(:klass) { 131 | Class.new do 132 | include Operatic 133 | 134 | def initialize(name:) 135 | @name = name 136 | end 137 | 138 | def call 139 | success!(message: "Hello #{@name}") 140 | end 141 | end 142 | } 143 | 144 | it 'works as expected with known arguments' do 145 | result = klass.call(name: 'Bob') 146 | 147 | expect(result[:message]).to eql('Hello Bob') 148 | end 149 | 150 | it 'works as expected with unknown arguments' do 151 | expect { 152 | klass.call(foo: 'bar') 153 | }.to raise_error(ArgumentError) 154 | end 155 | end 156 | 157 | describe '#failure!' do 158 | let(:klass) { 159 | Class.new do 160 | include Operatic 161 | 162 | attr_reader :call_twice 163 | attr_reader :early_failure 164 | attr_reader :early_failure_with_data 165 | attr_reader :failure_after_setting_data 166 | attr_reader :failure_after_success 167 | 168 | def call 169 | if call_twice 170 | failure! 171 | return failure! 172 | end 173 | 174 | return failure! if early_failure 175 | 176 | return failure!(a: 1, b: 2) if early_failure_with_data 177 | 178 | if failure_after_setting_data 179 | data[:c] = 3 180 | return failure! 181 | end 182 | 183 | if failure_after_success 184 | success! 185 | return failure! 186 | end 187 | end 188 | end 189 | } 190 | 191 | context 'when called with no data' do 192 | it do 193 | result = klass.call(early_failure: true) 194 | 195 | expect(result).to be_a(Operatic::Failure) 196 | expect(result).to be_frozen 197 | expect(result.to_h).to eql({}) 198 | expect(result.to_h).to be_frozen 199 | end 200 | end 201 | 202 | context 'when called with data' do 203 | it do 204 | result = klass.call(early_failure_with_data: true) 205 | 206 | expect(result).to be_a(Operatic::Failure) 207 | expect(result).to be_frozen 208 | expect(result.to_h).to eql({ a: 1, b: 2 }) 209 | expect(result.to_h).to be_frozen 210 | end 211 | end 212 | 213 | context 'when called after setting data on the result object' do 214 | it do 215 | result = klass.call(failure_after_setting_data: true) 216 | 217 | expect(result).to be_a(Operatic::Failure) 218 | expect(result).to be_frozen 219 | expect(result.to_h).to eql({ c: 3 }) 220 | expect(result.to_h).to be_frozen 221 | end 222 | end 223 | 224 | context 'when called more than once' do 225 | it do 226 | expect { 227 | klass.call(call_twice: true) 228 | }.to raise_error(FrozenError) 229 | end 230 | end 231 | 232 | context 'when called after #success!' do 233 | it do 234 | expect { 235 | klass.call(failure_after_success: true) 236 | }.to raise_error(FrozenError) 237 | end 238 | end 239 | end 240 | 241 | describe '#success!' do 242 | let(:klass) { 243 | Class.new do 244 | include Operatic 245 | 246 | attr_reader :call_after_failure 247 | attr_reader :call_after_setting_data 248 | attr_reader :call_twice 249 | attr_reader :explicitly_called 250 | attr_reader :explicitly_called_with_data 251 | 252 | data_attr :a 253 | 254 | def call 255 | if call_after_failure 256 | failure! 257 | return success! 258 | end 259 | 260 | if call_after_setting_data 261 | data[:a] = 1 262 | return success! 263 | end 264 | 265 | if call_twice 266 | success! 267 | return success! 268 | end 269 | 270 | return success! if explicitly_called 271 | 272 | return success!(b: 2) if explicitly_called_with_data 273 | end 274 | end 275 | } 276 | 277 | context 'when not explicitly called' do 278 | it do 279 | result = klass.call 280 | 281 | expect(result).to be_a(Operatic::Success) 282 | expect(result).to be_frozen 283 | expect(result.to_h).to be_empty 284 | expect(result.to_h).to be_frozen 285 | end 286 | end 287 | 288 | context 'when called explicitly' do 289 | it do 290 | result = klass.call(explicitly_called: true) 291 | 292 | expect(result).to be_a(Operatic::Success) 293 | expect(result).to be_frozen 294 | expect(result.to_h).to be_empty 295 | expect(result.to_h).to be_frozen 296 | end 297 | end 298 | 299 | context 'when called explicitly with data' do 300 | it do 301 | result = klass.call(explicitly_called_with_data: true) 302 | 303 | expect(result).to be_a(Operatic::Success) 304 | expect(result).to be_frozen 305 | expect(result.to_h).to eql({ b: 2 }) 306 | expect(result.to_h).to be_frozen 307 | end 308 | end 309 | 310 | context 'when called after setting data' do 311 | it do 312 | result = klass.call(call_after_setting_data: true) 313 | 314 | expect(result).to be_a(Operatic::Success) 315 | expect(result).to be_frozen 316 | expect(result.to_h).to eql({ a: 1 }) 317 | expect(result.to_h).to be_frozen 318 | end 319 | end 320 | 321 | context 'when called more than once' do 322 | it do 323 | expect { 324 | klass.call(call_twice: true) 325 | }.to raise_error(FrozenError) 326 | end 327 | end 328 | 329 | context 'when called after #failure!' do 330 | it do 331 | expect { 332 | klass.call(call_after_failure: true) 333 | }.to raise_error(FrozenError) 334 | end 335 | end 336 | end 337 | end 338 | --------------------------------------------------------------------------------