easy_command 1.0.0.pre.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.github/CODEOWNERS +5 -0
  3. data/.github/workflows/ci.yaml +52 -0
  4. data/.github/workflows/lint.yaml +38 -0
  5. data/.github/workflows/release.yml +43 -0
  6. data/.gitignore +14 -0
  7. data/.release-please-manifest.json +3 -0
  8. data/.rspec +1 -0
  9. data/.rubocop.yml +14 -0
  10. data/.rubocop_maintainer_style.yml +34 -0
  11. data/.rubocop_style.yml +142 -0
  12. data/.rubocop_todo.yml +453 -0
  13. data/.ruby-version +1 -0
  14. data/CHANGELOG.md +89 -0
  15. data/Gemfile +19 -0
  16. data/LICENSE.txt +22 -0
  17. data/README.md +736 -0
  18. data/easy_command.gemspec +26 -0
  19. data/lib/easy_command/chainable.rb +16 -0
  20. data/lib/easy_command/errors.rb +85 -0
  21. data/lib/easy_command/result.rb +53 -0
  22. data/lib/easy_command/ruby-2-7-specific.rb +49 -0
  23. data/lib/easy_command/ruby-2-specific.rb +53 -0
  24. data/lib/easy_command/ruby-3-specific.rb +49 -0
  25. data/lib/easy_command/spec_helpers/command_matchers.rb +89 -0
  26. data/lib/easy_command/spec_helpers/mock_command_helper.rb +89 -0
  27. data/lib/easy_command/spec_helpers.rb +2 -0
  28. data/lib/easy_command/version.rb +3 -0
  29. data/lib/easy_command.rb +94 -0
  30. data/locales/en.yml +2 -0
  31. data/release-please-config.json +11 -0
  32. data/spec/easy_command/errors_spec.rb +121 -0
  33. data/spec/easy_command/result_spec.rb +176 -0
  34. data/spec/easy_command_spec.rb +298 -0
  35. data/spec/factories/addition_command.rb +12 -0
  36. data/spec/factories/callback_command.rb +20 -0
  37. data/spec/factories/failure_command.rb +12 -0
  38. data/spec/factories/missed_call_command.rb +7 -0
  39. data/spec/factories/multiplication_command.rb +12 -0
  40. data/spec/factories/sub_command.rb +19 -0
  41. data/spec/factories/subcommand_command.rb +14 -0
  42. data/spec/factories/success_command.rb +11 -0
  43. data/spec/spec_helper.rb +16 -0
  44. metadata +102 -0
@@ -0,0 +1,26 @@
1
+ lib = File.expand_path('lib', __dir__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'easy_command/version'
4
+
5
+ Gem::Specification.new do |s|
6
+ s.required_ruby_version = '>= 2.7'
7
+ s.name = 'easy_command'
8
+ s.version = EasyCommand::VERSION
9
+ s.authors = ['Swile']
10
+ s.email = ['ruby-maintainers@swile.co']
11
+ s.summary = 'Easy way to build and manage commands (service objects)'
12
+ s.description = 'Easy way to build and manage commands (service objects)'
13
+ s.homepage = 'http://github.com/Swile/easy-command'
14
+ s.license = 'MIT'
15
+
16
+ s.metadata['rubygems_mfa_required'] = 'true'
17
+
18
+ s.metadata["source_code_uri"] = "https://github.com/Swile/easy-command"
19
+ s.metadata["github_repo"] = "ssh://github.com/Swile/easy-command"
20
+
21
+ s.files = `git ls-files -z`.split("\x0")
22
+ s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
23
+ s.require_paths = ['lib']
24
+
25
+ s.add_development_dependency 'bundler', '~> 2.0' # rubocop:disable Gemspec/DevelopmentDependencies
26
+ end
@@ -0,0 +1,16 @@
1
+ module EasyCommand
2
+ module Chainable
3
+ def then(other)
4
+ if success?
5
+ other.call(result)
6
+ else
7
+ self
8
+ end
9
+ end
10
+ alias_method :|, :then
11
+
12
+ def self.included(klass)
13
+ klass.define_method(:call) { self } unless klass.instance_methods.include? :call
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,85 @@
1
+ module EasyCommand
2
+ class NotImplementedError < ::StandardError; end
3
+
4
+ class Errors < Hash
5
+ attr_reader :source
6
+
7
+ def initialize(source: nil)
8
+ @source = source
9
+ super()
10
+ end
11
+
12
+ def exists?(attribute, code)
13
+ fetch(attribute, []).any? { |e| e[:code] == code }
14
+ end
15
+ alias_method :has_error?, :exists?
16
+
17
+ def add(attribute, code, message_or_key = nil, **options)
18
+ code = code.to_sym
19
+ message_or_key ||= code
20
+
21
+ if defined?(I18n)
22
+ # Can't use `I18n.exists?` because it doesn't accept a scope: kwarg
23
+ message =
24
+ begin
25
+ I18n.t!(message_or_key, scope: source&.i18n_scope, **options)
26
+ rescue I18n::MissingTranslationData
27
+ nil
28
+ end
29
+ end
30
+ message ||= message_or_key
31
+
32
+ self[attribute] ||= []
33
+ self[attribute] << { code: code, message: message }
34
+ self[attribute].uniq!
35
+ end
36
+
37
+ def merge_from(object)
38
+ raise ArgumentError unless object.respond_to?(:errors)
39
+ errors =
40
+ if object.errors.respond_to?(:messages)
41
+ object.errors.messages.each_with_object({}) do |(attribute, messages), object_errors|
42
+ object_errors[attribute] = messages.
43
+ zip(object.errors.details[attribute]).
44
+ map { |message, detail| [detail[:error], message] }
45
+ end
46
+ else
47
+ object.errors
48
+ end
49
+
50
+ add_multiple_errors(errors)
51
+ end
52
+
53
+ def add_multiple_errors(errors_hash)
54
+ errors_hash.each do |key, values|
55
+ values.each do |value|
56
+ if value.is_a?(Hash)
57
+ code = value[:code]
58
+ message_or_key = value[:message]
59
+ else
60
+ code = value.first
61
+ message_or_key = value.last || value.first
62
+ end
63
+ add(key, code, message_or_key)
64
+ end
65
+ end
66
+ end
67
+
68
+ # For SimpleCommand gem compatibility, to ease migration.
69
+ def full_messages
70
+ messages = []
71
+ each do |attribute, errors|
72
+ errors.each do |error|
73
+ messages << full_message(attribute, error[:message])
74
+ end
75
+ end
76
+ messages
77
+ end
78
+
79
+ def full_message(attribute, message)
80
+ return message if attribute == :base
81
+ attr_name = attribute.to_s.tr('.', '_').capitalize
82
+ format("%s %s", attr_name, message)
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,53 @@
1
+ require 'easy_command/chainable'
2
+
3
+ module EasyCommand
4
+ class Result
5
+ include Chainable
6
+
7
+ def initialize(content)
8
+ @content = content
9
+ end
10
+
11
+ def result
12
+ @content
13
+ end
14
+
15
+ def errors
16
+ EasyCommand::Errors.new
17
+ end
18
+
19
+ def failure?; false; end
20
+ def success?; true; end
21
+
22
+ def on_success
23
+ yield(result) if success?
24
+ self
25
+ end
26
+
27
+ def on_failure
28
+ yield(errors) if failure?
29
+ self
30
+ end
31
+ end
32
+
33
+ class Success < Result; end
34
+
35
+ class Params < Result; end
36
+
37
+ class Failure < Result
38
+ def success?; false; end
39
+ def failure?; true; end
40
+
41
+ def with_errors(errors)
42
+ @_errors = errors
43
+ self
44
+ end
45
+
46
+ def errors
47
+ @_errors ||=
48
+ EasyCommand::Errors.new.tap do |errors|
49
+ errors.add(:result, :failure)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,49 @@
1
+ # rubocop:disable Naming/FileName
2
+ # rubocop:enable Naming/FileName
3
+ # frozen_string_literal: true
4
+
5
+ module EasyCommand
6
+ class Result
7
+ module ClassMethods
8
+ ruby2_keywords def [](*args)
9
+ new(*args)
10
+ end
11
+ end
12
+ extend ClassMethods
13
+ end
14
+
15
+ module ClassMethods
16
+ ruby2_keywords def call(*args)
17
+ new(*args).call
18
+ end
19
+ end
20
+
21
+ ruby2_keywords def abort(*args)
22
+ errors.add(*args)
23
+ raise ExitError
24
+ end
25
+
26
+ module LegacyErrorHandling
27
+ # Convenience/retrocompatibility aliases
28
+ def self.errors_legacy_alias(method, errors_method)
29
+ ruby2_keywords define_method(method) { |*args|
30
+ warn "/!\\ #{method} is deprecated, please use errors.#{errors_method} instead."
31
+ errors.__send__ errors_method, *args
32
+ }
33
+ end
34
+ end
35
+
36
+ ruby2_keywords def assert_subcommand(klass, *args)
37
+ command_instance = klass.new(*args).as_sub_command
38
+ (@sub_commands ||= []) << command_instance
39
+ command = command_instance.call
40
+ return command.result if command.success?
41
+ errors.merge_from(command)
42
+ raise ExitError.new(result: command.result)
43
+ end
44
+
45
+ def assert_sub(...)
46
+ warn "/!\\ 'assert_sub' is deprecated, please use 'assert_subcommand' instead."
47
+ assert_subcommand(...)
48
+ end
49
+ end
@@ -0,0 +1,53 @@
1
+ # rubocop:disable Naming/FileName
2
+ # rubocop:enable Naming/FileName
3
+ # frozen_string_literal: true
4
+
5
+ module EasyCommand
6
+ class Result
7
+ module ClassMethods
8
+ def [](*args)
9
+ new(*args)
10
+ end
11
+ end
12
+ extend ClassMethods
13
+ end
14
+
15
+ module ClassMethods
16
+ def call(*args)
17
+ new(*args).call
18
+ end
19
+ end
20
+
21
+ def abort(*args)
22
+ errors.add(*args)
23
+ raise ExitError
24
+ end
25
+
26
+ def assert(*_args)
27
+ raise ExitError if errors.any?
28
+ end
29
+
30
+ module LegacyErrorHandling
31
+ # Convenience/retrocompatibility aliases
32
+ def self.errors_legacy_alias(method, errors_method)
33
+ define_method method do |*args|
34
+ warn "/!\\ #{method} is deprecated, please use errors.#{errors_method} instead."
35
+ errors.__send__ errors_method, *args
36
+ end
37
+ end
38
+ end
39
+
40
+ def assert_subcommand(klass, *args)
41
+ command_instance = klass.new(*args).as_sub_command
42
+ (@sub_commands ||= []) << command_instance
43
+ command = command_instance.call
44
+ return command.result if command.success?
45
+ errors.merge_from(command)
46
+ raise ExitError.new(result: command.result)
47
+ end
48
+
49
+ def assert_sub(klass, *args)
50
+ warn "/!\\ 'assert_sub' is deprecated, please use 'assert_subcommand' instead."
51
+ assert_subcommand(klass, *args)
52
+ end
53
+ end
@@ -0,0 +1,49 @@
1
+ # rubocop:disable Naming/FileName
2
+ # rubocop:enable Naming/FileName
3
+ # frozen_string_literal: true
4
+
5
+ module EasyCommand
6
+ class Result
7
+ module ClassMethods
8
+ def [](*args, **kwargs)
9
+ new(*args, **kwargs)
10
+ end
11
+ end
12
+ extend ClassMethods
13
+ end
14
+
15
+ module ClassMethods
16
+ def call(*args, **kwargs)
17
+ new(*args, **kwargs).call
18
+ end
19
+ end
20
+
21
+ def abort(*args, **kwargs)
22
+ errors.add(*args, **kwargs)
23
+ raise ExitError
24
+ end
25
+
26
+ module LegacyErrorHandling
27
+ # Convenience/retrocompatibility aliases
28
+ def self.errors_legacy_alias(method, errors_method)
29
+ define_method method do |*args, **kwargs|
30
+ warn "/!\\ #{method} is deprecated, please use errors.#{errors_method} instead."
31
+ errors.__send__ errors_method, *args, **kwargs
32
+ end
33
+ end
34
+ end
35
+
36
+ def assert_subcommand(klass, *args, **kwargs)
37
+ command_instance = klass.new(*args, **kwargs).as_sub_command
38
+ (@sub_commands ||= []) << command_instance
39
+ command = command_instance.call
40
+ return command.result if command.success?
41
+ errors.merge_from(command)
42
+ raise ExitError.new(result: command.result)
43
+ end
44
+
45
+ def assert_sub(...)
46
+ warn "/!\\ 'assert_sub' is deprecated, please use 'assert_subcommand' instead."
47
+ assert_subcommand(...)
48
+ end
49
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TODO: Rewrite this as a pure module definition to remove dependency on RSpec
4
+ require 'rspec/matchers'
5
+
6
+ =begin
7
+ it { expect(Extinguishers::PayloadValidator).to have_been_called_with_acp(payload) }
8
+ it { is_expected.to be_failure }
9
+ it { is_expected.to have_failed }
10
+ it { is_expected.to have_failed.with_error(:date, :invalid) }
11
+ it { is_expected.to have_failed.with_error(:date, :invalid, "The format must be iso8601") }
12
+ it { is_expected.to have_error(:date, :invalid) }
13
+ it { is_expected.to have_error(:date, :invalid, "The format must be iso8601") }
14
+ =end
15
+ module EasyCommand
16
+ module SpecHelpers
17
+ module CommandMatchers
18
+ extend RSpec::Matchers::DSL
19
+
20
+ matcher :have_been_called_with_action_controller_parameters do
21
+ match(notify_expectation_failures: true) do |command_class|
22
+ expect(command_class).to have_received(:call).
23
+ with(an_instance_of(ActionController::Parameters)) do |params|
24
+ expect(params.to_unsafe_h).to match(payload)
25
+ end
26
+ end
27
+ end
28
+ alias_matcher :have_been_called_with_ac_parameters, :have_been_called_with_action_controller_parameters
29
+ alias_matcher :have_been_called_with_acp, :have_been_called_with_action_controller_parameters
30
+
31
+ matcher :have_failed do
32
+ match(notify_expectation_failures: true) do |result|
33
+ expect(result).to have_error(@key, @code, @message) if @key.presence
34
+ result.failure?
35
+ rescue RSpec::Expectations::ExpectationNotMetError => e
36
+ @matcher_error_message = e.message
37
+ false
38
+ end
39
+
40
+ chain :with_error do |key, code, message = nil|
41
+ @key = key
42
+ @code = code
43
+ @message = message
44
+ end
45
+
46
+ failure_message do
47
+ @matcher_error_message
48
+ end
49
+ end
50
+
51
+ matcher :have_error do
52
+ match do |result|
53
+ if message.presence
54
+ result.errors[key]&.include?(code: code, message: message)
55
+ else
56
+ result.errors.exists?(key, code)
57
+ end
58
+ end
59
+
60
+ failure_message do
61
+ err = "expected #{command_name} to have errors on #{to_txt key} with code #{to_txt code}"
62
+ err += " and message #{to_txt message}" if message.present?
63
+ err += "\nactual error for #{to_txt key}: #{@actual.errors[key] || 'nil'}"
64
+ err
65
+ end
66
+
67
+ def command_name
68
+ actual.class.name
69
+ end
70
+
71
+ def key
72
+ expected.first
73
+ end
74
+
75
+ def code
76
+ expected.second
77
+ end
78
+
79
+ def message
80
+ expected.third
81
+ end
82
+
83
+ def to_txt(value)
84
+ value.is_a?(Symbol) ? ":#{value}" : "\"#{value}\""
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyCommand
4
+ module SpecHelpers
5
+ module MockCommandHelper
6
+ NO_PARAMS_PASSED = Object.new
7
+
8
+ def mock_successful_command(command, result:, params: NO_PARAMS_PASSED)
9
+ mock_command(command, success: true, result: result, params: params)
10
+ end
11
+
12
+ =begin
13
+ Example :
14
+ mock_unsuccessful_command(ExtinguishDebtAndLetterIt, errors: {
15
+ entry: { not_found: "Couldn't find Entry with 'document_identifier'='foo'" }
16
+ })
17
+
18
+ is equivalent to
19
+ mock_command(ExtinguishDebtAndLetterIt,
20
+ success: false,
21
+ result: nil,
22
+ errors: {:entry=>[code: :not_found, message: "Couldn't find Entry with 'document_identifier'='foo'"]},
23
+ )
24
+ =end
25
+ def mock_unsuccessful_command(command, errors:, params: NO_PARAMS_PASSED)
26
+ mock_command(command, success: false, errors: detailed_errors(errors), params: params)
27
+ end
28
+
29
+ def mock_command(command, success:, result: nil, errors: {}, params: NO_PARAMS_PASSED)
30
+ if Object.const_defined?('FakeCommandErrors')
31
+ klass = Object.const_get('FakeCommandErrors')
32
+ else
33
+ klass = Object.const_set 'FakeCommandErrors', Class.new
34
+ klass.prepend Command
35
+ end
36
+ fake_command = klass.new
37
+ if errors.any?
38
+ errors.each do |attr, details|
39
+ details.each do |detail|
40
+ fake_command.errors.add(attr, detail[:code], detail[:message])
41
+ end
42
+ end
43
+ end
44
+ double = instance_double(command)
45
+ allow(double).to receive(:as_sub_command).and_return(double)
46
+ allow(double).to receive(:errors).and_return(fake_command.errors)
47
+ monad =
48
+ if success
49
+ Command::Success.new(result)
50
+ else
51
+ Command::Failure.new(result).with_errors(fake_command.errors)
52
+ end
53
+ allow(double).to receive(:call).and_return(monad)
54
+ allow(double).to receive(:on_success).and_return(double)
55
+ if params == NO_PARAMS_PASSED
56
+ allow(command).to receive(:call).and_return(monad)
57
+ allow(command).to receive(:new).and_return(double)
58
+ else
59
+ mock_params, hash_params = extract_mock_params(params)
60
+ allow(command).to receive(:call).with(*mock_params, **hash_params).and_return(monad)
61
+ allow(command).to receive(:new).with(*mock_params, **hash_params).and_return(double)
62
+ end
63
+ double
64
+ end
65
+
66
+ private
67
+
68
+ def extract_mock_params(params)
69
+ if params.is_a? Array
70
+ kw_params =
71
+ if params.last.is_a? Hash
72
+ params.pop
73
+ else
74
+ {}
75
+ end
76
+ [params, kw_params]
77
+ else
78
+ [[params], {}]
79
+ end
80
+ end
81
+
82
+ def detailed_errors(errors)
83
+ errors.transform_values do |detail|
84
+ detail.map { |code, message| { code: code, message: message } }
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,2 @@
1
+ require 'easy_command/spec_helpers/command_matchers'
2
+ require 'easy_command/spec_helpers/mock_command_helper'
@@ -0,0 +1,3 @@
1
+ module EasyCommand
2
+ VERSION = '1.0.0-rc1'.freeze
3
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'byebug'
4
+ if RUBY_VERSION >= "3"
5
+ require "easy_command/ruby-3-specific"
6
+ elsif RUBY_VERSION.start_with? "2.7"
7
+ require "easy_command/ruby-2-7-specific"
8
+ else
9
+ require "easy_command/ruby-2-specific"
10
+ end
11
+
12
+ require 'easy_command/errors'
13
+ require 'easy_command/result'
14
+ require 'easy_command/version'
15
+
16
+ module EasyCommand
17
+ class CommandError < RuntimeError
18
+ attr_reader :code
19
+
20
+ def initialize(message = nil, code = nil)
21
+ @code = code
22
+ super(message)
23
+ end
24
+ end
25
+
26
+ class ExitError < CommandError
27
+ attr_reader :result
28
+
29
+ def initialize(message = nil, code = nil, result: nil)
30
+ @result = result
31
+ super(message, code)
32
+ end
33
+ end
34
+
35
+ def self.prepended(base)
36
+ base.extend ClassMethods
37
+ end
38
+
39
+ attr_reader :result
40
+
41
+ module ClassMethods
42
+ def self.extended(base)
43
+ base.i18n_scope = "errors.messages"
44
+ end
45
+ attr_accessor :i18n_scope
46
+ end
47
+
48
+ def call
49
+ raise NotImplementedError unless defined?(super)
50
+
51
+ result = super
52
+ if errors.none?
53
+ on_success unless @as_sub_command
54
+ Success[result]
55
+ else
56
+ Failure[result].with_errors(errors)
57
+ end
58
+ rescue ExitError => e
59
+ Failure[@result || e.result].with_errors(errors)
60
+ end
61
+
62
+ def abort(*args, result: nil, **kwargs)
63
+ errors.add(*args, **kwargs)
64
+ raise ExitError.new(result: result)
65
+ end
66
+
67
+ def assert(*_args, result: nil)
68
+ raise ExitError.new(result: result) if errors.any?
69
+ end
70
+
71
+ def errors
72
+ @_errors ||= EasyCommand::Errors.new(source: self.class)
73
+ end
74
+
75
+ def on_success
76
+ (@sub_commands ||= []).each(&:on_success)
77
+
78
+ super if defined?(super)
79
+ end
80
+
81
+ module LegacyErrorHandling
82
+ errors_legacy_alias :clear_errors, :clear
83
+ errors_legacy_alias :add_error, :add
84
+ errors_legacy_alias :merge_errors_from, :merge_from
85
+ errors_legacy_alias :has_error?, :exists?
86
+ errors_legacy_alias :full_errors, :itself
87
+ end
88
+ include LegacyErrorHandling
89
+
90
+ def as_sub_command
91
+ @as_sub_command = true
92
+ self
93
+ end
94
+ end
data/locales/en.yml ADDED
@@ -0,0 +1,2 @@
1
+ en:
2
+ hello: 'hello'
@@ -0,0 +1,11 @@
1
+ {
2
+ "packages": {
3
+ ".": {
4
+ "release-type": "ruby",
5
+ "extra-files": [
6
+ "easy_command.gemspec"
7
+ ],
8
+ "version-file": "lib/easy_command/version.rb"
9
+ }
10
+ }
11
+ }