diff --git a/Gemfile.lock b/Gemfile.lock index f9b8445..4e7ce1d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - decanter (3.5.0) + decanter (3.5.1) actionpack (>= 4.2.10) activesupport rails-html-sanitizer (>= 1.0.4) diff --git a/README.md b/README.md index df77f05..8d98140 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ If this option is not provided, autodetect logic is used to determine if the pro - `nil` or not provided: will try to autodetect single vs collection - `true` will always treat the incoming params args as *collection* - `false` will always treat incoming params args as *single object* +- `truthy` will raise an error ### Nested resources diff --git a/lib/decanter.rb b/lib/decanter.rb index b189d72..989173e 100644 --- a/lib/decanter.rb +++ b/lib/decanter.rb @@ -57,7 +57,6 @@ def config require 'decanter/version' require 'decanter/configuration' -require 'decanter/core' require 'decanter/base' require 'decanter/extensions' require 'decanter/exceptions' diff --git a/lib/decanter/base.rb b/lib/decanter/base.rb index 84a6386..c45f0c1 100644 --- a/lib/decanter/base.rb +++ b/lib/decanter/base.rb @@ -1,7 +1,9 @@ require 'decanter/core' +require 'decanter/collection_detection' module Decanter class Base include Core + include CollectionDetection end end diff --git a/lib/decanter/collection_detection.rb b/lib/decanter/collection_detection.rb new file mode 100644 index 0000000..a5d47e3 --- /dev/null +++ b/lib/decanter/collection_detection.rb @@ -0,0 +1,26 @@ +module Decanter + module CollectionDetection + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def decant(args, **options) + return super(args) unless collection?(args, options[:is_collection]) + + args.map { |resource| super(resource) } + end + + private + + # leveraging the approach used in the [fast JSON API gem](https://github.com/Netflix/fast_jsonapi#collection-serialization) + def collection?(args, collection_option = nil) + raise(ArgumentError, "#{name}: Unknown collection option value: #{collection_option}") unless [true, false, nil].include? collection_option + + return collection_option unless collection_option.nil? + + args.respond_to?(:size) && !args.respond_to?(:each_pair) + end + end + end +end diff --git a/lib/decanter/extensions.rb b/lib/decanter/extensions.rb index 85db15a..b2eb73c 100644 --- a/lib/decanter/extensions.rb +++ b/lib/decanter/extensions.rb @@ -1,6 +1,5 @@ module Decanter module Extensions - def self.included(base) base.extend(ClassMethods) end @@ -34,29 +33,13 @@ def decant_create!(args, **options) .save!(context: options[:context]) end - def decant(args, options={}) - is_collection?(args, options[:is_collection]) ? decant_collection(args, options) : decant_args(args, options) - end - - def decant_collection(args, options) - args.map { |resource| decant_args(resource, options) } - end - - def decant_args(args, options) - if specified_decanter = options[:decanter] + def decant(args, options = {}) + if (specified_decanter = options[:decanter]) Decanter.decanter_from(specified_decanter) else Decanter.decanter_for(self) end.decant(args) end - - private - - # leveraging the approach used in the [fast JSON API gem](https://github.com/Netflix/fast_jsonapi#collection-serialization) - def is_collection?(args, collection_option=nil) - return collection_option[:is_collection] unless collection_option.nil? - args.respond_to?(:size) && !args.respond_to?(:each_pair) - end end module ActiveRecordExtensions diff --git a/lib/decanter/version.rb b/lib/decanter/version.rb index 0ce328d..45d83ab 100644 --- a/lib/decanter/version.rb +++ b/lib/decanter/version.rb @@ -1,3 +1,3 @@ module Decanter - VERSION = '3.5.0'.freeze + VERSION = '3.5.1'.freeze end diff --git a/spec/decanter/decanter_collection_detection_spec.rb b/spec/decanter/decanter_collection_detection_spec.rb new file mode 100644 index 0000000..36bb7a6 --- /dev/null +++ b/spec/decanter/decanter_collection_detection_spec.rb @@ -0,0 +1,76 @@ +require 'spec_helper' + +describe Decanter::CollectionDetection do + let(:base_decanter) { + stub_const('BaseDecanter', Class.new) + } + + let(:decanter) { + stub_const('TripDecanter', base_decanter.new) + TripDecanter.class_eval { include Decanter::CollectionDetection } + } + let(:args) { { destination: 'Hawaii' } } + + before(:each) { + allow(base_decanter).to receive(:decant) + } + + describe '#decant' do + context 'when args are a single hash' do + it 'calls decant on the entire element' do + decanter.decant(args) + expect(base_decanter).to have_received(:decant).once.with(args) + end + end + + context 'when no collection option is passed' do + context 'and args are a collection' do + let(:args) { [{ destination: 'Hawaii' }, { destination: 'Denver' }] } + + it 'calls decant with each element' do + decanter.decant(args) + expect(base_decanter).to have_received(:decant).with(args.first) + expect(base_decanter).to have_received(:decant).with(args.second) + end + end + + context 'and args are not a collection' do + let(:args) { { "0": [{ destination: 'Hawaii' }] } } + it 'calls decant on the entire element' do + decanter.decant(args) + expect(base_decanter).to have_received(:decant).once.with(args) + end + end + end + + context 'when the collection option is passed' do + let(:fake_collection) { double('fake_collection') } + let(:args) { fake_collection } + + before(:each) do + allow(fake_collection).to receive(:map).and_yield(1).and_yield(2) + end + + context 'and the value is true' do + it 'is considered a collection' do + decanter.decant(args, is_collection: true) + expect(base_decanter).to have_received(:decant).with(1) + expect(base_decanter).to have_received(:decant).with(2) + end + end + + context 'and the value is false' do + it 'is not considered a collection' do + decanter.decant(args, is_collection: false) + expect(base_decanter).to have_received(:decant).once.with(args) + end + end + + context 'and the value is truthy' do + it 'raises an error' do + expect { decanter.decant(args, is_collection: 'yes') }.to raise_error(ArgumentError) + end + end + end + end +end diff --git a/spec/decanter/decanter_core_spec.rb b/spec/decanter/decanter_core_spec.rb index 26961e8..113f592 100644 --- a/spec/decanter/decanter_core_spec.rb +++ b/spec/decanter/decanter_core_spec.rb @@ -2,7 +2,7 @@ describe Decanter::Core do - let(:dummy) { Class.new(Decanter::Base) } + let(:dummy) { Class.new { include Decanter::Core } } before(:each) do Decanter::Core.class_variable_set(:@@handlers, {}) @@ -576,7 +576,8 @@ def self.name context 'when inputs are required' do let(:decanter) { - Class.new(Decanter::Base) do + Class.new do + include Decanter::Core input :name, :pass, required: true end } @@ -604,7 +605,8 @@ def self.name context 'when params keys are strings' do let(:decanter) { - Class.new(Decanter::Base) do + Class.new do + include Decanter::Core input :name, :string input :description, :string end @@ -621,7 +623,8 @@ def self.name context 'and when inputs are strings' do let(:decanter) { - Class.new(Decanter::Base) do + Class.new do + include Decanter::Core input 'name', :string input 'description', :string end @@ -650,7 +653,8 @@ def self.name context 'with missing non-required args' do let(:decanter) { - Class.new(Decanter::Base) do + Class.new do + include Decanter::Core input :name, :string input :description, :string end @@ -665,7 +669,8 @@ def self.name context 'with key having a :default_value in the decanter' do let(:decanter) { - Class.new(Decanter::Base) do + Class.new do + include Decanter::Core input :name, :string, default_value: 'foo' input :cost, :float, default_value: '99.99' input :description, :string diff --git a/spec/decanter/decanter_extensions_spec.rb b/spec/decanter/decanter_extensions_spec.rb index 703510f..e26b322 100644 --- a/spec/decanter/decanter_extensions_spec.rb +++ b/spec/decanter/decanter_extensions_spec.rb @@ -3,61 +3,6 @@ describe Decanter::Extensions do describe '#decant' do - let(:options) { { } } - let(:dummy_class) { Class.new { include Decanter::Extensions } } - - context 'when args is a single resource' do - let(:args) { { } } - - before(:each) do - allow(dummy_class).to receive(:decant_args).and_return(true) - end - - it 'calls decant_args with the args and options' do - dummy_class.decant(args, options) - expect(dummy_class) - .to have_received(:decant_args) - .with(args, options) - end - end - - context 'when args is a collection' do - let(:args) { [{}, {}] } - - before(:each) do - allow(dummy_class).to receive(:decant_collection).and_return(true) - end - - it 'calls decant_collection with the args and options' do - dummy_class.decant(args, options) - expect(dummy_class) - .to have_received(:decant_collection) - .with(args, options) - end - end - end - - describe '#decant_collection' do - let!(:args) { [{ foo: 'bar' }, { foo: 'baz' }] } - let(:options) { { } } - let(:dummy_class) { Class.new { include Decanter::Extensions } } - - before(:each) do - allow(dummy_class).to receive(:decant_args).and_return(true) - end - - it 'calls decant_args on each element in the collection' do - dummy_class.decant_collection(args, options) - expect(dummy_class) - .to have_received(:decant_args) - .with(args[0], options) - expect(dummy_class) - .to have_received(:decant_args) - .with(args[1], options) - end - end - - describe '#decant_args' do let(:args) { { } } let(:decanter) { class_double('Decanter::Base', decant: true) } @@ -70,7 +15,7 @@ it 'calls Decanter.decanter_from with the specified decanter' do dummy_class = Class.new { include Decanter::Extensions } - dummy_class.decant_args(args, options) + dummy_class.decant(args, options) expect(Decanter) .to have_received(:decanter_from) .with(options[:decanter]) @@ -78,7 +23,7 @@ it 'calls decant on the returned decanter with the args' do dummy_class = Class.new { include Decanter::Extensions } - dummy_class.decant_args(args, options) + dummy_class.decant(args, options) expect(decanter) .to have_received(:decant) .with(args) @@ -94,7 +39,7 @@ it 'calls Decanter.decanter_for with self' do dummy_class = Class.new { include Decanter::Extensions } - dummy_class.decant_args(args, options) + dummy_class.decant(args, options) expect(Decanter) .to have_received(:decanter_for) .with(dummy_class) @@ -102,7 +47,7 @@ it 'calls decant on the returned decanter with the args' do dummy_class = Class.new { include Decanter::Extensions } - dummy_class.decant_args(args, options) + dummy_class.decant(args, options) expect(decanter) .to have_received(:decant) .with(args) @@ -110,33 +55,7 @@ end end - describe '#is_collection?' do - let(:singular_args) { { foo: 'bar' } } - let(:collection_args) { [{ foo: 'bar' }, { foo: 'baz' }] } - let(:dummy_class) { Class.new { include Decanter::Extensions } } - - context 'true' do - it 'when options[:is_collection] is nil and collection is provided' do - expect(dummy_class.send(:is_collection?, collection_args)).to be(true) - end - - it 'when options[:is_collection] is true' do - expect(dummy_class.send(:is_collection?, singular_args, { is_collection: true })).to be(true) - end - end - - context 'false' do - it 'when options[:is_collection] is nil and single object is provided' do - expect(dummy_class.send(:is_collection?, singular_args)).to be(false) - end - - it 'when options[:is_collection] is false' do - expect(dummy_class.send(:is_collection?, collection_args, { is_collection: false })).to be(false) - end - end - end - - context '' do + context 'ActiveRecord::Persistence' do let(:dummy_class) { Class.new { include Decanter::Extensions } } let(:dummy_instance) { dummy_class.new } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index db8a689..0a834d8 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,3 +6,7 @@ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) require 'decanter' + +RSpec.configure do |config| + config.filter_run_when_matching focus: true +end