diff --git a/codefresh.yml b/codefresh.yml index b695740..53f8a96 100644 --- a/codefresh.yml +++ b/codefresh.yml @@ -6,6 +6,7 @@ steps: title: 'Build image' type: build image_name: codequest/pipes + registry: cfcr test_library: title: 'Test the gem' diff --git a/codequest_pipes.gemspec b/codequest_pipes.gemspec index 0cdda07..e312bcb 100644 --- a/codequest_pipes.gemspec +++ b/codequest_pipes.gemspec @@ -2,7 +2,7 @@ Gem::Specification.new do |spec| spec.name = 'codequest_pipes' - spec.version = '0.3.1.1' + spec.version = '0.3.2' spec.author = 'codequest' spec.email = 'hello@codequest.com' diff --git a/lib/codequest_pipes.rb b/lib/codequest_pipes.rb index 1072217..ef3a4a7 100755 --- a/lib/codequest_pipes.rb +++ b/lib/codequest_pipes.rb @@ -5,4 +5,5 @@ module Pipes class MissingCallMethod < ::Exception; end class MissingContext < ::Exception; end + class InvalidType < ::Exception; end end # module Pipes diff --git a/lib/codequest_pipes/context.rb b/lib/codequest_pipes/context.rb index 0fcc7ba..155c1e1 100644 --- a/lib/codequest_pipes/context.rb +++ b/lib/codequest_pipes/context.rb @@ -1,10 +1,10 @@ +require 'codequest_pipes/context/error_collector' + module Pipes # Context is an object used to pass data between Pipes. It behaves like an # OpenStruct except you can write a value only once - this way we prevent # context keys from being overwritten. class Context - attr_reader :error - # Override is an exception raised when an attempt is made to override an # existing Context property. class Override < ::StandardError; end @@ -18,7 +18,7 @@ class ExecutionTerminated < ::StandardError; end # @param values [Hash] def initialize(values = {}) add(values) - @error = nil + @error_collector = ErrorCollector.new end # Method `add` allows adding new properties (as a Hash) to the Context. @@ -32,23 +32,20 @@ def add(values) end end - # Quietly fail the pipe, allowing the error to be saved and accessed from - # the Context. + # Quietly fail the pipe. The error will be passed to the error_collector + # and stored in the :base errors collection. # - # @param error [Any] Error to be set. + ## @param error [String] def halt(error = 'Execution stopped') - @error = error + add_errors(base: error) end - # Explicitly fail the pipe, allowing the error to be saved and accessed from - # the Context. - # - # @param error [Any] Error to be set. + # Explicitly fail the pipe. # # @raise [ExecutionTerminated] def terminate(error) halt(error) - fail ExecutionTerminated, error + fail ExecutionTerminated end # Check if the Context finished successfully. @@ -56,7 +53,7 @@ def terminate(error) # # @return [Boolean] Success status. def success? - @error.nil? + errors.empty? end # Check if the Context failed. @@ -73,9 +70,37 @@ def failure? def inspect keys = methods - Object.methods - Pipes::Context.instance_methods fields = keys.map { |key| "#{key}=#{public_send(key).inspect}" } - fields << "@error=#{@error.inspect}" + fields << "@errors=#{@errors.inspect}" object_id_hex = '%x' % (object_id << 1) "#" end + + # Return errors from ErrorCollector object. + # + # @return [Hash] + def errors + error_collector.errors + end + + # This method is added to maintain backwards compatibility - previous + # versions implemented a single @error instance variable of String for error + # storage. + # + # @return [String] + def error + errors[:base]&.first + end + + # Add errors to ErrorCollector object. + # It doesn't fail the pipe as opposed to `halt` and `terminate` methods. + # + # @param collectable_errors [Hash] + def add_errors(collectable_errors) + error_collector.add(collectable_errors) + end + + private + + attr_reader :error_collector end # class Context end # module Pipes diff --git a/lib/codequest_pipes/context/error_collector.rb b/lib/codequest_pipes/context/error_collector.rb new file mode 100644 index 0000000..60a048c --- /dev/null +++ b/lib/codequest_pipes/context/error_collector.rb @@ -0,0 +1,20 @@ +module Pipes + class Context + # ErrorCollector is Context's companion object for storing non-critical + # errors. + class ErrorCollector + attr_reader :errors + + def initialize + @errors = {} + end + + def add(errors_hash) + errors_hash.map do |key, errors| + @errors[key] ||= [] + @errors[key] = @errors[key] | Array(errors) + end + end + end # class ErrorColletor + end # class Context +end # module Pipes diff --git a/lib/codequest_pipes/pipe.rb b/lib/codequest_pipes/pipe.rb index d2eff11..0817580 100644 --- a/lib/codequest_pipes/pipe.rb +++ b/lib/codequest_pipes/pipe.rb @@ -20,18 +20,18 @@ def self.|(other) end def self.call(ctx) - return ctx if ctx.error + return ctx if ctx.errors.any? _validate_ctx(_required_context_elements, ctx) new(ctx).call _validate_ctx(_provided_context_elements, ctx) end - def self.require_context(*args) - _required_context_elements.push(*args) + def self.require_context(*args, **kwargs) + _merge_context_elements(_required_context_elements, args, kwargs) end - def self.provide_context(*args) - _provided_context_elements.push(*args) + def self.provide_context(*args, **kwargs) + _merge_context_elements(_provided_context_elements, args, kwargs) end def self._combine(first, second) @@ -50,23 +50,45 @@ def self._check_interface(klass) private_class_method :_check_interface def self._required_context_elements - @required_context_elements ||= [] + @required_context_elements ||= {} end private_class_method :_required_context_elements def self._provided_context_elements - @provided_context_elements ||= [] + @provided_context_elements ||= {} end private_class_method :_provided_context_elements def self._validate_ctx(collection, ctx) - collection.each do |element| - next if ctx.respond_to?(element) - fail MissingContext, "context does not respond to '#{element}'" + collection.each do |element, klass| + _validate_value_presence(ctx, element) + _validate_value_type(ctx, element, klass) if klass end end private_class_method :_validate_ctx + def self._validate_value_presence(ctx, element) + return if ctx.respond_to?(element) + raise MissingContext, "context does not respond to '#{element}'" + end + private_class_method :_validate_value_presence + + def self._validate_value_type(ctx, element, klass) + obj = ctx.public_send(element) + return if obj.is_a?(klass) + raise InvalidType, + "'#{element}' has invalid type #{obj.class} (expected: #{klass})" + end + private_class_method :_validate_value_type + + def self._merge_context_elements(elements, args, kwargs) + elements.merge!( + **args.map { |a| [a, nil] }.to_h, + **kwargs + ) + end + private_class_method :_merge_context_elements + private def method_missing(name, *args, &block) diff --git a/spec/context_spec.rb b/spec/context_spec.rb index bd632cc..e058c32 100644 --- a/spec/context_spec.rb +++ b/spec/context_spec.rb @@ -7,6 +7,12 @@ expect(subject.key).to eq('val') end + it 'can add multiple fields' do + subject.add(bacon: 'yum', raisins: 'bleh') + expect(subject.bacon).to eq('yum') + expect(subject.raisins).to eq('bleh') + end + it 'does not allow rewriting existing fields' do subject.add(key: 'val') expect { subject.add(key: 'other_val') } @@ -15,16 +21,31 @@ end # describe '#add' describe '#inspect' do - it 'lists all fields' do - subject.add(bacon: 'yum', raisins: 'bleh') - expect(subject.inspect) - .to match(/bacon=\"yum\", raisins=\"bleh\", @error=nil/) - end - it 'lists nested contexts' do subject.add(nested: Pipes::Context.new(foo: 'bar')) expect(subject.inspect) - .to match(/nested=#,/) + .to match(/nested=#,/) end end # describe '#inspect' + + describe '#add_errors' do + it 'adds error to error_collector' do + subject.add_errors(base: 'Error message') + subject.add_errors( + base: ['Another error message'], + user: 'User error message' + ) + expect(subject.errors).to eq( + base: ['Error message', 'Another error message'], + user: ['User error message'] + ) + end + end # describe '#add_errors' + + describe '#halt' do + it 'adds error to error collector :base' do + subject.halt('Some error') + expect(subject.error).to eq('Some error') + end + end # describe '#halt' end # describe Pipes::Context diff --git a/spec/matcher_spec.rb b/spec/matcher_spec.rb index 71f2c2d..3ad7b55 100644 --- a/spec/matcher_spec.rb +++ b/spec/matcher_spec.rb @@ -13,7 +13,7 @@ shared_examples_for 'fails_with_message' do |message| it 'fails' do expected_message = - message || /expected # to match/ + message || /expected # to match/ expect { expect(ctx).to match(pipe_context(expected)) } .to fail_with(expected_message) end diff --git a/spec/pipe_spec.rb b/spec/pipe_spec.rb index 426536b..366dfbf 100644 --- a/spec/pipe_spec.rb +++ b/spec/pipe_spec.rb @@ -20,6 +20,45 @@ def call end end +class ProvidingChild < Parent + provide_context :bacon + + def call + super + add(bacon: true) + end +end # class ProvidingChild + +class ProvidingNumericChild < Parent + provide_context bacon: Numeric + + def call + super + add(bacon: 4) + end +end # class ProvidingNumericChild + +class NotProvidingChild < Parent + provide_context :bacon +end # class NotProvidingChild + +class ProvidingInvalidChild < Parent + provide_context bacon: Numeric + + def call + super + add(bacon: "yes, please") + end +end # class ProvidingInvalidChild + +class RequiringChild < Parent + require_context :bacon +end # class RequiringChild + +class RequiringNumericChild < Parent + require_context bacon: Numeric +end # class RequiringNumericChild + # NoMethodPipe will break with NoMethodError. class NoMethodPipe < Pipes::Pipe; end @@ -55,15 +94,6 @@ class NoMethodPipe < Pipes::Pipe; end describe '.provide_context' do context 'when context element provided' do - class ProvidingChild < Parent - provide_context :bacon - - def call - super - add(bacon: true) - end - end # class ProvideChild - let(:pipe) { Parent | ProvidingChild } it 'does not raise' do @@ -72,16 +102,62 @@ def call end # context 'when context element provided' context 'when context element not provided' do - class NotProvidingChild < Parent - provide_context :bacon - end - let(:pipe) { Parent | NotProvidingChild } it 'raises MissingContext' do expect { subject }.to raise_error Pipes::MissingContext end end # context 'when context element not provided' + + context 'when context element with invalid type provided' do + let(:pipe) { Parent | ProvidingInvalidChild } + + it 'raises InvalidType' do + expect { subject }.to raise_error Pipes::InvalidType + end + end # context 'when context element with invalid type provided' + + context 'when context element with valid type provided' do + let(:pipe) { Parent | ProvidingNumericChild } + + it 'does not raise' do + expect { subject }.to_not raise_error + end + end # context 'when context element with valid type provided' + end # describe '.provide_context' + + describe '.require_context' do + context 'when required context element present' do + let(:pipe) { ProvidingChild | RequiringChild } + + it 'does not raise' do + expect { subject }.to_not raise_error + end + end # context 'when required context element present' + + context 'when required context element missing' do + let(:pipe) { Parent | RequiringChild } + + it 'raises MissingContext' do + expect { subject }.to raise_error Pipes::MissingContext + end + end # context 'when context element missing' + + context 'when required context element present with invalid type' do + let(:pipe) { ProvidingInvalidChild | RequiringNumericChild } + + it 'raises InvalidType' do + expect { subject }.to raise_error Pipes::InvalidType + end + end # context 'when context element present with invalid type' + + context 'when required context element present with valid type' do + let(:pipe) { ProvidingNumericChild | RequiringNumericChild } + + it 'does not raise' do + expect { subject }.to_not raise_error + end + end # context 'when context element present with valid type' end # describe '.provide_context' describe 'pipes declared using Pipe::Closure' do