From d0a1b729a9a30dc1430612961f20e4a13ec83e5b Mon Sep 17 00:00:00 2001 From: Alexander Yunin Date: Thu, 28 Jan 2016 14:50:19 +0700 Subject: [PATCH] Add option :merge to expose. Allow to merge fields into nested hashes or into the root. This also closes #138 --- CHANGELOG.md | 2 + README.md | 31 +++++++++++- lib/grape_entity/entity.rb | 3 +- lib/grape_entity/exposure/base.rb | 3 +- lib/grape_entity/exposure/nesting_exposure.rb | 11 +++-- .../nesting_exposure/output_builder.rb | 49 +++++++++++++++++++ spec/grape_entity/entity_spec.rb | 37 +++++++++++++- 7 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 lib/grape_entity/exposure/nesting_exposure/output_builder.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index d9de9412..a4e5ef35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ * Your contribution here. +* [#204](https://github.com/ruby-grape/grape-entity/pull/204): Added ability to merge fields into hashes/root (`:merge` option for `.expose`): [#138](https://github.com/ruby-grape/grape-entity/issues/138) - [@avyy](https://github.com/avyy). + 0.5.0 (2015-12-07) ================== diff --git a/README.md b/README.md index e69c1675..1e82ca6c 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,10 @@ module API expose :text, documentation: { type: "String", desc: "Status update text." } expose :ip, if: { type: :full } expose :user_type, :user_id, if: lambda { |status, options| status.user.public? } + expose :location, merge: true expose :contact_info do expose :phone - expose :address, using: API::Entities::Address + expose :address, merge: true, using: API::Entities::Address end expose :digest do |status, options| Digest::MD5.hexdigest status.txt @@ -153,6 +154,34 @@ As example: ``` +#### Merge Fields + +Use `:merge` option to merge fields into the hash or into the root: + +```ruby +expose :contact_info do + expose :phone + expose :address, merge: true, using: API::Entities::Address +end + +expose :status, merge: true +``` + +This will return something like: + +```ruby +{ contact_info: { phone: "88002000700", city: 'City 17', address_line: 'Block C' }, text: 'HL3', likes: 19 } +``` + +It also works with collections: + +```ruby +expose :profiles do + expose :users, merge: true, using: API::Entities::User + expose :admins, merge: true, using: API::Entities::Admin +end +``` + #### Runtime Exposure Use a block or a `Proc` to evaluate exposure at runtime. The supplied block or diff --git a/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index 90b4fbc0..b10c8b1d 100644 --- a/lib/grape_entity/entity.rb +++ b/lib/grape_entity/entity.rb @@ -146,6 +146,7 @@ def self.inherited(subclass) # block to the expose call to achieve the same effect. # @option options :documentation Define documenation for an exposed # field, typically the value is a hash with two fields, type and desc. + # @option options :merge This option allows you to merge an exposed field to the root def self.expose(*args, &block) options = merge_options(args.last.is_a?(Hash) ? args.pop : {}) @@ -498,7 +499,7 @@ def to_xml(options = {}) # All supported options. OPTIONS = [ - :rewrite, :as, :if, :unless, :using, :with, :proc, :documentation, :format_with, :safe, :attr_path, :if_extras, :unless_extras + :rewrite, :as, :if, :unless, :using, :with, :proc, :documentation, :format_with, :safe, :attr_path, :if_extras, :unless_extras, :merge ].to_set.freeze # Merges the given options with current block options. diff --git a/lib/grape_entity/exposure/base.rb b/lib/grape_entity/exposure/base.rb index ae2f2987..8c7d6e68 100644 --- a/lib/grape_entity/exposure/base.rb +++ b/lib/grape_entity/exposure/base.rb @@ -2,7 +2,7 @@ module Grape class Entity module Exposure class Base - attr_reader :attribute, :key, :is_safe, :documentation, :conditions + attr_reader :attribute, :key, :is_safe, :documentation, :conditions, :for_merge def self.new(attribute, options, conditions, *args, &block) super(attribute, options, conditions).tap { |e| e.setup(*args, &block) } @@ -13,6 +13,7 @@ def initialize(attribute, options, conditions) @options = options @key = (options[:as] || attribute).try(:to_sym) @is_safe = options[:safe] + @for_merge = options[:merge] @attr_path_proc = options[:attr_path] @documentation = options[:documentation] @conditions = conditions diff --git a/lib/grape_entity/exposure/nesting_exposure.rb b/lib/grape_entity/exposure/nesting_exposure.rb index 4e2dfc49..95da4075 100644 --- a/lib/grape_entity/exposure/nesting_exposure.rb +++ b/lib/grape_entity/exposure/nesting_exposure.rb @@ -30,11 +30,12 @@ def valid?(entity) def value(entity, options) new_options = nesting_options_for(options) + output = OutputBuilder.new - normalized_exposures(entity, new_options).each_with_object({}) do |exposure, output| + normalized_exposures(entity, new_options).each_with_object(output) do |exposure, out| exposure.with_attr_path(entity, new_options) do result = exposure.value(entity, new_options) - output[exposure.key] = result + out.add(exposure, result) end end end @@ -53,11 +54,12 @@ def valid_value_for(key, entity, options) def serializable_value(entity, options) new_options = nesting_options_for(options) + output = OutputBuilder.new - normalized_exposures(entity, new_options).each_with_object({}) do |exposure, output| + normalized_exposures(entity, new_options).each_with_object(output) do |exposure, out| exposure.with_attr_path(entity, new_options) do result = exposure.serializable_value(entity, new_options) - output[exposure.key] = result + out.add(exposure, result) end end end @@ -126,3 +128,4 @@ def normalized_exposures(entity, options) end require 'grape_entity/exposure/nesting_exposure/nested_exposures' +require 'grape_entity/exposure/nesting_exposure/output_builder' diff --git a/lib/grape_entity/exposure/nesting_exposure/output_builder.rb b/lib/grape_entity/exposure/nesting_exposure/output_builder.rb new file mode 100644 index 00000000..25ce6913 --- /dev/null +++ b/lib/grape_entity/exposure/nesting_exposure/output_builder.rb @@ -0,0 +1,49 @@ +module Grape + class Entity + module Exposure + class NestingExposure + class OutputBuilder < SimpleDelegator + def initialize + @output_hash = {} + @output_collection = [] + end + + def add(exposure, result) + # Save a result array in collections' array if it should be merged + if result.is_a?(Array) && exposure.for_merge + @output_collection << result + else + + # If we have an array which should not be merged - save it with a key as a hash + # If we have hash which should be merged - save it without a key (merge) + if exposure.for_merge + @output_hash.merge! result + else + @output_hash[exposure.key] = result + end + + end + end + + def __getobj__ + output + end + + private + + # If output_collection contains at least one element we have to represent the output as a collection + def output + if @output_collection.empty? + output = @output_hash + else + output = @output_collection + output << @output_hash unless @output_hash.empty? + output.flatten! + end + output + end + end + end + end + end +end diff --git a/spec/grape_entity/entity_spec.rb b/spec/grape_entity/entity_spec.rb index 08457744..8d54faa0 100644 --- a/spec/grape_entity/entity_spec.rb +++ b/spec/grape_entity/entity_spec.rb @@ -35,6 +35,17 @@ end end + context 'with a :merge option' do + let(:nested_hash) do + { something: { like_nested_hash: true } } + end + + it 'merges an exposure to the root' do + subject.expose(:something, merge: true) + expect(subject.represent(nested_hash).serializable_hash).to eq(nested_hash[:something]) + end + end + context 'with a block' do it 'errors out if called with multiple attributes' do expect { subject.expose(:name, :email) { true } }.to raise_error ArgumentError @@ -243,6 +254,30 @@ class Parent < Person } ) end + it 'merges attriutes if :merge option is passed' do + user_entity = Class.new(Grape::Entity) + admin_entity = Class.new(Grape::Entity) + user_entity.expose(:id, :name) + admin_entity.expose(:id, :name) + + subject.expose(:profiles) do + subject.expose(:users, merge: true, using: user_entity) + subject.expose(:admins, merge: true, using: admin_entity) + end + + subject.expose :awesome do + subject.expose(:nested, merge: true) { |_| { just_a_key: 'value' } } + subject.expose(:another_nested, merge: true) { |_| { just_another_key: 'value' } } + end + + additional_hash = { users: [{ id: 1, name: 'John' }, { id: 2, name: 'Jay' }], + admins: [{ id: 3, name: 'Jack' }, { id: 4, name: 'James' }] + } + expect(subject.represent(additional_hash).serializable_hash).to eq( + profiles: additional_hash[:users] + additional_hash[:admins], + awesome: { just_a_key: 'value', just_another_key: 'value' } + ) + end end end @@ -863,7 +898,7 @@ class Parent < Person subject.expose :my_items representation = subject.represent(4.times.map { Object.new }, serializable: true) - expect(representation).to be_kind_of(Hash) + expect(representation).to be_kind_of(Grape::Entity::Exposure::NestingExposure::OutputBuilder) expect(representation).to have_key :my_items expect(representation[:my_items]).to be_kind_of Array expect(representation[:my_items].size).to be 4