├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib ├── simple_command.rb └── simple_command │ ├── errors.rb │ ├── utils.rb │ └── version.rb ├── simple_command.gemspec └── spec ├── factories ├── missed_call_command.rb └── success_command.rb ├── simple_command └── errors_spec.rb ├── simple_command_spec.rb └── spec_helper.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | ruby: ["2.6", "2.7", "3.0", "3.1", ruby-head, jruby-9.3, jruby-head] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | bundler-cache: true # 'bundle install' and cache gems 21 | ruby-version: ${{ matrix.ruby }} 22 | - name: Run tests 23 | run: bundle exec rake 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # This is the configuration used to check the rubocop source code. 2 | 3 | # inherit_from: .rubocop_todo.yml 4 | 5 | # AllCops: 6 | # Exclude: 7 | # - 'simple_command.gemspec' 8 | # - 'spec/factories/**/*' 9 | 10 | # Style/Encoding: 11 | # Enabled: when_needed 12 | 13 | Style/Documentation: 14 | Enabled: false 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0.0 4 | - 2.1.3 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in simple_command.gemspec 4 | gemspec 5 | 6 | group :development do 7 | gem 'simplecov' 8 | gem 'rubocop' 9 | end 10 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Nebulab S.r.l. (http://nebulab.it) 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Code Climate](https://codeclimate.com/github/nebulab/simple_command/badges/gpa.svg)](https://codeclimate.com/github/nebulab/simple_command) 2 | ![CI](https://github.com/nebulab/simple_command/actions/workflows/ci.yml/badge.svg) 3 | 4 | # SimpleCommand 5 | 6 | A simple, standardized way to build and use _Service Objects_ (aka _Commands_) in Ruby 7 | 8 | ## Requirements 9 | 10 | * Ruby 2.6+ 11 | 12 | ## Installation 13 | 14 | Add this line to your application's Gemfile: 15 | 16 | ```ruby 17 | gem 'simple_command' 18 | ``` 19 | 20 | And then execute: 21 | 22 | $ bundle 23 | 24 | Or install it yourself as: 25 | 26 | $ gem install simple_command 27 | 28 | ## Usage 29 | 30 | Here's a basic example of a command that authenticates a user 31 | 32 | ```ruby 33 | # define a command class 34 | class AuthenticateUser 35 | # put SimpleCommand before the class' ancestors chain 36 | prepend SimpleCommand 37 | include ActiveModel::Validations 38 | 39 | # optional, initialize the command with some arguments 40 | def initialize(email, password) 41 | @email = email 42 | @password = password 43 | end 44 | 45 | # mandatory: define a #call method. its return value will be available 46 | # through #result 47 | def call 48 | if user = User.find_by(email: @email)&.authenticate(@password) 49 | return user 50 | else 51 | errors.add(:base, :failure) 52 | end 53 | nil 54 | end 55 | end 56 | ``` 57 | 58 | in your locale file 59 | ```yaml 60 | # config/locales/en.yml 61 | en: 62 | activemodel: 63 | errors: 64 | models: 65 | authenticate_user: 66 | failure: Wrong email or password 67 | ``` 68 | 69 | Then, in your controller: 70 | 71 | ```ruby 72 | class SessionsController < ApplicationController 73 | def create 74 | # initialize and execute the command 75 | # NOTE: `.call` is a shortcut for `.new(args).call` 76 | command = AuthenticateUser.call(session_params[:email], session_params[:password]) 77 | 78 | # check command outcome 79 | if command.success? 80 | # command#result will contain the user instance, if found 81 | session[:user_token] = command.result.secret_token 82 | redirect_to root_path 83 | else 84 | flash.now[:alert] = t(command.errors.full_messages.to_sentence) 85 | render :new 86 | end 87 | end 88 | 89 | private 90 | 91 | def session_params 92 | params.require(:session).permit(:email, :password) 93 | end 94 | end 95 | ``` 96 | 97 | ## Test with Rspec 98 | Make the spec file `spec/commands/authenticate_user_spec.rb` like: 99 | 100 | ```ruby 101 | describe AuthenticateUser do 102 | subject(:context) { described_class.call(username, password) } 103 | 104 | describe '.call' do 105 | context 'when the context is successful' do 106 | let(:username) { 'correct_user' } 107 | let(:password) { 'correct_password' } 108 | 109 | it 'succeeds' do 110 | expect(context).to be_success 111 | end 112 | end 113 | 114 | context 'when the context is not successful' do 115 | let(:username) { 'wrong_user' } 116 | let(:password) { 'wrong_password' } 117 | 118 | it 'fails' do 119 | expect(context).to be_failure 120 | end 121 | end 122 | end 123 | end 124 | ``` 125 | 126 | ## Contributing 127 | 128 | 1. Fork it ( https://github.com/nebulab/simple_command/fork ) 129 | 2. Create your feature branch (`git checkout -b my-new-feature`) 130 | 3. Commit your changes (`git commit -am 'Add some feature'`) 131 | 4. Push to the branch (`git push origin my-new-feature`) 132 | 5. Create a new Pull Request 133 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/simple_command.rb: -------------------------------------------------------------------------------- 1 | require 'simple_command/version' 2 | require 'simple_command/utils' 3 | require 'simple_command/errors' 4 | 5 | module SimpleCommand 6 | attr_reader :result 7 | 8 | module ClassMethods 9 | def call(*args, **kwargs) 10 | new(*args, **kwargs).call 11 | end 12 | end 13 | 14 | def self.prepended(base) 15 | base.extend ClassMethods 16 | end 17 | 18 | def call 19 | fail NotImplementedError unless defined?(super) 20 | 21 | @called = true 22 | @result = super 23 | 24 | self 25 | end 26 | 27 | def success? 28 | called? && !failure? 29 | end 30 | alias_method :successful?, :success? 31 | 32 | def failure? 33 | called? && errors.any? 34 | end 35 | 36 | def errors 37 | return super if defined?(super) 38 | 39 | @errors ||= Errors.new 40 | end 41 | 42 | private 43 | 44 | def called? 45 | @called ||= false 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/simple_command/errors.rb: -------------------------------------------------------------------------------- 1 | module SimpleCommand 2 | class NotImplementedError < ::StandardError; end 3 | 4 | class Errors < Hash 5 | def add(key, value, _opts = {}) 6 | self[key] ||= [] 7 | self[key] << value 8 | self[key].uniq! 9 | end 10 | 11 | def add_multiple_errors(errors_hash) 12 | errors_hash.each do |key, values| 13 | SimpleCommand::Utils.array_wrap(values).each { |value| add key, value } 14 | end 15 | end 16 | 17 | def each 18 | each_key do |field| 19 | self[field].each { |message| yield field, message } 20 | end 21 | end 22 | 23 | def full_messages 24 | map { |attribute, message| full_message(attribute, message) } 25 | end 26 | 27 | private 28 | def full_message(attribute, message) 29 | return message if attribute == :base 30 | attr_name = attribute.to_s.tr('.', '_').capitalize 31 | "%s %s" % [attr_name, message] 32 | end 33 | 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/simple_command/utils.rb: -------------------------------------------------------------------------------- 1 | module SimpleCommand 2 | module Utils 3 | # Borrowed from active_support/core_ext/array/wrap 4 | def self.array_wrap(object) 5 | if object.nil? 6 | [] 7 | elsif object.respond_to?(:to_ary) 8 | object.to_ary || [object] 9 | else 10 | [object] 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/simple_command/version.rb: -------------------------------------------------------------------------------- 1 | module SimpleCommand 2 | VERSION = '1.0.1' 3 | end 4 | -------------------------------------------------------------------------------- /simple_command.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'simple_command/version' 5 | 6 | Gem::Specification.new do |s| 7 | s.required_ruby_version = '>= 2.6' 8 | s.name = 'simple_command' 9 | s.version = SimpleCommand::VERSION 10 | s.authors = ['Andrea Pavoni'] 11 | s.email = ['andrea.pavoni@gmail.com'] 12 | s.summary = 'Easy way to build and manage commands (service objects)' 13 | s.description = 'Easy way to build and manage commands (service objects)' 14 | s.homepage = 'http://github.com/nebulab/simple_command' 15 | s.license = 'MIT' 16 | 17 | s.files = `git ls-files -z`.split("\x0") 18 | s.executables = s.files.grep(/^bin\//) { |f| File.basename(f) } 19 | s.test_files = s.files.grep(/^(test|spec|features)\//) 20 | s.require_paths = ['lib'] 21 | 22 | s.add_development_dependency 'bundler' 23 | s.add_development_dependency 'rake' 24 | s.add_development_dependency 'rspec', '~> 3.1' 25 | end 26 | -------------------------------------------------------------------------------- /spec/factories/missed_call_command.rb: -------------------------------------------------------------------------------- 1 | class MissedCallCommand 2 | prepend SimpleCommand 3 | 4 | def initialize(input) 5 | @input = input 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/factories/success_command.rb: -------------------------------------------------------------------------------- 1 | class SuccessCommand 2 | prepend SimpleCommand 3 | 4 | def initialize(input) 5 | @input = input 6 | end 7 | 8 | def call 9 | @input * 2 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/simple_command/errors_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe SimpleCommand::Errors do 4 | let(:errors) { SimpleCommand::Errors.new } 5 | 6 | describe '#add' do 7 | before do 8 | errors.add :some_error, 'some error description' 9 | end 10 | 11 | it 'adds the error' do 12 | expect(errors[:some_error]).to eq(['some error description']) 13 | end 14 | 15 | it 'adds the same error only once' do 16 | errors.add :some_error, 'some error description' 17 | expect(errors[:some_error]).to eq(['some error description']) 18 | end 19 | end 20 | 21 | describe '#add_multiple_errors' do 22 | it 'populates itself with the added errors' do 23 | errors_list = { 24 | some_error: ['some error description'], 25 | another_error: ['another error description'] 26 | } 27 | 28 | errors.add_multiple_errors errors_list 29 | 30 | expect(errors[:some_error]).to eq(errors_list[:some_error]) 31 | expect(errors[:another_error]).to eq(errors_list[:another_error]) 32 | end 33 | 34 | it 'copies errors from another SimpleCommand::Errors object' do 35 | command_errors = SimpleCommand::Errors.new 36 | command_errors.add :some_error, "was found" 37 | command_errors.add :some_error, "happened again" 38 | 39 | errors.add_multiple_errors command_errors 40 | 41 | expect(errors[:some_error]).to eq(["was found", "happened again"]) 42 | end 43 | 44 | it "ignores nil values" do 45 | errors.add_multiple_errors({:foo => nil}) 46 | 47 | expect(errors[:foo]).to eq nil 48 | end 49 | end 50 | 51 | describe '#each' do 52 | let(:errors_list) do 53 | { 54 | email: ['taken'], 55 | password: ['blank', 'too short'] 56 | } 57 | end 58 | 59 | before { errors.add_multiple_errors(errors_list) } 60 | 61 | it 'yields each message for the same key independently' do 62 | expect { |b| errors.each(&b) }.to yield_control.exactly(3).times 63 | expect { |b| errors.each(&b) }.to yield_successive_args( 64 | [:email, 'taken'], 65 | [:password, 'blank'], 66 | [:password, 'too short'] 67 | ) 68 | end 69 | end 70 | 71 | describe '#full_messages' do 72 | before do 73 | errors.add :attr1, 'has an error' 74 | errors.add :attr2, 'has an error' 75 | errors.add :attr2, 'has two errors' 76 | end 77 | 78 | it "returrns the full messages array" do 79 | expect(errors.full_messages).to eq ["Attr1 has an error", "Attr2 has an error", "Attr2 has two errors"] 80 | end 81 | 82 | end 83 | 84 | end 85 | -------------------------------------------------------------------------------- /spec/simple_command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe SimpleCommand do 4 | let(:command) { SuccessCommand.new(2) } 5 | 6 | describe '.call' do 7 | before do 8 | allow(SuccessCommand).to receive(:new).and_return(command) 9 | allow(command).to receive(:call) 10 | 11 | SuccessCommand.call 2 12 | end 13 | 14 | it 'initializes the command' do 15 | expect(SuccessCommand).to have_received(:new) 16 | end 17 | 18 | it 'calls #call method' do 19 | expect(command).to have_received(:call) 20 | end 21 | end 22 | 23 | describe '#call' do 24 | let(:missed_call_command) { MissedCallCommand.new(2) } 25 | 26 | it 'returns the command object' do 27 | expect(command.call).to be_a(SuccessCommand) 28 | end 29 | 30 | it 'raises an exception if the method is not defined in the command' do 31 | expect do 32 | missed_call_command.call 33 | end.to raise_error(SimpleCommand::NotImplementedError) 34 | end 35 | end 36 | 37 | describe '#success?' do 38 | it 'is true by default' do 39 | expect(command.call.success?).to be_truthy 40 | end 41 | 42 | it 'is false if something went wrong' do 43 | command.errors.add(:some_error, 'some message') 44 | expect(command.call.success?).to be_falsy 45 | end 46 | 47 | context 'when call is not called yet' do 48 | it 'is false by default' do 49 | expect(command.success?).to be_falsy 50 | end 51 | end 52 | end 53 | 54 | describe '#result' do 55 | it 'returns the result of command execution' do 56 | expect(command.call.result).to eq(4) 57 | end 58 | 59 | context 'when call is not called yet' do 60 | it 'returns nil' do 61 | expect(command.result).to be_nil 62 | end 63 | end 64 | end 65 | 66 | describe '#failure?' do 67 | it 'is false by default' do 68 | expect(command.call.failure?).to be_falsy 69 | end 70 | 71 | it 'is true if something went wrong' do 72 | command.errors.add(:some_error, 'some message') 73 | expect(command.call.failure?).to be_truthy 74 | end 75 | 76 | context 'when call is not called yet' do 77 | it 'is false by default' do 78 | expect(command.failure?).to be_falsy 79 | end 80 | end 81 | end 82 | 83 | describe '#errors' do 84 | it 'returns an SimpleCommand::Errors' do 85 | expect(command.errors).to be_a(SimpleCommand::Errors) 86 | end 87 | 88 | context 'with no errors' do 89 | it 'is empty' do 90 | expect(command.errors).to be_empty 91 | end 92 | end 93 | 94 | context 'with errors' do 95 | before do 96 | command.errors.add(:some_error, 'some message') 97 | end 98 | 99 | it 'has a key with error message' do 100 | expect(command.errors[:some_error]).to eq(['some message']) 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | 3 | SimpleCov.start do 4 | add_filter '/spec/' 5 | end 6 | 7 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 8 | 9 | require 'simple_command' 10 | 11 | Dir[File.join(File.dirname(__FILE__), 'factories', '**/*.rb')].each do |factory| 12 | require factory 13 | end 14 | --------------------------------------------------------------------------------