diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 5a2de356..9d0ce88f 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,38 +1,38 @@ # This configuration was generated by `rubocop --auto-gen-config` -# on 2015-05-21 22:47:03 +0700 using RuboCop version 0.31.0. +# on 2015-08-02 19:30:25 +0300 using RuboCop version 0.31.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 8 +# Offense count: 6 Metrics/AbcSize: - Max: 53 + Max: 33 -# Offense count: 1 +# Offense count: 2 # Configuration parameters: CountComments. Metrics/ClassLength: - Max: 346 + Max: 198 -# Offense count: 5 +# Offense count: 3 Metrics/CyclomaticComplexity: - Max: 17 + Max: 11 -# Offense count: 176 +# Offense count: 208 # Configuration parameters: AllowURI, URISchemes. Metrics/LineLength: Max: 146 -# Offense count: 7 +# Offense count: 8 # Configuration parameters: CountComments. Metrics/MethodLength: - Max: 37 + Max: 28 # Offense count: 5 Metrics/PerceivedComplexity: - Max: 15 + Max: 13 -# Offense count: 31 +# Offense count: 58 Style/Documentation: Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md index c438a147..2e1b4fb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ 0.5.0 (Next) ============ * [#139](https://github.com/intridea/grape-entity/pull/139): Keep a track of attribute nesting path during condition check or runtime exposure - [@calfzhou](https://github.com/calfzhou). +* [#151](https://github.com/intridea/grape-entity/pull/151): `.exposures` is removed and substituted with `.root_exposures` array - [@marshall-lee](https://github.com/marshall-lee). +* [#151](https://github.com/intridea/grape-entity/pull/151): `.nested_exposures` is removed too - [@marshall-lee](https://github.com/marshall-lee). +* [#151](https://github.com/intridea/grape-entity/pull/151): `#should_return_attribute?`, `#only_fields` and `#except_fields` are moved to other classes - [@marshall-lee](https://github.com/marshall-lee). +* [#151](https://github.com/intridea/grape-entity/pull/151): Fix: double exposures with conditions does not rewrite previously defined now: [#56](https://github.com/intridea/grape-entity/issues/56) - [@marshall-lee](https://github.com/marshall-lee). +* [#151](https://github.com/intridea/grape-entity/pull/151): Fix: nested exposures were flattened in `.documentation`: [#112](https://github.com/intridea/grape-entity/issues/112) - [@marshall-lee](https://github.com/marshall-lee). +* [#151](https://github.com/intridea/grape-entity/pull/151): Fix: `@only_fields` and `@except_fields` memoization: [#149](https://github.com/intridea/grape-entity/issues/149) - [@marshall-lee](https://github.com/marshall-lee). +* [#151](https://github.com/intridea/grape-entity/pull/151): Fix: `:unless` condition with `Hash` argument logic: [#150](https://github.com/intridea/grape-entity/issues/150) - [@marshall-lee](https://github.com/marshall-lee). +* [#151](https://github.com/intridea/grape-entity/pull/151): Nested `unexpose` now raises an exception: [#152](https://github.com/intridea/grape-entity/issues/152) - [@marshall-lee](https://github.com/marshall-lee). +* [#151](https://github.com/intridea/grape-entity/pull/151): Fix: `@documentation` memoization: [#153](https://github.com/intridea/grape-entity/issues/153) - [@marshall-lee](https://github.com/marshall-lee). +* [#151](https://github.com/intridea/grape-entity/pull/151): Fix: serializing of deeply nested presenter exposures: [#155](https://github.com/intridea/grape-entity/issues/155) - [@marshall-lee](https://github.com/marshall-lee). +* [#151](https://github.com/intridea/grape-entity/pull/151): Fix: deep projections (`:only`, `:except`) were unaware of nesting: [#156](https://github.com/intridea/grape-entity/issues/156) - [@marshall-lee](https://github.com/marshall-lee). * Your contribution here. 0.4.6 (2015-07-27) diff --git a/lib/grape_entity.rb b/lib/grape_entity.rb index 44815600..fd7a87b0 100644 --- a/lib/grape_entity.rb +++ b/lib/grape_entity.rb @@ -3,3 +3,5 @@ require 'grape_entity/version' require 'grape_entity/entity' require 'grape_entity/delegator' +require 'grape_entity/exposure' +require 'grape_entity/options' diff --git a/lib/grape_entity/condition.rb b/lib/grape_entity/condition.rb new file mode 100644 index 00000000..bf7b2088 --- /dev/null +++ b/lib/grape_entity/condition.rb @@ -0,0 +1,26 @@ +require 'grape_entity/condition/base' +require 'grape_entity/condition/block_condition' +require 'grape_entity/condition/hash_condition' +require 'grape_entity/condition/symbol_condition' + +module Grape + class Entity + module Condition + def self.new_if(arg) + case arg + when Hash then HashCondition.new false, arg + when Proc then BlockCondition.new false, &arg + when Symbol then SymbolCondition.new false, arg + end + end + + def self.new_unless(arg) + case arg + when Hash then HashCondition.new true, arg + when Proc then BlockCondition.new true, &arg + when Symbol then SymbolCondition.new true, arg + end + end + end + end +end diff --git a/lib/grape_entity/condition/base.rb b/lib/grape_entity/condition/base.rb new file mode 100644 index 00000000..d5b7998a --- /dev/null +++ b/lib/grape_entity/condition/base.rb @@ -0,0 +1,35 @@ +module Grape + class Entity + module Condition + class Base + def self.new(inverse, *args, &block) + super(inverse).tap { |e| e.setup(*args, &block) } + end + + def initialize(inverse = false) + @inverse = inverse + end + + def ==(other) + (self.class == other.class) && (self.inversed? == other.inversed?) + end + + def inversed? + @inverse + end + + def met?(entity, options) + !@inverse ? if_value(entity, options) : unless_value(entity, options) + end + + def if_value(_entity, _options) + fail NotImplementedError + end + + def unless_value(entity, options) + !if_value(entity, options) + end + end + end + end +end diff --git a/lib/grape_entity/condition/block_condition.rb b/lib/grape_entity/condition/block_condition.rb new file mode 100644 index 00000000..e15ab9f9 --- /dev/null +++ b/lib/grape_entity/condition/block_condition.rb @@ -0,0 +1,21 @@ +module Grape + class Entity + module Condition + class BlockCondition < Base + attr_reader :block + + def setup(&block) + @block = block + end + + def ==(other) + super && @block == other.block + end + + def if_value(entity, options) + entity.exec_with_object(options, &@block) + end + end + end + end +end diff --git a/lib/grape_entity/condition/hash_condition.rb b/lib/grape_entity/condition/hash_condition.rb new file mode 100644 index 00000000..18cba98f --- /dev/null +++ b/lib/grape_entity/condition/hash_condition.rb @@ -0,0 +1,25 @@ +module Grape + class Entity + module Condition + class HashCondition < Base + attr_reader :cond_hash + + def setup(cond_hash) + @cond_hash = cond_hash + end + + def ==(other) + super && @cond_hash == other.cond_hash + end + + def if_value(_entity, options) + @cond_hash.all? { |k, v| options[k.to_sym] == v } + end + + def unless_value(_entity, options) + @cond_hash.any? { |k, v| options[k.to_sym] != v } + end + end + end + end +end diff --git a/lib/grape_entity/condition/symbol_condition.rb b/lib/grape_entity/condition/symbol_condition.rb new file mode 100644 index 00000000..9c4be7fc --- /dev/null +++ b/lib/grape_entity/condition/symbol_condition.rb @@ -0,0 +1,21 @@ +module Grape + class Entity + module Condition + class SymbolCondition < Base + attr_reader :symbol + + def setup(symbol) + @symbol = symbol + end + + def ==(other) + super && @symbol == other.symbol + end + + def if_value(_entity, options) + options[symbol] + end + end + end + end +end diff --git a/lib/grape_entity/entity.rb b/lib/grape_entity/entity.rb index 414534e6..e455323d 100644 --- a/lib/grape_entity/entity.rb +++ b/lib/grape_entity/entity.rb @@ -99,24 +99,14 @@ def entity(options = {}) end class << self - # Returns exposures that have been declared for this Entity or - # ancestors. The keys are symbolized references to methods on the - # containing object, the values are the options that were passed into expose. - # @return [Hash] of exposures - attr_accessor :exposures - attr_accessor :root_exposures + attr_accessor :root_exposure # Returns all formatters that are registered for this and it's ancestors # @return [Hash] of formatters attr_accessor :formatters - attr_accessor :nested_attribute_names - attr_accessor :nested_exposures end def self.inherited(subclass) - subclass.exposures = exposures.try(:dup) || {} - subclass.root_exposures = root_exposures.try(:dup) || {} - subclass.nested_exposures = nested_exposures.try(:dup) || {} - subclass.nested_attribute_names = nested_attribute_names.try(:dup) || {} + subclass.root_exposure = root_exposure.try(:dup) || build_root_exposure subclass.formatters = formatters.try(:dup) || {} end @@ -155,35 +145,68 @@ def self.expose(*args, &block) fail ArgumentError, 'You may not use block-setting when also using format_with' if block_given? && options[:format_with].respond_to?(:call) - options[:proc] = block if block_given? && block.parameters.any? + if block_given? + if block.parameters.any? + options[:proc] = block + else + options[:nesting] = true + end + end - @nested_attributes ||= [] + @documentation = nil + @nesting_stack ||= [] # rubocop:disable Style/Next args.each do |attribute| - if @nested_attributes.empty? - root_exposures[attribute] = options + exposure = Exposure.new(attribute, options) + + if @nesting_stack.empty? + root_exposures << exposure else - orig_attribute = attribute.to_sym - attribute = "#{@nested_attributes.last}__#{attribute}".to_sym - nested_attribute_names[attribute] = orig_attribute - options[:nested] = true - nested_exposures.deep_merge!(@nested_attributes.last.to_sym => { attribute => options }) + @nesting_stack.last.nested_exposures << exposure end - exposures[attribute] = options - # Nested exposures are given in a block with no parameters. - if block_given? && block.parameters.empty? - @nested_attributes << attribute + if exposure.nesting? + @nesting_stack << exposure block.call - @nested_attributes.pop + @nesting_stack.pop end end end - def self.unexpose(attribute) - exposures.delete(attribute) + def self.build_root_exposure + Exposure.new(nil, nesting: true) + end + + # Returns exposures that have been declared for this Entity on the top level. + # @return [Array] of exposures + def self.root_exposures + root_exposure.nested_exposures + end + + def self.find_exposure(attribute) + root_exposures.find_by(attribute) + end + + def self.unexpose(*attributes) + cannot_unexpose! unless can_unexpose? + @documentation = nil + root_exposures.delete_by(*attributes) + end + + def self.unexpose_all + cannot_unexpose! unless can_unexpose? + @documentation = nil + root_exposures.clear + end + + def self.can_unexpose? + (@nesting_stack ||= []).empty? + end + + def self.cannot_unexpose! + fail "You cannot call 'unexpose` inside of nesting exposure!" end # Set options that will be applied to any exposures declared inside the block. @@ -205,9 +228,9 @@ def self.with_options(options) # the values are document keys in the entity's documentation key. When calling # #docmentation, any exposure without a documentation key will be ignored. def self.documentation - @documentation ||= exposures.each_with_object({}) do |(attribute, exposure_options), memo| - if exposure_options[:documentation].present? - memo[key_for(attribute)] = exposure_options[:documentation] + @documentation ||= root_exposures.each_with_object({}) do |exposure, memo| + if exposure.documentation.present? + memo[exposure.key] = exposure.documentation end end end @@ -363,7 +386,7 @@ def self.present_collection(present_collection = false, collection_name = :items def self.represent(objects, options = {}) if objects.respond_to?(:to_ary) && ! @present_collection root_element = root_element(:collection_root) - inner = objects.to_ary.map { |object| new(object, { collection: true }.merge(options)).presented } + inner = objects.to_ary.map { |object| new(object, options.reverse_merge(collection: true)).presented } else objects = { @collection_name => objects } if @present_collection root_element = root_element(:root) @@ -396,17 +419,21 @@ def presented def initialize(object, options = {}) @object = object @delegator = Delegator.new object - @options = options - end - - def exposures - self.class.exposures + @options = if options.is_a? Options + options + else + Options.new options + end end def root_exposures self.class.root_exposures end + def root_exposure + self.class.root_exposure + end + def documentation self.class.documentation end @@ -427,63 +454,26 @@ def serializable_hash(runtime_options = {}) opts = options.merge(runtime_options || {}) - root_exposures.each_with_object({}) do |(attribute, exposure_options), output| - parent_path = track_attr_path(attribute, opts) - if should_return_attribute?(attribute, opts) && conditions_met?(exposure_options, opts) - output[self.class.key_for(attribute)] = partial_hash(attribute, opts) - end - backtrack_attr_path(parent_path, opts) - end + root_exposure.serializable_value(self, opts) end - def should_return_attribute?(attribute, options) - key = self.class.key_for(attribute) - only = only_fields(options).nil? || - only_fields(options).include?(key) - except = except_fields(options) && except_fields(options).include?(key) && - except_fields(options)[key] == true - only && !except + def exec_with_object(options, &block) + instance_exec(object, options, &block) end - def only_fields(options, for_attribute = nil) - return nil unless options[:only] - - @only_fields ||= options[:only].each_with_object({}) do |attribute, allowed_fields| - if attribute.is_a?(Hash) - attribute.each do |attr, nested_attrs| - allowed_fields[attr] ||= [] - allowed_fields[attr] += nested_attrs - end - else - allowed_fields[attribute] = true - end - end.symbolize_keys - - if for_attribute && @only_fields[for_attribute].is_a?(Array) - @only_fields[for_attribute] - elsif for_attribute.nil? - @only_fields - end + def exec_with_attribute(attribute, &block) + instance_exec(delegate_attribute(attribute), &block) end - def except_fields(options, for_attribute = nil) - return nil unless options[:except] - - @except_fields ||= options[:except].each_with_object({}) do |attribute, allowed_fields| - if attribute.is_a?(Hash) - attribute.each do |attr, nested_attrs| - allowed_fields[attr] ||= [] - allowed_fields[attr] += nested_attrs - end - else - allowed_fields[attribute] = true - end - end.symbolize_keys + def value_for(key, options = Options.new) + root_exposure.valid_value_for(key, self, options) + end - if for_attribute && @except_fields[for_attribute].is_a?(Array) - @except_fields[for_attribute] - elsif for_attribute.nil? - @except_fields + def delegate_attribute(attribute) + if respond_to?(attribute, true) + send(attribute) + else + delegator.delegate(attribute) end end @@ -499,173 +489,9 @@ def to_xml(options = {}) serializable_hash(options).to_xml(options) end - protected - - def self.name_for(attribute) - attribute = attribute.to_sym - nested_attribute_names[attribute] || attribute - end - - def self.key_for(attribute) - exposures[attribute.to_sym][:as] || name_for(attribute) - end - - def self.nested_exposures_for?(attribute) - nested_exposures.key?(attribute) - end - - def nested_value_for(attribute, options) - nested_exposures = self.class.nested_exposures[attribute] - nested_attributes = - nested_exposures.map do |nested_attribute, nested_exposure_options| - parent_path = track_attr_path(nested_attribute, options) - begin - if conditions_met?(nested_exposure_options, options) - [self.class.key_for(nested_attribute), value_for(nested_attribute, options)] - end - ensure - backtrack_attr_path(parent_path, options) - end - end - - Hash[nested_attributes.compact] - end - - def self.path_for(attribute) - key_for(attribute) - end - - def track_attr_path(attribute, options) - parent_path = options[:attr_path] - current_path = self.class.path_for(attribute) - options[:attr_path] = (parent_path || []).dup << current_path unless current_path.nil? - parent_path - end - - def backtrack_attr_path(parent_path, options) - options[:attr_path] = parent_path - end - - def value_for(attribute, options = {}) - exposure_options = exposures[attribute.to_sym] - return unless valid_exposure?(attribute, exposure_options) - - if exposure_options[:using] - exposure_options[:using] = exposure_options[:using].constantize if exposure_options[:using].respond_to? :constantize - - using_options = options_for_using(attribute, options) - - if exposure_options[:proc] - exposure_options[:using].represent(instance_exec(object, options, &exposure_options[:proc]), using_options) - else - exposure_options[:using].represent(delegate_attribute(attribute), using_options) - end - - elsif exposure_options[:proc] - instance_exec(object, options, &exposure_options[:proc]) - - elsif exposure_options[:format_with] - format_with = exposure_options[:format_with] - - if format_with.is_a?(Symbol) && formatters[format_with] - instance_exec(delegate_attribute(attribute), &formatters[format_with]) - elsif format_with.is_a?(Symbol) - send(format_with, delegate_attribute(attribute)) - elsif format_with.respond_to? :call - instance_exec(delegate_attribute(attribute), &format_with) - end - - elsif self.class.nested_exposures_for?(attribute) - nested_value_for(attribute, options) - else - delegate_attribute(attribute) - end - end - - def partial_hash(attribute, options = {}) - partial_output = value_for(attribute, options) - if partial_output.respond_to?(:serializable_hash) - partial_output.serializable_hash(options) - elsif partial_output.is_a?(Array) && !partial_output.map { |o| o.respond_to?(:serializable_hash) }.include?(false) - partial_output.map(&:serializable_hash) - elsif partial_output.is_a?(Hash) - partial_output.each do |key, value| - partial_output[key] = value.serializable_hash if value.respond_to?(:serializable_hash) - end - else - partial_output - end - end - - def delegate_attribute(attribute) - name = self.class.name_for(attribute) - if respond_to?(name, true) - send(name) - else - delegator.delegate(name) - end - end - - def valid_exposure?(attribute, exposure_options) - if self.class.nested_exposures_for?(attribute) - self.class.nested_exposures[attribute].all? { |a, o| valid_exposure?(a, o) } - elsif exposure_options.key?(:proc) - true - else - name = self.class.name_for(attribute) - if exposure_options[:safe] - delegator.delegatable?(name) - else - delegator.delegatable?(name) || fail(NoMethodError, "#{self.class.name} missing attribute `#{name}' on #{object}") - end - end - end - - def conditions_met?(exposure_options, options) - if_conditions = [] - unless exposure_options[:if_extras].nil? - if_conditions.concat(exposure_options[:if_extras]) - end - if_conditions << exposure_options[:if] unless exposure_options[:if].nil? - - if_conditions.each do |if_condition| - case if_condition - when Hash then if_condition.each_pair { |k, v| return false if options[k.to_sym] != v } - when Proc then return false unless instance_exec(object, options, &if_condition) - when Symbol then return false unless options[if_condition] - end - end - - unless_conditions = [] - unless exposure_options[:unless_extras].nil? - unless_conditions.concat(exposure_options[:unless_extras]) - end - unless_conditions << exposure_options[:unless] unless exposure_options[:unless].nil? - - unless_conditions.each do |unless_condition| - case unless_condition - when Hash then unless_condition.each_pair { |k, v| return false if options[k.to_sym] == v } - when Proc then return false if instance_exec(object, options, &unless_condition) - when Symbol then return false if options[unless_condition] - end - end - - true - end - - def options_for_using(attribute, options) - using_options = options.dup - using_options.delete(:collection) - using_options[:root] = nil - using_options[:only] = only_fields(using_options, attribute) - using_options[:except] = except_fields(using_options, attribute) - - using_options - end - # All supported options. OPTIONS = [ - :as, :if, :unless, :using, :with, :proc, :documentation, :format_with, :safe, :if_extras, :unless_extras + :rewrite, :as, :if, :unless, :using, :with, :proc, :documentation, :format_with, :safe, :attr_path, :if_extras, :unless_extras ].to_set.freeze # Merges the given options with current block options. diff --git a/lib/grape_entity/exposure.rb b/lib/grape_entity/exposure.rb new file mode 100644 index 00000000..6a4aba39 --- /dev/null +++ b/lib/grape_entity/exposure.rb @@ -0,0 +1,77 @@ +require 'grape_entity/exposure/base' +require 'grape_entity/exposure/represent_exposure' +require 'grape_entity/exposure/block_exposure' +require 'grape_entity/exposure/delegator_exposure' +require 'grape_entity/exposure/formatter_exposure' +require 'grape_entity/exposure/formatter_block_exposure' +require 'grape_entity/exposure/nesting_exposure' +require 'grape_entity/condition' + +module Grape + class Entity + module Exposure + def self.new(attribute, options) + conditions = compile_conditions(options) + base_args = [attribute, options, conditions] + + if options[:proc] + block_exposure = BlockExposure.new(*base_args, &options[:proc]) + else + delegator_exposure = DelegatorExposure.new(*base_args) + end + + if options[:using] + using_class = options[:using] + + if options[:proc] + RepresentExposure.new(*base_args, using_class, block_exposure) + else + RepresentExposure.new(*base_args, using_class, delegator_exposure) + end + + elsif options[:proc] + block_exposure + + elsif options[:format_with] + format_with = options[:format_with] + + if format_with.is_a? Symbol + FormatterExposure.new(*base_args, format_with) + elsif format_with.respond_to? :call + FormatterBlockExposure.new(*base_args, &format_with) + end + + elsif options[:nesting] + NestingExposure.new(*base_args) + + else + delegator_exposure + end + end + + def self.compile_conditions(options) + if_conditions = [] + unless options[:if_extras].nil? + if_conditions.concat(options[:if_extras]) + end + if_conditions << options[:if] unless options[:if].nil? + + if_conditions.map! do |cond| + Condition.new_if cond + end + + unless_conditions = [] + unless options[:unless_extras].nil? + unless_conditions.concat(options[:unless_extras]) + end + unless_conditions << options[:unless] unless options[:unless].nil? + + unless_conditions.map! do |cond| + Condition.new_unless cond + end + + if_conditions + unless_conditions + end + end + end +end diff --git a/lib/grape_entity/exposure/base.rb b/lib/grape_entity/exposure/base.rb new file mode 100644 index 00000000..c7639a09 --- /dev/null +++ b/lib/grape_entity/exposure/base.rb @@ -0,0 +1,118 @@ +module Grape + class Entity + module Exposure + class Base + attr_reader :attribute, :key, :is_safe, :documentation, :conditions + + def self.new(attribute, options, conditions, *args, &block) + super(attribute, options, conditions).tap { |e| e.setup(*args, &block) } + end + + def initialize(attribute, options, conditions) + @attribute = attribute.try(:to_sym) + @options = options + @key = (options[:as] || attribute).try(:to_sym) + @is_safe = options[:safe] + @attr_path_proc = options[:attr_path] + @documentation = options[:documentation] + @conditions = conditions + end + + def dup(&block) + self.class.new(*dup_args, &block) + end + + def dup_args + [@attribute, @options, @conditions.map(&:dup)] + end + + def ==(other) + self.class == other.class && + @attribute == other.attribute && + @options == other.options && + @conditions == other.conditions + end + + def setup + end + + def nesting? + false + end + + # if we have any nesting exposures with the same name. + def deep_complex_nesting? + false + end + + def valid?(entity) + is_delegatable = entity.delegator.delegatable?(@attribute) + if @is_safe + is_delegatable + else + is_delegatable || fail(NoMethodError, "#{entity.class.name} missing attribute `#{@attribute}' on #{entity.object}") + end + end + + def value(_entity, _options) + fail NotImplementedError + end + + def serializable_value(entity, options) + partial_output = valid_value(entity, options) + + if partial_output.respond_to?(:serializable_hash) + partial_output.serializable_hash(options) + elsif partial_output.is_a?(Array) && partial_output.all? { |o| o.respond_to?(:serializable_hash) } + partial_output.map(&:serializable_hash) + elsif partial_output.is_a?(Hash) + partial_output.each do |key, value| + partial_output[key] = value.serializable_hash if value.respond_to?(:serializable_hash) + end + else + partial_output + end + end + + def valid_value(entity, options) + valid?(entity) && value(entity, options) + end + + def should_return_key?(options) + options.should_return_key?(@key) + end + + def conditional? + !@conditions.empty? + end + + def conditions_met?(entity, options) + @conditions.all? { |condition| condition.met? entity, options } + end + + def should_expose?(entity, options) + should_return_key?(options) && conditions_met?(entity, options) + end + + def attr_path(entity, options) + if @attr_path_proc + entity.exec_with_object(options, &@attr_path_proc) + else + @key + end + end + + def with_attr_path(entity, options) + path_part = attr_path(entity, options) + options.with_attr_path(path_part) do + yield + end + end + + protected + + attr_reader :options + end + end + end +end diff --git a/lib/grape_entity/exposure/block_exposure.rb b/lib/grape_entity/exposure/block_exposure.rb new file mode 100644 index 00000000..268eeec8 --- /dev/null +++ b/lib/grape_entity/exposure/block_exposure.rb @@ -0,0 +1,29 @@ +module Grape + class Entity + module Exposure + class BlockExposure < Base + attr_reader :block + + def value(entity, options) + entity.exec_with_object(options, &@block) + end + + def dup + super(&@block) + end + + def ==(other) + super && @block == other.block + end + + def valid?(_entity) + true + end + + def setup(&block) + @block = block + end + end + end + end +end diff --git a/lib/grape_entity/exposure/delegator_exposure.rb b/lib/grape_entity/exposure/delegator_exposure.rb new file mode 100644 index 00000000..1f24d989 --- /dev/null +++ b/lib/grape_entity/exposure/delegator_exposure.rb @@ -0,0 +1,11 @@ +module Grape + class Entity + module Exposure + class DelegatorExposure < Base + def value(entity, _options) + entity.delegate_attribute(attribute) + end + end + end + end +end diff --git a/lib/grape_entity/exposure/formatter_block_exposure.rb b/lib/grape_entity/exposure/formatter_block_exposure.rb new file mode 100644 index 00000000..b0dabe13 --- /dev/null +++ b/lib/grape_entity/exposure/formatter_block_exposure.rb @@ -0,0 +1,25 @@ +module Grape + class Entity + module Exposure + class FormatterBlockExposure < Base + attr_reader :format_with + + def setup(&format_with) + @format_with = format_with + end + + def dup + super(&@format_with) + end + + def ==(other) + super && @format_with == other.format_with + end + + def value(entity, _options) + entity.exec_with_attribute(attribute, &@format_with) + end + end + end + end +end diff --git a/lib/grape_entity/exposure/formatter_exposure.rb b/lib/grape_entity/exposure/formatter_exposure.rb new file mode 100644 index 00000000..79038f34 --- /dev/null +++ b/lib/grape_entity/exposure/formatter_exposure.rb @@ -0,0 +1,30 @@ +module Grape + class Entity + module Exposure + class FormatterExposure < Base + attr_reader :format_with + + def setup(format_with) + @format_with = format_with + end + + def dup_args + [*super, format_with] + end + + def ==(other) + super && @format_with == other.format_with + end + + def value(entity, _options) + formatters = entity.class.formatters + if formatters[@format_with] + entity.exec_with_attribute(attribute, &formatters[@format_with]) + else + entity.send(@format_with, entity.delegate_attribute(attribute)) + end + end + end + end + end +end diff --git a/lib/grape_entity/exposure/nesting_exposure.rb b/lib/grape_entity/exposure/nesting_exposure.rb new file mode 100644 index 00000000..f309545d --- /dev/null +++ b/lib/grape_entity/exposure/nesting_exposure.rb @@ -0,0 +1,125 @@ +module Grape + class Entity + module Exposure + class NestingExposure < Base + attr_reader :nested_exposures + + def setup(nested_exposures = []) + @nested_exposures = NestedExposures.new(nested_exposures) + end + + def dup_args + [*super, @nested_exposures.map(&:dup)] + end + + def ==(other) + super && @nested_exposures == other.nested_exposures + end + + def nesting? + true + end + + def find_nested_exposure(attribute) + nested_exposures.find_by(attribute) + end + + def valid?(entity) + nested_exposures.all? { |e| e.valid?(entity) } + end + + def value(entity, options) + new_options = nesting_options_for(options) + + normalized_exposures(entity, new_options).each_with_object({}) do |exposure, output| + exposure.with_attr_path(entity, new_options) do + result = exposure.value(entity, new_options) + output[exposure.key] = result + end + end + end + + def valid_value_for(key, entity, options) + new_options = nesting_options_for(options) + + result = nil + normalized_exposures(entity, new_options).select { |e| e.key == key }.each do |exposure| + exposure.with_attr_path(entity, new_options) do + result = exposure.valid_value(entity, new_options) + end + end + result + end + + def serializable_value(entity, options) + new_options = nesting_options_for(options) + + normalized_exposures(entity, new_options).each_with_object({}) do |exposure, output| + exposure.with_attr_path(entity, new_options) do + result = exposure.serializable_value(entity, new_options) + output[exposure.key] = result + end + end + end + + # if we have any nesting exposures with the same name. + delegate :deep_complex_nesting?, to: :nested_exposures + + private + + def nesting_options_for(options) + if @key + options.for_nesting(@key) + else + options + end + end + + def easy_normalized_exposures(entity, options) + nested_exposures.select do |exposure| + exposure.with_attr_path(entity, options) do + exposure.should_expose?(entity, options) + end + end + end + + # This method 'merges' subsequent nesting exposures with the same name if it's needed + def normalized_exposures(entity, options) + return easy_normalized_exposures(entity, options) unless deep_complex_nesting? # optimization + + table = nested_exposures.each_with_object({}) do |exposure, output| + should_expose = exposure.with_attr_path(entity, options) do + exposure.should_expose?(entity, options) + end + next unless should_expose + output[exposure.key] ||= [] + output[exposure.key] << exposure + end + table.map do |key, exposures| + last_exposure = exposures.last + + if last_exposure.nesting? + # For the given key if the last candidates for exposing are nesting then combine them. + nesting_tail = [] + exposures.reverse_each do |exposure| + if exposure.nesting? + nesting_tail.unshift exposure + else + break + end + end + new_nested_exposures = nesting_tail.flat_map(&:nested_exposures) + NestingExposure.new(key, {}, [], new_nested_exposures).tap do |new_exposure| + new_exposure.instance_variable_set(:@deep_complex_nesting, true) if nesting_tail.any?(&:deep_complex_nesting?) + end + else + last_exposure + end + end + end + end + end + end +end + +require 'grape_entity/exposure/nesting_exposure/nested_exposures' diff --git a/lib/grape_entity/exposure/nesting_exposure/nested_exposures.rb b/lib/grape_entity/exposure/nesting_exposure/nested_exposures.rb new file mode 100644 index 00000000..7c2f5b53 --- /dev/null +++ b/lib/grape_entity/exposure/nesting_exposure/nested_exposures.rb @@ -0,0 +1,60 @@ +module Grape + class Entity + module Exposure + class NestingExposure + class NestedExposures + include Enumerable + + def initialize(exposures) + @exposures = exposures + end + + def find_by(attribute) + @exposures.find { |e| e.attribute == attribute } + end + + def <<(exposure) + reset_memoization! + @exposures << exposure + end + + def delete_by(*attributes) + reset_memoization! + @exposures.reject! { |e| e.attribute.in? attributes } + end + + def clear + reset_memoization! + @exposures.clear + end + + delegate :each, + :to_ary, :to_a, + :[], + :==, + :size, + :count, + :length, + :empty?, + to: :@exposures + + # Determine if we have any nesting exposures with the same name. + def deep_complex_nesting? + if @deep_complex_nesting.nil? + all_nesting = select(&:nesting?) + @deep_complex_nesting = all_nesting.group_by(&:key).any? { |_key, exposures| exposures.many? } + else + @deep_complex_nesting + end + end + + private + + def reset_memoization! + @deep_complex_nesting = nil + end + end + end + end + end +end diff --git a/lib/grape_entity/exposure/represent_exposure.rb b/lib/grape_entity/exposure/represent_exposure.rb new file mode 100644 index 00000000..bb77f7c1 --- /dev/null +++ b/lib/grape_entity/exposure/represent_exposure.rb @@ -0,0 +1,47 @@ +module Grape + class Entity + module Exposure + class RepresentExposure < Base + attr_reader :using_class_name, :subexposure + + def setup(using_class_name, subexposure) + @using_class_name = using_class_name + @subexposure = subexposure + end + + def dup_args + [*super, using_class_name, subexposure] + end + + def ==(other) + super && + @using_class_name == other.using_class_name && + @subexposure == other.subexposure + end + + def value(entity, options) + new_options = options.for_nesting(key) + using_class.represent(@subexposure.value(entity, options), new_options) + end + + def valid?(entity) + @subexposure.valid? entity + end + + def using_class + @using_class ||= if @using_class_name.respond_to? :constantize + @using_class_name.constantize + else + @using_class_name + end + end + + private + + def using_options_for(options) + options.for_nesting(key) + end + end + end + end +end diff --git a/lib/grape_entity/options.rb b/lib/grape_entity/options.rb new file mode 100644 index 00000000..af91f93b --- /dev/null +++ b/lib/grape_entity/options.rb @@ -0,0 +1,142 @@ +module Grape + class Entity + class Options + attr_reader :opts_hash + + def initialize(opts_hash = {}) + @opts_hash = opts_hash + @has_only = !opts_hash[:only].nil? + @has_except = !opts_hash[:except].nil? + @for_nesting_cache = {} + @should_return_key_cache = {} + end + + def [](key) + @opts_hash[key] + end + + def key?(key) + @opts_hash.key? key + end + + def merge(new_opts) + if new_opts.empty? + self + else + merged = if new_opts.instance_of? Options + @opts_hash.merge(new_opts.opts_hash) + else + @opts_hash.merge(new_opts) + end + Options.new(merged) + end + end + + def reverse_merge(new_opts) + if new_opts.empty? + self + else + merged = if new_opts.instance_of? Options + new_opts.opts_hash.merge(@opts_hash) + else + new_opts.merge(@opts_hash) + end + Options.new(merged) + end + end + + def empty? + @opts_hash.empty? + end + + def ==(other) + if other.is_a? Options + @opts_hash == other.opts_hash + else + @opts_hash == other + end + end + + def should_return_key?(key) + return true unless @has_only || @has_except + + only = only_fields.nil? || + only_fields.key?(key) + except = except_fields && except_fields.key?(key) && + except_fields[key] == true + only && !except + end + + def for_nesting(key) + @for_nesting_cache[key] ||= build_for_nesting(key) + end + + def only_fields(for_key = nil) + return nil unless @has_only + + @only_fields ||= @opts_hash[:only].each_with_object({}) do |attribute, allowed_fields| + if attribute.is_a?(Hash) + attribute.each do |attr, nested_attrs| + allowed_fields[attr] ||= [] + allowed_fields[attr] += nested_attrs + end + else + allowed_fields[attribute] = true + end + end.symbolize_keys + + if for_key && @only_fields[for_key].is_a?(Array) + @only_fields[for_key] + elsif for_key.nil? + @only_fields + end + end + + def except_fields(for_key = nil) + return nil unless @has_except + + @except_fields ||= @opts_hash[:except].each_with_object({}) do |attribute, allowed_fields| + if attribute.is_a?(Hash) + attribute.each do |attr, nested_attrs| + allowed_fields[attr] ||= [] + allowed_fields[attr] += nested_attrs + end + else + allowed_fields[attribute] = true + end + end.symbolize_keys + + if for_key && @except_fields[for_key].is_a?(Array) + @except_fields[for_key] + elsif for_key.nil? + @except_fields + end + end + + def with_attr_path(part) + stack = (opts_hash[:attr_path] ||= []) + if part + stack.push part + result = yield + stack.pop + result + else + yield + end + end + + private + + def build_for_nesting(key) + new_opts_hash = opts_hash.dup + new_opts_hash.delete(:collection) + new_opts_hash[:root] = nil + new_opts_hash[:only] = only_fields(key) + new_opts_hash[:except] = except_fields(key) + new_opts_hash[:attr_path] = opts_hash[:attr_path] + + Options.new(new_opts_hash) + end + end + end +end diff --git a/spec/grape_entity/entity_spec.rb b/spec/grape_entity/entity_spec.rb index 825b2e94..62a95ef5 100644 --- a/spec/grape_entity/entity_spec.rb +++ b/spec/grape_entity/entity_spec.rb @@ -11,12 +11,12 @@ context 'multiple attributes' do it 'is able to add multiple exposed attributes with a single call' do subject.expose :name, :email, :location - expect(subject.exposures.size).to eq 3 + expect(subject.root_exposures.size).to eq 3 end - it 'sets the same options for all exposures passed' do + it 'sets the same options for all.root_exposures passed' do subject.expose :name, :email, :location, documentation: true - subject.exposures.values.each { |v| expect(v).to eq(documentation: true) } + subject.root_exposures.each { |v| expect(v.documentation).to eq true } end end @@ -61,10 +61,10 @@ class BogusEntity < Grape::Entity end object = EntitySpec::SomeObject1.new - value = subject.represent(object).send(:value_for, :bogus) + value = subject.represent(object).value_for(:bogus) expect(value).to be_instance_of EntitySpec::BogusEntity - prop1 = value.send(:value_for, :prop1) + prop1 = value.value_for(:prop1) expect(prop1).to eq 'MODIFIED 2' end @@ -72,12 +72,14 @@ class BogusEntity < Grape::Entity it 'sets the :proc option in the exposure options' do block = ->(_) { true } subject.expose :name, using: 'Awesome', &block - expect(subject.exposures[:name]).to eq(proc: block, using: 'Awesome') + exposure = subject.find_exposure(:name) + expect(exposure.subexposure.block).to eq(block) + expect(exposure.using_class_name).to eq('Awesome') end it 'references an instance of the entity without any options' do subject.expose(:size) { |_| self } - expect(subject.represent({}).send(:value_for, :size)).to be_an_instance_of fresh_class + expect(subject.represent({}).value_for(:size)).to be_an_instance_of fresh_class end end @@ -90,33 +92,38 @@ class BogusEntity < Grape::Entity subject.expose :another_nested, using: 'Awesome' end - expect(subject.exposures).to eq( - awesome: {}, - awesome__nested: { nested: true }, - awesome__nested__moar_nested: { as: 'weee', nested: true }, - awesome__another_nested: { using: 'Awesome', nested: true } - ) + awesome = subject.find_exposure(:awesome) + nested = awesome.find_nested_exposure(:nested) + another_nested = awesome.find_nested_exposure(:another_nested) + moar_nested = nested.find_nested_exposure(:moar_nested) + + expect(awesome).to be_nesting + expect(nested).to_not be_nil + expect(another_nested).to_not be_nil + expect(another_nested.using_class_name).to eq('Awesome') + expect(moar_nested).to_not be_nil + expect(moar_nested.key).to eq(:weee) end - it 'represents the exposure as a hash of its nested exposures' do + it 'represents the exposure as a hash of its nested.root_exposures' do subject.expose :awesome do subject.expose(:nested) { |_| 'value' } subject.expose(:another_nested) { |_| 'value' } end - expect(subject.represent({}).send(:value_for, :awesome)).to eq( + expect(subject.represent({}).value_for(:awesome)).to eq( nested: 'value', another_nested: 'value' ) end - it 'does not represent nested exposures whose conditions are not met' do + it 'does not represent nested.root_exposures whose conditions are not met' do subject.expose :awesome do subject.expose(:condition_met, if: ->(_, _) { true }) { |_| 'value' } subject.expose(:condition_not_met, if: ->(_, _) { false }) { |_| 'value' } end - expect(subject.represent({}).send(:value_for, :awesome)).to eq(condition_met: 'value') + expect(subject.represent({}).value_for(:awesome)).to eq(condition_met: 'value') end it 'does not represent attributes, declared inside nested exposure, outside of it' do @@ -139,7 +146,7 @@ class BogusEntity < Grape::Entity ) end - it 'complex nested attributes' do + it 'merges complex nested attributes' do class ClassRoom < Grape::Entity expose(:parents, using: 'Parent') { |_| [{}, {}] } end @@ -181,7 +188,44 @@ class Parent < Person ) end - it 'is safe if its nested exposures are safe' do + it 'merges results of deeply nested double.root_exposures inside of nesting exposure' do + entity = Class.new(Grape::Entity) do + expose :data do + expose :something do + expose(:x) { |_| 'x' } + end + expose :something do + expose(:y) { |_| 'y' } + end + end + end + expect(entity.represent({}).serializable_hash).to eq( + data: { + something: { + x: 'x', + y: 'y' + } + } + ) + end + + it 'serializes deeply nested presenter exposures' do + e = Class.new(Grape::Entity) do + expose :f + end + subject.expose :a do + subject.expose :b do + subject.expose :c do + subject.expose :lol, using: e + end + end + end + expect(subject.represent(lol: { f: 123 }).serializable_hash).to eq( + a: { b: { c: { lol: { f: 123 } } } } + ) + end + + it 'is safe if its nested.root_exposures are safe' do subject.with_options safe: true do subject.expose :awesome do subject.expose(:nested) { |_| 'value' } @@ -202,31 +246,31 @@ class Parent < Person end end - context 'inherited exposures' do - it 'returns exposures from an ancestor' do + context 'inherited.root_exposures' do + it 'returns.root_exposures from an ancestor' do subject.expose :name, :email child_class = Class.new(subject) - expect(child_class.exposures).to eq(subject.exposures) + expect(child_class.root_exposures).to eq(subject.root_exposures) end - it 'returns exposures from multiple ancestor' do + it 'returns.root_exposures from multiple ancestor' do subject.expose :name, :email parent_class = Class.new(subject) child_class = Class.new(parent_class) - expect(child_class.exposures).to eq(subject.exposures) + expect(child_class.root_exposures).to eq(subject.root_exposures) end - it 'returns descendant exposures as a priority' do + it 'returns descendant.root_exposures as a priority' do subject.expose :name, :email child_class = Class.new(subject) child_class.expose :name do |_| 'foo' end - expect(subject.exposures[:name]).not_to have_key :proc - expect(child_class.exposures[:name]).to have_key :proc + expect(subject.represent({ name: 'bar' }, serializable: true)).to eq(email: nil, name: 'bar') + expect(child_class.represent({ name: 'bar' }, serializable: true)).to eq(email: nil, name: 'foo') end end @@ -265,7 +309,7 @@ class Parent < Person object = {} subject.expose(:size, format_with: ->(_value) { self.object.class.to_s }) - expect(subject.represent(object).send(:value_for, :size)).to eq object.class.to_s + expect(subject.represent(object).value_for(:size)).to eq object.class.to_s end it 'formats an exposure with a :format_with symbol that returns a value from the entity instance' do @@ -276,7 +320,7 @@ class Parent < Person object = {} subject.expose(:size, format_with: :size_formatter) - expect(subject.represent(object).send(:value_for, :size)).to eq object.class.to_s + expect(subject.represent(object).value_for(:size)).to eq object.class.to_s end end end @@ -286,17 +330,20 @@ class Parent < Person subject.expose :name, :email subject.unexpose :email - expect(subject.exposures).to eq(name: {}) + expect(subject.root_exposures.length).to eq 1 + expect(subject.root_exposures[0].attribute).to eq :name end - context 'inherited exposures' do + context 'inherited.root_exposures' do it 'when called from child class, only removes from the attribute from child' do subject.expose :name, :email child_class = Class.new(subject) child_class.unexpose :email - expect(child_class.exposures).to eq(name: {}) - expect(subject.exposures).to eq(name: {}, email: {}) + expect(child_class.root_exposures.length).to eq 1 + expect(child_class.root_exposures[0].attribute).to eq :name + expect(subject.root_exposures[0].attribute).to eq :name + expect(subject.root_exposures[1].attribute).to eq :email end context 'when called from the parent class' do @@ -305,11 +352,24 @@ class Parent < Person child_class = Class.new(subject) subject.unexpose :email - expect(subject.exposures).to eq(name: {}) - expect(child_class.exposures).to eq(name: {}, email: {}) + expect(subject.root_exposures.length).to eq 1 + expect(subject.root_exposures[0].attribute).to eq :name + expect(child_class.root_exposures[0].attribute).to eq :name + expect(child_class.root_exposures[1].attribute).to eq :email end end end + + it 'does not allow unexposing inside of nesting exposures' do + expect do + Class.new(Grape::Entity) do + expose :something do + expose :x + unexpose :x + end + end + end.to raise_error(/You cannot call 'unexpose`/) + end end describe '.with_options' do @@ -323,14 +383,16 @@ class Parent < Person expect { subject.class_eval(&block) }.to raise_error ArgumentError end - it 'applies the options to all exposures inside' do + it 'applies the options to all.root_exposures inside' do subject.class_eval do with_options(if: { awesome: true }) do expose :awesome_thing, using: 'Awesome' end end - expect(subject.exposures[:awesome_thing]).to eq(if: { awesome: true }, using: 'Awesome') + exposure = subject.find_exposure(:awesome_thing) + expect(exposure.using_class_name).to eq('Awesome') + expect(exposure.conditions[0].cond_hash).to eq(awesome: true) end it 'allows for nested .with_options' do @@ -342,7 +404,9 @@ class Parent < Person end end - expect(subject.exposures[:awesome_thing]).to eq(if: { awesome: true }, using: 'Something') + exposure = subject.find_exposure(:awesome_thing) + expect(exposure.using_class_name).to eq('Something') + expect(exposure.conditions[0].cond_hash).to eq(awesome: true) end it 'overrides nested :as option' do @@ -352,7 +416,8 @@ class Parent < Person end end - expect(subject.exposures[:awesome_thing]).to eq(as: :extra_smooth) + exposure = subject.find_exposure(:awesome_thing) + expect(exposure.key).to eq :extra_smooth end it 'merges nested :if option' do @@ -374,10 +439,11 @@ class Parent < Person end end - expect(subject.exposures[:awesome_thing]).to eq( - if: { awesome: false, less_awesome: true }, - if_extras: [:awesome, match_proc] - ) + exposure = subject.find_exposure(:awesome_thing) + expect(exposure.conditions.any?(&:inversed?)).to be_falsey + expect(exposure.conditions[0].symbol).to eq(:awesome) + expect(exposure.conditions[1].block).to eq(match_proc) + expect(exposure.conditions[2].cond_hash).to eq(awesome: false, less_awesome: true) end it 'merges nested :unless option' do @@ -399,10 +465,11 @@ class Parent < Person end end - expect(subject.exposures[:awesome_thing]).to eq( - unless: { awesome: false, less_awesome: true }, - unless_extras: [:awesome, match_proc] - ) + exposure = subject.find_exposure(:awesome_thing) + expect(exposure.conditions.all?(&:inversed?)).to be_truthy + expect(exposure.conditions[0].symbol).to eq(:awesome) + expect(exposure.conditions[1].block).to eq(match_proc) + expect(exposure.conditions[2].cond_hash).to eq(awesome: false, less_awesome: true) end it 'overrides nested :using option' do @@ -412,7 +479,8 @@ class Parent < Person end end - expect(subject.exposures[:awesome_thing]).to eq(using: 'SomethingElse') + exposure = subject.find_exposure(:awesome_thing) + expect(exposure.using_class_name).to eq('SomethingElse') end it 'aliases :with option to :using option' do @@ -421,7 +489,9 @@ class Parent < Person expose :awesome_thing, with: 'SomethingElse' end end - expect(subject.exposures[:awesome_thing]).to eq(using: 'SomethingElse') + + exposure = subject.find_exposure(:awesome_thing) + expect(exposure.using_class_name).to eq('SomethingElse') end it 'overrides nested :proc option' do @@ -433,7 +503,8 @@ class Parent < Person end end - expect(subject.exposures[:awesome_thing]).to eq(proc: match_proc) + exposure = subject.find_exposure(:awesome_thing) + expect(exposure.block).to eq(match_proc) end it 'overrides nested :documentation option' do @@ -443,7 +514,8 @@ class Parent < Person end end - expect(subject.exposures[:awesome_thing]).to eq(documentation: { desc: 'Other description.' }) + exposure = subject.find_exposure(:awesome_thing) + expect(exposure.documentation).to eq(desc: 'Other description.') end end @@ -560,6 +632,22 @@ class Parent < Person representation = subject.represent(object, except: [:id, 'address', { user: [:id, 'name'] }], serializable: true) expect(representation).to eq(name: nil, phone: nil, user: { email: nil }) end + + context 'with nested attributes' do + before do + subject.expose :additional do + subject.expose :something + end + end + + it 'preserves nesting' do + expect(subject.represent({ something: 123 }, only: [{ additional: [:something] }], serializable: true)).to eq( + additional: { + something: 123 + } + ) + end + end end it 'can specify children attributes with only' do @@ -617,6 +705,78 @@ class Parent < Person representation = subject.represent(OpenStruct.new, condition: true, except: [:phone, :mobile_phone], serializable: true) expect(representation).to eq(id: nil, name: nil) end + + it 'choses proper exposure according to condition' do + strategy1 = ->(_obj, _opts) { 'foo' } + strategy2 = ->(_obj, _opts) { 'bar' } + + subject.expose :id, proc: strategy1 + subject.expose :id, proc: strategy2 + expect(subject.represent({}, condition: false, serializable: true)).to eq(id: 'bar') + expect(subject.represent({}, condition: true, serializable: true)).to eq(id: 'bar') + + subject.unexpose_all + + subject.expose :id, proc: strategy1, if: :condition + subject.expose :id, proc: strategy2 + expect(subject.represent({}, condition: false, serializable: true)).to eq(id: 'bar') + expect(subject.represent({}, condition: true, serializable: true)).to eq(id: 'bar') + + subject.unexpose_all + + subject.expose :id, proc: strategy1 + subject.expose :id, proc: strategy2, if: :condition + expect(subject.represent({}, condition: false, serializable: true)).to eq(id: 'foo') + expect(subject.represent({}, condition: true, serializable: true)).to eq(id: 'bar') + + subject.unexpose_all + + subject.expose :id, proc: strategy1, if: :condition1 + subject.expose :id, proc: strategy2, if: :condition2 + expect(subject.represent({}, condition1: false, condition2: false, serializable: true)).to eq({}) + expect(subject.represent({}, condition1: false, condition2: true, serializable: true)).to eq(id: 'bar') + expect(subject.represent({}, condition1: true, condition2: false, serializable: true)).to eq(id: 'foo') + expect(subject.represent({}, condition1: true, condition2: true, serializable: true)).to eq(id: 'bar') + end + + it 'does not merge nested exposures with plain hashes' do + subject.expose(:id) + subject.expose(:info, if: :condition1) do + subject.expose :a, :b + subject.expose(:additional, if: :condition2) do |_obj, _opts| + { + x: 11, y: 22, c: 123 + } + end + end + subject.expose(:info, if: :condition2) do + subject.expose(:additional) do + subject.expose :c + end + end + subject.expose(:d, as: :info, if: :condition3) + + obj = { id: 123, a: 1, b: 2, c: 3, d: 4 } + + expect(subject.represent(obj, serializable: true)).to eq(id: 123) + expect(subject.represent(obj, condition1: true, serializable: true)).to eq(id: 123, info: { a: 1, b: 2 }) + expect(subject.represent(obj, condition2: true, serializable: true)).to eq( + id: 123, + info: { + additional: { + c: 3 + } + } + ) + expect(subject.represent(obj, condition1: true, condition2: true, serializable: true)).to eq( + id: 123, + info: { + a: 1, b: 2, additional: { c: 3 } + } + ) + expect(subject.represent(obj, condition3: true, serializable: true)).to eq(id: 123, info: 4) + expect(subject.represent(obj, condition1: true, condition2: true, condition3: true, serializable: true)).to eq(id: 123, info: 4) + end end context 'attribute with alias' do @@ -1059,13 +1219,8 @@ class CharacterEntity < Grape::Entity end fresh_class.class_eval do - expose :characteristics, using: EntitySpec::CharacterEntity - - protected - - def self.path_for(attribute) - attribute == :characteristics ? :character : super - end + expose :characteristics, using: EntitySpec::CharacterEntity, + attr_path: ->(_obj, _opts) { :character } end expect(subject.serializable_hash).to eq( @@ -1084,13 +1239,7 @@ class NoPathCharacterEntity < Grape::Entity end fresh_class.class_eval do - expose :characteristics, using: EntitySpec::NoPathCharacterEntity - - protected - - def self.path_for(_attribute) - nil - end + expose :characteristics, using: EntitySpec::NoPathCharacterEntity, attr_path: proc { nil } end expect(subject.serializable_hash).to eq( @@ -1100,6 +1249,15 @@ def self.path_for(_attribute) ) end end + + context 'with projections passed in options' do + it 'allows to pass different :only and :except params using the same instance' do + fresh_class.expose :a, :b, :c + presenter = fresh_class.new(a: 1, b: 2, c: 3) + expect(presenter.serializable_hash(only: [:a, :b])).to eq(a: 1, b: 2) + expect(presenter.serializable_hash(only: [:b, :c])).to eq(b: 2, c: 3) + end + end end describe '#value_for' do @@ -1122,17 +1280,19 @@ def timestamp(date) end it 'passes through bare expose attributes' do - expect(subject.send(:value_for, :name)).to eq attributes[:name] + expect(subject.value_for(:name)).to eq attributes[:name] end it 'instantiates a representation if that is called for' do - rep = subject.send(:value_for, :friends) + rep = subject.value_for(:friends) expect(rep.reject { |r| r.is_a?(fresh_class) }).to be_empty expect(rep.first.serializable_hash[:name]).to eq 'Friend 1' expect(rep.last.serializable_hash[:name]).to eq 'Friend 2' end context 'child representations' do + after { EntitySpec::FriendEntity.unexpose_all } + it 'disables root key name for child representations' do module EntitySpec class FriendEntity < Grape::Entity @@ -1145,7 +1305,7 @@ class FriendEntity < Grape::Entity expose :friends, using: EntitySpec::FriendEntity end - rep = subject.send(:value_for, :friends) + rep = subject.value_for(:friends) expect(rep).to be_kind_of Array expect(rep.reject { |r| r.is_a?(EntitySpec::FriendEntity) }).to be_empty expect(rep.first.serializable_hash[:name]).to eq 'Friend 1' @@ -1166,7 +1326,7 @@ class FriendEntity < Grape::Entity end end - rep = subject.send(:value_for, :custom_friends) + rep = subject.value_for(:custom_friends) expect(rep).to be_kind_of Array expect(rep.reject { |r| r.is_a?(EntitySpec::FriendEntity) }).to be_empty expect(rep.first.serializable_hash).to eq(name: 'Friend 1', email: 'friend1@example.com') @@ -1187,7 +1347,7 @@ class FriendEntity < Grape::Entity end end - rep = subject.send(:value_for, :first_friend) + rep = subject.value_for(:first_friend) expect(rep).to be_kind_of EntitySpec::FriendEntity expect(rep.serializable_hash).to eq(name: 'Friend 1', email: 'friend1@example.com') end @@ -1205,7 +1365,7 @@ class FriendEntity < Grape::Entity end end - rep = subject.send(:value_for, :first_friend) + rep = subject.value_for(:first_friend) expect(rep).to be_kind_of EntitySpec::FriendEntity expect(rep.serializable_hash).to be_nil end @@ -1222,7 +1382,7 @@ class CharacteristicsEntity < Grape::Entity expose :characteristics, using: EntitySpec::CharacteristicsEntity end - rep = subject.send(:value_for, :characteristics) + rep = subject.value_for(:characteristics) expect(rep).to be_kind_of Array expect(rep.reject { |r| r.is_a?(EntitySpec::CharacteristicsEntity) }).to be_empty expect(rep.first.serializable_hash[:key]).to eq 'hair_color' @@ -1242,13 +1402,13 @@ class FriendEntity < Grape::Entity expose :friends, using: EntitySpec::FriendEntity end - rep = subject.send(:value_for, :friends) + rep = subject.value_for(:friends) expect(rep).to be_kind_of Array expect(rep.reject { |r| r.is_a?(EntitySpec::FriendEntity) }).to be_empty expect(rep.first.serializable_hash[:email]).to be_nil expect(rep.last.serializable_hash[:email]).to be_nil - rep = subject.send(:value_for, :friends, user_type: :admin) + rep = subject.value_for(:friends, Grape::Entity::Options.new(user_type: :admin)) expect(rep).to be_kind_of Array expect(rep.reject { |r| r.is_a?(EntitySpec::FriendEntity) }).to be_empty expect(rep.first.serializable_hash[:email]).to eq 'friend1@example.com' @@ -1268,7 +1428,7 @@ class FriendEntity < Grape::Entity expose :friends, using: EntitySpec::FriendEntity end - rep = subject.send(:value_for, :friends, collection: false) + rep = subject.value_for(:friends, Grape::Entity::Options.new(collection: false)) expect(rep).to be_kind_of Array expect(rep.reject { |r| r.is_a?(EntitySpec::FriendEntity) }).to be_empty expect(rep.first.serializable_hash[:email]).to eq 'friend1@example.com' @@ -1277,15 +1437,15 @@ class FriendEntity < Grape::Entity end it 'calls through to the proc if there is one' do - expect(subject.send(:value_for, :computed, awesome: 123)).to eq 123 + expect(subject.value_for(:computed, Grape::Entity::Options.new(awesome: 123))).to eq 123 end it 'returns a formatted value if format_with is passed' do - expect(subject.send(:value_for, :birthday)).to eq '02/27/2012' + expect(subject.value_for(:birthday)).to eq '02/27/2012' end it 'returns a formatted value if format_with is passed a lambda' do - expect(subject.send(:value_for, :fantasies)).to eq ['Nessy', 'Double Rainbows', 'Unicorns'] + expect(subject.value_for(:fantasies)).to eq ['Nessy', 'Double Rainbows', 'Unicorns'] end it 'tries instance methods on the entity first' do @@ -1305,8 +1465,8 @@ def name friend = double('Friend', name: 'joe', email: 'joe@example.com') rep = EntitySpec::DelegatingEntity.new(friend) - expect(rep.send(:value_for, :name)).to eq 'cooler name' - expect(rep.send(:value_for, :email)).to eq 'joe@example.com' + expect(rep.value_for(:name)).to eq 'cooler name' + expect(rep.value_for(:email)).to eq 'joe@example.com' end context 'using' do @@ -1322,7 +1482,7 @@ class UserEntity < Grape::Entity expose :friends, using: 'EntitySpec::UserEntity' end - rep = subject.send(:value_for, :friends) + rep = subject.value_for(:friends) expect(rep).to be_kind_of Array expect(rep.size).to eq 2 expect(rep.all? { |r| r.is_a?(EntitySpec::UserEntity) }).to be true @@ -1333,7 +1493,7 @@ class UserEntity < Grape::Entity expose :friends, using: EntitySpec::UserEntity end - rep = subject.send(:value_for, :friends) + rep = subject.value_for(:friends) expect(rep).to be_kind_of Array expect(rep.size).to eq 2 expect(rep.all? { |r| r.is_a?(EntitySpec::UserEntity) }).to be true @@ -1341,7 +1501,7 @@ class UserEntity < Grape::Entity end end - describe '#documentation' do + describe '.documentation' do it 'returns an empty hash is no documentation is provided' do fresh_class.expose :name @@ -1366,6 +1526,18 @@ class UserEntity < Grape::Entity expect(subject.documentation).to eq(label: doc, email: doc) end + it 'resets memoization when exposing additional attributes' do + fresh_class.expose :x, documentation: { desc: 'just x' } + expect(fresh_class.instance_variable_get(:@documentation)).to be_nil + doc1 = fresh_class.documentation + expect(fresh_class.instance_variable_get(:@documentation)).not_to be_nil + fresh_class.expose :y, documentation: { desc: 'just y' } + expect(fresh_class.instance_variable_get(:@documentation)).to be_nil + doc2 = fresh_class.documentation + expect(doc1).to eq(x: { desc: 'just x' }) + expect(doc2).to eq(x: { desc: 'just x' }, y: { desc: 'just y' }) + end + context 'inherited documentation' do it 'returns documentation from ancestor' do doc = { type: 'foo', desc: 'bar' } @@ -1404,78 +1576,14 @@ class UserEntity < Grape::Entity expect(child_class.documentation).to eq(name: doc) expect(nephew_class.documentation).to eq(name: doc, email: new_doc) end - end - end - - describe '#key_for' do - it 'returns the attribute if no :as is set' do - fresh_class.expose :name - expect(subject.class.send(:key_for, :name)).to eq :name - end - it 'returns a symbolized version of the attribute' do - fresh_class.expose :name - expect(subject.class.send(:key_for, 'name')).to eq :name - end - - it 'returns the :as alias if one exists' do - fresh_class.expose :name, as: :nombre - expect(subject.class.send(:key_for, 'name')).to eq :nombre - end - end - - describe '#conditions_met?' do - it 'only passes through hash :if exposure if all attributes match' do - exposure_options = { if: { condition1: true, condition2: true } } - - expect(subject.send(:conditions_met?, exposure_options, {})).to be false - expect(subject.send(:conditions_met?, exposure_options, condition1: true)).to be false - expect(subject.send(:conditions_met?, exposure_options, condition1: true, condition2: true)).to be true - expect(subject.send(:conditions_met?, exposure_options, condition1: false, condition2: true)).to be false - expect(subject.send(:conditions_met?, exposure_options, condition1: true, condition2: true, other: true)).to be true - end - - it 'looks for presence/truthiness if a symbol is passed' do - exposure_options = { if: :condition1 } - - expect(subject.send(:conditions_met?, exposure_options, {})).to be false - expect(subject.send(:conditions_met?, exposure_options, condition1: true)).to be true - expect(subject.send(:conditions_met?, exposure_options, condition1: false)).to be false - expect(subject.send(:conditions_met?, exposure_options, condition1: nil)).to be false - end - - it 'looks for absence/falsiness if a symbol is passed' do - exposure_options = { unless: :condition1 } - - expect(subject.send(:conditions_met?, exposure_options, {})).to be true - expect(subject.send(:conditions_met?, exposure_options, condition1: true)).to be false - expect(subject.send(:conditions_met?, exposure_options, condition1: false)).to be true - expect(subject.send(:conditions_met?, exposure_options, condition1: nil)).to be true - end - - it 'only passes through proc :if exposure if it returns truthy value' do - exposure_options = { if: ->(_, opts) { opts[:true] } } - - expect(subject.send(:conditions_met?, exposure_options, true: false)).to be false - expect(subject.send(:conditions_met?, exposure_options, true: true)).to be true - end - - it 'only passes through hash :unless exposure if any attributes do not match' do - exposure_options = { unless: { condition1: true, condition2: true } } - - expect(subject.send(:conditions_met?, exposure_options, {})).to be true - expect(subject.send(:conditions_met?, exposure_options, condition1: true)).to be false - expect(subject.send(:conditions_met?, exposure_options, condition1: true, condition2: true)).to be false - expect(subject.send(:conditions_met?, exposure_options, condition1: false, condition2: true)).to be false - expect(subject.send(:conditions_met?, exposure_options, condition1: true, condition2: true, other: true)).to be false - expect(subject.send(:conditions_met?, exposure_options, condition1: false, condition2: false)).to be true - end - - it 'only passes through proc :unless exposure if it returns falsy value' do - exposure_options = { unless: ->(_, opts) { opts[:true] == true } } - - expect(subject.send(:conditions_met?, exposure_options, true: false)).to be true - expect(subject.send(:conditions_met?, exposure_options, true: true)).to be false + it 'includes only root exposures' do + fresh_class.expose :name, documentation: { desc: 'foo' } + fresh_class.expose :nesting do + fresh_class.expose :smth, documentation: { desc: 'should not be seen' } + end + expect(fresh_class.documentation).to eq(name: { desc: 'foo' }) + end end end @@ -1496,19 +1604,19 @@ class UserEntity < Grape::Entity expose :name end - expect(subject.entity_class.exposures).not_to be_empty + expect(subject.entity_class.root_exposures).not_to be_empty end it 'is able to expose straight from the class' do subject.entity :name, :email - expect(subject.entity_class.exposures.size).to eq 2 + expect(subject.entity_class.root_exposures.size).to eq 2 end - it 'is able to mix field and advanced exposures' do + it 'is able to mix field and advanced.root_exposures' do subject.entity :name, :email do expose :third end - expect(subject.entity_class.exposures.size).to eq 3 + expect(subject.entity_class.root_exposures.size).to eq 3 end context 'instance' do diff --git a/spec/grape_entity/exposure/nesting_exposure/nested_exposures_spec.rb b/spec/grape_entity/exposure/nesting_exposure/nested_exposures_spec.rb new file mode 100644 index 00000000..dd09c02c --- /dev/null +++ b/spec/grape_entity/exposure/nesting_exposure/nested_exposures_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe Grape::Entity::Exposure::NestingExposure::NestedExposures do + subject { described_class.new([]) } + + describe '#deep_complex_nesting?' do + it 'is reset when additional exposure is added' do + subject << Grape::Entity::Exposure.new(:x, {}) + expect(subject.instance_variable_get(:@deep_complex_nesting)).to be_nil + subject.deep_complex_nesting? + expect(subject.instance_variable_get(:@deep_complex_nesting)).to_not be_nil + subject << Grape::Entity::Exposure.new(:y, {}) + expect(subject.instance_variable_get(:@deep_complex_nesting)).to be_nil + end + + it 'is reset when exposure is deleted' do + subject << Grape::Entity::Exposure.new(:x, {}) + expect(subject.instance_variable_get(:@deep_complex_nesting)).to be_nil + subject.deep_complex_nesting? + expect(subject.instance_variable_get(:@deep_complex_nesting)).to_not be_nil + subject.delete_by(:x) + expect(subject.instance_variable_get(:@deep_complex_nesting)).to be_nil + end + + it 'is reset when exposures are cleared' do + subject << Grape::Entity::Exposure.new(:x, {}) + expect(subject.instance_variable_get(:@deep_complex_nesting)).to be_nil + subject.deep_complex_nesting? + expect(subject.instance_variable_get(:@deep_complex_nesting)).to_not be_nil + subject.clear + expect(subject.instance_variable_get(:@deep_complex_nesting)).to be_nil + end + end +end diff --git a/spec/grape_entity/exposure_spec.rb b/spec/grape_entity/exposure_spec.rb new file mode 100644 index 00000000..dc318320 --- /dev/null +++ b/spec/grape_entity/exposure_spec.rb @@ -0,0 +1,90 @@ +require 'spec_helper' + +describe Grape::Entity::Exposure do + let(:fresh_class) { Class.new(Grape::Entity) } + let(:model) { double(attributes) } + let(:attributes) do + { + name: 'Bob Bobson', + email: 'bob@example.com', + birthday: Time.gm(2012, 2, 27), + fantasies: ['Unicorns', 'Double Rainbows', 'Nessy'], + characteristics: [ + { key: 'hair_color', value: 'brown' } + ], + friends: [ + double(name: 'Friend 1', email: 'friend1@example.com', characteristics: [], fantasies: [], birthday: Time.gm(2012, 2, 27), friends: []), + double(name: 'Friend 2', email: 'friend2@example.com', characteristics: [], fantasies: [], birthday: Time.gm(2012, 2, 27), friends: []) + ] + } + end + let(:entity) { fresh_class.new(model) } + subject { fresh_class.find_exposure(:name) } + + describe '#key' do + it 'returns the attribute if no :as is set' do + fresh_class.expose :name + expect(subject.key).to eq :name + end + + it 'returns the :as alias if one exists' do + fresh_class.expose :name, as: :nombre + expect(subject.key).to eq :nombre + end + end + + describe '#conditions_met?' do + it 'only passes through hash :if exposure if all attributes match' do + fresh_class.expose :name, if: { condition1: true, condition2: true } + + expect(subject.conditions_met?(entity, {})).to be false + expect(subject.conditions_met?(entity, condition1: true)).to be false + expect(subject.conditions_met?(entity, condition1: true, condition2: true)).to be true + expect(subject.conditions_met?(entity, condition1: false, condition2: true)).to be false + expect(subject.conditions_met?(entity, condition1: true, condition2: true, other: true)).to be true + end + + it 'looks for presence/truthiness if a symbol is passed' do + fresh_class.expose :name, if: :condition1 + + expect(subject.conditions_met?(entity, {})).to be false + expect(subject.conditions_met?(entity, condition1: true)).to be true + expect(subject.conditions_met?(entity, condition1: false)).to be false + expect(subject.conditions_met?(entity, condition1: nil)).to be false + end + + it 'looks for absence/falsiness if a symbol is passed' do + fresh_class.expose :name, unless: :condition1 + + expect(subject.conditions_met?(entity, {})).to be true + expect(subject.conditions_met?(entity, condition1: true)).to be false + expect(subject.conditions_met?(entity, condition1: false)).to be true + expect(subject.conditions_met?(entity, condition1: nil)).to be true + end + + it 'only passes through proc :if exposure if it returns truthy value' do + fresh_class.expose :name, if: ->(_, opts) { opts[:true] } + + expect(subject.conditions_met?(entity, true: false)).to be false + expect(subject.conditions_met?(entity, true: true)).to be true + end + + it 'only passes through hash :unless exposure if any attributes do not match' do + fresh_class.expose :name, unless: { condition1: true, condition2: true } + + expect(subject.conditions_met?(entity, {})).to be true + expect(subject.conditions_met?(entity, condition1: true)).to be true + expect(subject.conditions_met?(entity, condition1: true, condition2: true)).to be false + expect(subject.conditions_met?(entity, condition1: false, condition2: true)).to be true + expect(subject.conditions_met?(entity, condition1: true, condition2: true, other: true)).to be false + expect(subject.conditions_met?(entity, condition1: false, condition2: false)).to be true + end + + it 'only passes through proc :unless exposure if it returns falsy value' do + fresh_class.expose :name, unless: ->(_, opts) { opts[:true] == true } + + expect(subject.conditions_met?(entity, true: false)).to be true + expect(subject.conditions_met?(entity, true: true)).to be false + end + end +end