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..df6caa41 --- /dev/null +++ b/lib/grape_entity/condition/base.rb @@ -0,0 +1,31 @@ +module Grape + class Entity + module Condition + class Base + def self.new(inverse, *args, &block) + super(inverse).tap { |e| e.setup_options(*args, &block) } + end + + def initialize(inverse = false) + @inverse = inverse + 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..40763497 --- /dev/null +++ b/lib/grape_entity/condition/block_condition.rb @@ -0,0 +1,17 @@ +module Grape + class Entity + module Condition + class BlockCondition < Base + attr_reader :block + + def setup_options(&block) + @block = 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..c10967a9 --- /dev/null +++ b/lib/grape_entity/condition/hash_condition.rb @@ -0,0 +1,21 @@ +module Grape + class Entity + module Condition + class HashCondition < Base + attr_reader :cond_hash + + def setup_options(cond_hash) + @cond_hash = 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..28b806d5 --- /dev/null +++ b/lib/grape_entity/condition/symbol_condition.rb @@ -0,0 +1,17 @@ +module Grape + class Entity + module Condition + class SymbolCondition < Base + attr_reader :symbol + + def setup_options(symbol) + @symbol = 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 7e67fd34..ea9c7aa8 100644 --- a/lib/grape_entity/entity.rb +++ b/lib/grape_entity/entity.rb @@ -108,15 +108,11 @@ class << self # 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.exposures = exposures.try(:dup) || [] + subclass.root_exposures = root_exposures.try(:dup) || [] subclass.formatters = formatters.try(:dup) || {} end @@ -155,35 +151,45 @@ 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 ||= [] + @nesting_stack ||= [] # rubocop:disable Style/Next args.each do |attribute| - if @nested_attributes.empty? - root_exposures[attribute] = options + unexpose(attribute) if options[:rewrite] + 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 + exposures << exposure # 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.find_exposure(attribute) + exposures.find { |e| e.attribute == attribute } + end + def self.unexpose(attribute) - exposures.delete(attribute) + root_exposures.reject! { |e| e.attribute == attribute } + exposures.reject! { |e| e.attribute == attribute } end # Set options that will be applied to any exposures declared inside the block. @@ -205,9 +211,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 ||= exposures.each_with_object({}) do |exposure, memo| + if exposure.documentation.present? + memo[exposure.key] = exposure.documentation end end end @@ -363,7 +369,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,7 +402,7 @@ def presented def initialize(object, options = {}) @object = object @delegator = Delegator.new object - @options = options + @options = Options.new(options) end def exposures @@ -427,12 +433,11 @@ def serializable_hash(runtime_options = {}) opts = options.merge(runtime_options || {}) - root_exposures.each_with_object({}) do |(attribute, exposure_options), output| - next unless should_return_attribute?(attribute, opts) && conditions_met?(exposure_options, opts) + root_exposures.each_with_object({}) do |exposure, output| + next unless exposure.should_return_attribute?(opts) && exposure.conditions_met?(self, opts) - partial_output = value_for(attribute, opts) - - output[self.class.key_for(attribute)] = + partial_output = exposure.valid_value(self, opts) + result = if partial_output.respond_to?(:serializable_hash) partial_output.serializable_hash(runtime_options) elsif partial_output.is_a?(Array) && partial_output.all? { |o| o.respond_to?(:serializable_hash) } @@ -444,6 +449,11 @@ def serializable_hash(runtime_options = {}) else partial_output end + if exposure.nesting? + output.deep_merge!(exposure.key => result) + else + output[exposure.key] = result + end end end @@ -498,150 +508,46 @@ def except_fields(options, for_attribute = nil) end end - alias_method :as_json, :serializable_hash - - def to_json(options = {}) - options = options.to_h if options && options.respond_to?(:to_h) - MultiJson.dump(serializable_hash(options)) - end - - def to_xml(options = {}) - options = options.to_h if options && options.respond_to?(:to_h) - 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) + def exec_with_object(options, &block) + instance_exec(object, options, &block) end - def self.nested_exposures_for?(attribute) - nested_exposures.key?(attribute) + def exec_with_attribute(attribute, &block) + instance_exec(delegate_attribute(attribute), &block) 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| - if conditions_met?(nested_exposure_options, options) - [self.class.key_for(nested_attribute), value_for(nested_attribute, options)] - end - end - - Hash[nested_attributes.compact] - 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) + def value_for(attribute, options = Options.new) + value = nil + exposures.select { |e| e.attribute == attribute }.each do |exposure| + next unless exposure.should_return_attribute?(options) && exposure.conditions_met?(self, options) + value = exposure.valid_value(self, options) end + value end def delegate_attribute(attribute) - name = self.class.name_for(attribute) - if respond_to?(name, true) - send(name) + if respond_to?(attribute, true) + send(attribute) else - delegator.delegate(name) + delegator.delegate(attribute) 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 + alias_method :as_json, :serializable_hash - true + def to_json(options = {}) + options = options.to_h if options && options.respond_to?(:to_h) + MultiJson.dump(serializable_hash(options)) 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 + def to_xml(options = {}) + options = options.to_h if options && options.respond_to?(:to_h) + serializable_hash(options).to_xml(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, :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..36d0fb37 --- /dev/null +++ b/lib/grape_entity/exposure/base.rb @@ -0,0 +1,57 @@ +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_options(*args, &block) } + end + + def initialize(attribute, options, conditions) + @attribute = attribute.to_sym + @key = (options[:as] || attribute).to_sym + @is_safe = options[:safe] + @documentation = options[:documentation] + @conditions = conditions + end + + def setup_options + end + + def 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 valid_value(entity, options) + valid?(entity) && value(entity, options) + end + + def should_return_attribute?(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 + 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..e31b15cb --- /dev/null +++ b/lib/grape_entity/exposure/block_exposure.rb @@ -0,0 +1,21 @@ +module Grape + class Entity + module Exposure + class BlockExposure < Base + attr_reader :block + + def value(entity, options) + entity.exec_with_object(options, &@block) + end + + def valid?(_entity) + true + end + + def setup_options(&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..f164afee --- /dev/null +++ b/lib/grape_entity/exposure/formatter_block_exposure.rb @@ -0,0 +1,15 @@ +module Grape + class Entity + module Exposure + class FormatterBlockExposure < Base + def setup_options(&format_with) + @format_with = 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..7818bdfd --- /dev/null +++ b/lib/grape_entity/exposure/formatter_exposure.rb @@ -0,0 +1,20 @@ +module Grape + class Entity + module Exposure + class FormatterExposure < Base + def setup_options(format_with) + @format_with = 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..bad372a3 --- /dev/null +++ b/lib/grape_entity/exposure/nesting_exposure.rb @@ -0,0 +1,34 @@ +module Grape + class Entity + module Exposure + class NestingExposure < Base + attr_reader :nested_exposures + + def initialize(*args) + super + @nested_exposures = [] + end + + def nesting? + true + end + + def find_nested_exposure(attribute) + nested_exposures.find { |e| e.attribute == attribute } + end + + def valid?(entity) + nested_exposures.all? { |e| e.valid?(entity) } + end + + def value(entity, options) + nested_exposures.each_with_object({}) do |nested_exposure, attributes| + if nested_exposure.conditions_met?(entity, options) + attributes[nested_exposure.key] = nested_exposure.value(entity, options) + end + 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..6d93017d --- /dev/null +++ b/lib/grape_entity/exposure/represent_exposure.rb @@ -0,0 +1,40 @@ +module Grape + class Entity + module Exposure + class RepresentExposure < Base + attr_reader :using_class_name, :subexposure + def setup_options(using_class_name, subexposure) + @using_class_name = using_class_name + @subexposure = subexposure + end + + def value(entity, options) + using_options = using_options_for(options) + using_class.represent(@subexposure.value(entity, options), using_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 + + def using_options_for(options) + using_options = options.opts.dup + using_options.delete(:collection) + using_options[:root] = nil + using_options[:only] = options.only_fields(attribute) + using_options[:except] = options.except_fields(attribute) + + using_options + 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..0114c58e --- /dev/null +++ b/lib/grape_entity/options.rb @@ -0,0 +1,85 @@ +module Grape + class Entity + class Options + attr_reader :opts + + def initialize(opts = {}) + @opts = opts + end + + def [](key) + @opts[key] + end + + def key?(key) + @opts.key? key + end + + def merge(opts) + if opts.empty? + self + else + self.class.new(@opts.merge(opts)) + end + end + + def ==(other) + if other.is_a? Options + @opts == other.opts + else + @opts == other + end + end + + def should_return_key?(key) + only = only_fields.nil? || + only_fields.include?(key) + except = except_fields && except_fields.include?(key) && + except_fields[key] == true + only && !except + end + + def only_fields(for_attribute = nil) + return nil unless @opts[:only] + + @only_fields ||= @opts[: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 + end + + def except_fields(for_attribute = nil) + return nil unless @opts[:except] + + @except_fields ||= @opts[: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_attribute && @except_fields[for_attribute].is_a?(Array) + @except_fields[for_attribute] + elsif for_attribute.nil? + @except_fields + end + end + end + end +end diff --git a/spec/grape_entity/entity_spec.rb b/spec/grape_entity/entity_spec.rb index f11cacd0..6f3e3f8a 100644 --- a/spec/grape_entity/entity_spec.rb +++ b/spec/grape_entity/entity_spec.rb @@ -16,7 +16,7 @@ it 'sets the same options for all exposures passed' do subject.expose :name, :email, :location, documentation: true - subject.exposures.values.each { |v| expect(v).to eq(documentation: true) } + subject.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,12 +92,17 @@ 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 @@ -104,7 +111,7 @@ class BogusEntity < Grape::Entity 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' ) @@ -116,7 +123,7 @@ class BogusEntity < Grape::Entity 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 @@ -225,8 +232,8 @@ class Parent < Person '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 +272,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 +283,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,7 +293,8 @@ 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 @@ -295,8 +303,10 @@ class Parent < Person 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,8 +315,10 @@ 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 @@ -330,7 +342,9 @@ class Parent < Person 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 +356,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 +368,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 +391,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 +417,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 +431,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 +441,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 +455,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 +466,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 @@ -1013,11 +1037,11 @@ 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' @@ -1036,7 +1060,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' @@ -1057,7 +1081,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') @@ -1078,7 +1102,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 @@ -1096,7 +1120,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 @@ -1113,7 +1137,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' @@ -1125,7 +1149,7 @@ module EntitySpec class FriendEntity < Grape::Entity root 'friends', 'friend' expose :name - expose :email, if: { user_type: :admin } + expose :email, rewrite: true, if: { user_type: :admin } end end @@ -1133,13 +1157,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' @@ -1159,7 +1183,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' @@ -1168,15 +1192,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 @@ -1196,8 +1220,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 @@ -1213,7 +1237,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 @@ -1224,7 +1248,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 @@ -1298,78 +1322,6 @@ class UserEntity < Grape::Entity 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 - end - end - describe '::DSL' do subject { Class.new } 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