grape-entity 0.4.8 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +5 -5
  2. data/.coveralls.yml +1 -0
  3. data/.github/dependabot.yml +20 -0
  4. data/.github/workflows/ci.yml +41 -0
  5. data/.gitignore +5 -1
  6. data/.rspec +2 -1
  7. data/.rubocop.yml +85 -2
  8. data/.rubocop_todo.yml +41 -33
  9. data/CHANGELOG.md +243 -37
  10. data/CONTRIBUTING.md +1 -1
  11. data/Dangerfile +3 -0
  12. data/Gemfile +11 -7
  13. data/Guardfile +4 -2
  14. data/LICENSE +1 -1
  15. data/README.md +272 -19
  16. data/Rakefile +9 -11
  17. data/UPGRADING.md +35 -0
  18. data/bench/serializing.rb +7 -0
  19. data/grape-entity.gemspec +13 -8
  20. data/lib/grape-entity.rb +2 -0
  21. data/lib/grape_entity/condition/base.rb +37 -0
  22. data/lib/grape_entity/condition/block_condition.rb +23 -0
  23. data/lib/grape_entity/condition/hash_condition.rb +27 -0
  24. data/lib/grape_entity/condition/symbol_condition.rb +23 -0
  25. data/lib/grape_entity/condition.rb +35 -0
  26. data/lib/grape_entity/delegator/base.rb +7 -0
  27. data/lib/grape_entity/delegator/fetchable_object.rb +2 -0
  28. data/lib/grape_entity/delegator/hash_object.rb +4 -2
  29. data/lib/grape_entity/delegator/openstruct_object.rb +2 -0
  30. data/lib/grape_entity/delegator/plain_object.rb +2 -0
  31. data/lib/grape_entity/delegator.rb +14 -9
  32. data/lib/grape_entity/deprecated.rb +13 -0
  33. data/lib/grape_entity/entity.rb +192 -258
  34. data/lib/grape_entity/exposure/base.rb +138 -0
  35. data/lib/grape_entity/exposure/block_exposure.rb +31 -0
  36. data/lib/grape_entity/exposure/delegator_exposure.rb +13 -0
  37. data/lib/grape_entity/exposure/formatter_block_exposure.rb +27 -0
  38. data/lib/grape_entity/exposure/formatter_exposure.rb +32 -0
  39. data/lib/grape_entity/exposure/nesting_exposure/nested_exposures.rb +83 -0
  40. data/lib/grape_entity/exposure/nesting_exposure/output_builder.rb +66 -0
  41. data/lib/grape_entity/exposure/nesting_exposure.rb +133 -0
  42. data/lib/grape_entity/exposure/represent_exposure.rb +50 -0
  43. data/lib/grape_entity/exposure.rb +105 -0
  44. data/lib/grape_entity/options.rb +132 -0
  45. data/lib/grape_entity/version.rb +3 -1
  46. data/lib/grape_entity.rb +9 -2
  47. data/spec/grape_entity/entity_spec.rb +903 -184
  48. data/spec/grape_entity/exposure/nesting_exposure/nested_exposures_spec.rb +58 -0
  49. data/spec/grape_entity/exposure/represent_exposure_spec.rb +32 -0
  50. data/spec/grape_entity/exposure_spec.rb +102 -0
  51. data/spec/grape_entity/hash_spec.rb +91 -0
  52. data/spec/grape_entity/options_spec.rb +66 -0
  53. data/spec/spec_helper.rb +21 -2
  54. metadata +91 -18
  55. data/.travis.yml +0 -19
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext'
5
+
6
+ module Grape
7
+ class Entity
8
+ module Exposure
9
+ class Base
10
+ attr_reader :attribute, :is_safe, :documentation, :override, :conditions, :for_merge
11
+
12
+ def self.new(attribute, options, conditions, *args, &block)
13
+ super(attribute, options, conditions).tap { |e| e.setup(*args, &block) }
14
+ end
15
+
16
+ def initialize(attribute, options, conditions)
17
+ @attribute = attribute.try(:to_sym)
18
+ @options = options
19
+ key = options[:as] || attribute
20
+ @key = key.respond_to?(:to_sym) ? key.to_sym : key
21
+ @is_safe = options[:safe]
22
+ @default_value = options[:default]
23
+ @for_merge = options[:merge]
24
+ @attr_path_proc = options[:attr_path]
25
+ @documentation = options[:documentation]
26
+ @override = options[:override]
27
+ @conditions = conditions
28
+ end
29
+
30
+ def dup(&block)
31
+ self.class.new(*dup_args, &block)
32
+ end
33
+
34
+ def dup_args
35
+ [@attribute, @options, @conditions.map(&:dup)]
36
+ end
37
+
38
+ def ==(other)
39
+ self.class == other.class &&
40
+ @attribute == other.attribute &&
41
+ @options == other.options &&
42
+ @conditions == other.conditions
43
+ end
44
+
45
+ def setup; end
46
+
47
+ def nesting?
48
+ false
49
+ end
50
+
51
+ # if we have any nesting exposures with the same name.
52
+ def deep_complex_nesting?(entity) # rubocop:disable Lint/UnusedMethodArgument
53
+ false
54
+ end
55
+
56
+ def valid?(entity)
57
+ is_delegatable = entity.delegator.delegatable?(@attribute) || entity.respond_to?(@attribute, true)
58
+ if @is_safe
59
+ is_delegatable
60
+ else
61
+ is_delegatable || raise(
62
+ NoMethodError,
63
+ "#{entity.class.name} missing attribute `#{@attribute}' on #{entity.object}"
64
+ )
65
+ end
66
+ end
67
+
68
+ def value(_entity, _options)
69
+ raise NotImplementedError
70
+ end
71
+
72
+ def serializable_value(entity, options)
73
+ partial_output = valid_value(entity, options)
74
+
75
+ if partial_output.respond_to?(:serializable_hash)
76
+ partial_output.serializable_hash
77
+ elsif partial_output.is_a?(Array) && partial_output.all? { |o| o.respond_to?(:serializable_hash) }
78
+ partial_output.map(&:serializable_hash)
79
+ elsif partial_output.is_a?(Hash)
80
+ partial_output.each do |key, value|
81
+ partial_output[key] = value.serializable_hash if value.respond_to?(:serializable_hash)
82
+ end
83
+ else
84
+ partial_output
85
+ end
86
+ end
87
+
88
+ def valid_value(entity, options)
89
+ return unless valid?(entity)
90
+
91
+ output = value(entity, options)
92
+ output.blank? && @default_value.present? ? @default_value : output
93
+ end
94
+
95
+ def should_return_key?(options)
96
+ options.should_return_key?(@key)
97
+ end
98
+
99
+ def conditional?
100
+ !@conditions.empty?
101
+ end
102
+
103
+ def conditions_met?(entity, options)
104
+ @conditions.all? { |condition| condition.met? entity, options }
105
+ end
106
+
107
+ def should_expose?(entity, options)
108
+ should_return_key?(options) && conditions_met?(entity, options)
109
+ end
110
+
111
+ def attr_path(entity, options)
112
+ if @attr_path_proc
113
+ entity.exec_with_object(options, &@attr_path_proc)
114
+ else
115
+ @key
116
+ end
117
+ end
118
+
119
+ def key(entity = nil)
120
+ @key.respond_to?(:call) ? entity.exec_with_object(@options, &@key) : @key
121
+ end
122
+
123
+ def with_attr_path(entity, options, &block)
124
+ path_part = attr_path(entity, options)
125
+ options.with_attr_path(path_part, &block)
126
+ end
127
+
128
+ def override?
129
+ @override
130
+ end
131
+
132
+ protected
133
+
134
+ attr_reader :options
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ class Entity
5
+ module Exposure
6
+ class BlockExposure < Base
7
+ attr_reader :block
8
+
9
+ def value(entity, options)
10
+ entity.exec_with_object(options, &@block)
11
+ end
12
+
13
+ def dup
14
+ super(&@block)
15
+ end
16
+
17
+ def ==(other)
18
+ super && @block == other.block
19
+ end
20
+
21
+ def valid?(_entity)
22
+ true
23
+ end
24
+
25
+ def setup(&block)
26
+ @block = block
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ class Entity
5
+ module Exposure
6
+ class DelegatorExposure < Base
7
+ def value(entity, _options)
8
+ entity.delegate_attribute(attribute)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ class Entity
5
+ module Exposure
6
+ class FormatterBlockExposure < Base
7
+ attr_reader :format_with
8
+
9
+ def setup(&format_with)
10
+ @format_with = format_with
11
+ end
12
+
13
+ def dup
14
+ super(&@format_with)
15
+ end
16
+
17
+ def ==(other)
18
+ super && @format_with == other.format_with
19
+ end
20
+
21
+ def value(entity, _options)
22
+ entity.exec_with_attribute(attribute, &@format_with)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ class Entity
5
+ module Exposure
6
+ class FormatterExposure < Base
7
+ attr_reader :format_with
8
+
9
+ def setup(format_with)
10
+ @format_with = format_with
11
+ end
12
+
13
+ def dup_args
14
+ [*super, format_with]
15
+ end
16
+
17
+ def ==(other)
18
+ super && @format_with == other.format_with
19
+ end
20
+
21
+ def value(entity, _options)
22
+ formatters = entity.class.formatters
23
+ if formatters[@format_with]
24
+ entity.exec_with_attribute(attribute, &formatters[@format_with])
25
+ else
26
+ entity.send(@format_with, entity.delegate_attribute(attribute))
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ class Entity
5
+ module Exposure
6
+ class NestingExposure
7
+ class NestedExposures
8
+ include Enumerable
9
+
10
+ def initialize(exposures)
11
+ @exposures = exposures
12
+ @deep_complex_nesting = nil
13
+ end
14
+
15
+ def find_by(attribute)
16
+ @exposures.find { |e| e.attribute == attribute }
17
+ end
18
+
19
+ def select_by(attribute)
20
+ @exposures.select { |e| e.attribute == attribute }
21
+ end
22
+
23
+ def <<(exposure)
24
+ reset_memoization!
25
+ @exposures << exposure
26
+ end
27
+
28
+ def delete_by(*attributes)
29
+ reset_memoization!
30
+ @exposures.reject! { |e| attributes.include? e.attribute }
31
+ @exposures
32
+ end
33
+
34
+ def clear
35
+ reset_memoization!
36
+ @exposures.clear
37
+ end
38
+
39
+ # rubocop:disable Style/DocumentDynamicEvalDefinition
40
+ %i[
41
+ each
42
+ to_ary to_a
43
+ all?
44
+ select
45
+ each_with_object
46
+ \[\]
47
+ ==
48
+ size
49
+ count
50
+ length
51
+ empty?
52
+ ].each do |name|
53
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
54
+ def #{name}(*args, &block)
55
+ @exposures.#{name}(*args, &block)
56
+ end
57
+ RUBY
58
+ end
59
+ # rubocop:enable Style/DocumentDynamicEvalDefinition
60
+
61
+ # Determine if we have any nesting exposures with the same name.
62
+ def deep_complex_nesting?(entity)
63
+ if @deep_complex_nesting.nil?
64
+ all_nesting = select(&:nesting?)
65
+ @deep_complex_nesting =
66
+ all_nesting
67
+ .group_by { |exposure| exposure.key(entity) }
68
+ .any? { |_key, exposures| exposures.length > 1 }
69
+ else
70
+ @deep_complex_nesting
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def reset_memoization!
77
+ @deep_complex_nesting = nil
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ class Entity
5
+ module Exposure
6
+ class NestingExposure
7
+ class OutputBuilder < SimpleDelegator
8
+ def initialize(entity)
9
+ @entity = entity
10
+ @output_hash = {}
11
+ @output_collection = []
12
+
13
+ super
14
+ end
15
+
16
+ def add(exposure, result)
17
+ # Save a result array in collections' array if it should be merged
18
+ if result.is_a?(Array) && exposure.for_merge
19
+ @output_collection << result
20
+ elsif exposure.for_merge
21
+ # If we have an array which should not be merged - save it with a key as a hash
22
+ # If we have hash which should be merged - save it without a key (merge)
23
+ return unless result
24
+
25
+ @output_hash.merge! result, &merge_strategy(exposure.for_merge)
26
+ else
27
+ @output_hash[exposure.key(@entity)] = result
28
+ end
29
+ end
30
+
31
+ def kind_of?(klass)
32
+ klass == output.class || super
33
+ end
34
+ alias is_a? kind_of?
35
+
36
+ def __getobj__
37
+ output
38
+ end
39
+
40
+ private
41
+
42
+ # If output_collection contains at least one element we have to represent the output as a collection
43
+ def output
44
+ if @output_collection.empty?
45
+ output = @output_hash
46
+ else
47
+ output = @output_collection
48
+ output << @output_hash unless @output_hash.empty?
49
+ output.flatten!
50
+ end
51
+ output
52
+ end
53
+
54
+ # In case if we want to solve collisions providing lambda to :merge option
55
+ def merge_strategy(for_merge)
56
+ if for_merge.respond_to? :call
57
+ for_merge
58
+ else
59
+ -> {}
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ class Entity
5
+ module Exposure
6
+ class NestingExposure < Base
7
+ attr_reader :nested_exposures
8
+
9
+ def setup(nested_exposures = [])
10
+ @nested_exposures = NestedExposures.new(nested_exposures)
11
+ end
12
+
13
+ def dup_args
14
+ [*super, @nested_exposures.map(&:dup)]
15
+ end
16
+
17
+ def ==(other)
18
+ super && @nested_exposures == other.nested_exposures
19
+ end
20
+
21
+ def nesting?
22
+ true
23
+ end
24
+
25
+ def find_nested_exposure(attribute)
26
+ nested_exposures.find_by(attribute)
27
+ end
28
+
29
+ def valid?(entity)
30
+ nested_exposures.all? { |e| e.valid?(entity) }
31
+ end
32
+
33
+ def value(entity, options)
34
+ map_entity_exposures(entity, options) do |exposure, nested_options|
35
+ exposure.value(entity, nested_options)
36
+ end
37
+ end
38
+
39
+ def serializable_value(entity, options)
40
+ map_entity_exposures(entity, options) do |exposure, nested_options|
41
+ exposure.serializable_value(entity, nested_options)
42
+ end
43
+ end
44
+
45
+ def valid_value_for(key, entity, options)
46
+ new_options = nesting_options_for(options)
47
+
48
+ key_exposures = normalized_exposures(entity, new_options).select { |e| e.key(entity) == key }
49
+
50
+ key_exposures.map do |exposure|
51
+ exposure.with_attr_path(entity, new_options) do
52
+ exposure.valid_value(entity, new_options)
53
+ end
54
+ end.last
55
+ end
56
+
57
+ # if we have any nesting exposures with the same name.
58
+ # delegate :deep_complex_nesting?(entity), to: :nested_exposures
59
+ def deep_complex_nesting?(entity)
60
+ nested_exposures.deep_complex_nesting?(entity)
61
+ end
62
+
63
+ private
64
+
65
+ def nesting_options_for(options)
66
+ if @key
67
+ options.for_nesting(@key)
68
+ else
69
+ options
70
+ end
71
+ end
72
+
73
+ def easy_normalized_exposures(entity, options)
74
+ nested_exposures.select do |exposure|
75
+ exposure.with_attr_path(entity, options) do
76
+ exposure.should_expose?(entity, options)
77
+ end
78
+ end
79
+ end
80
+
81
+ # This method 'merges' subsequent nesting exposures with the same name if it's needed
82
+ def normalized_exposures(entity, options)
83
+ return easy_normalized_exposures(entity, options) unless deep_complex_nesting?(entity) # optimization
84
+
85
+ table = nested_exposures.each_with_object({}) do |exposure, output|
86
+ should_expose = exposure.with_attr_path(entity, options) do
87
+ exposure.should_expose?(entity, options)
88
+ end
89
+ next unless should_expose
90
+
91
+ output[exposure.key(entity)] ||= []
92
+ output[exposure.key(entity)] << exposure
93
+ end
94
+
95
+ table.map do |key, exposures|
96
+ last_exposure = exposures.last
97
+
98
+ if last_exposure.nesting?
99
+ # For the given key if the last candidates for exposing are nesting then combine them.
100
+ nesting_tail = []
101
+ exposures.reverse_each do |exposure|
102
+ nesting_tail.unshift exposure if exposure.nesting?
103
+ end
104
+ new_nested_exposures = nesting_tail.flat_map(&:nested_exposures)
105
+ NestingExposure.new(key, {}, [], new_nested_exposures).tap do |new_exposure|
106
+ if nesting_tail.any? { |exposure| exposure.deep_complex_nesting?(entity) }
107
+ new_exposure.instance_variable_set(:@deep_complex_nesting, true)
108
+ end
109
+ end
110
+ else
111
+ last_exposure
112
+ end
113
+ end
114
+ end
115
+
116
+ def map_entity_exposures(entity, options)
117
+ new_options = nesting_options_for(options)
118
+ output = OutputBuilder.new(entity)
119
+
120
+ normalized_exposures(entity, new_options).each_with_object(output) do |exposure, out|
121
+ exposure.with_attr_path(entity, new_options) do
122
+ result = yield(exposure, new_options)
123
+ out.add(exposure, result)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ require 'grape_entity/exposure/nesting_exposure/nested_exposures'
133
+ require 'grape_entity/exposure/nesting_exposure/output_builder'
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Grape
4
+ class Entity
5
+ module Exposure
6
+ class RepresentExposure < Base
7
+ attr_reader :using_class_name, :subexposure
8
+
9
+ def setup(using_class_name, subexposure)
10
+ @using_class = nil
11
+ @using_class_name = using_class_name
12
+ @subexposure = subexposure
13
+ end
14
+
15
+ def dup_args
16
+ [*super, using_class_name, subexposure]
17
+ end
18
+
19
+ def ==(other)
20
+ super &&
21
+ @using_class_name == other.using_class_name &&
22
+ @subexposure == other.subexposure
23
+ end
24
+
25
+ def value(entity, options)
26
+ new_options = options.for_nesting(key(entity))
27
+ using_class.represent(@subexposure.value(entity, options), new_options)
28
+ end
29
+
30
+ def valid?(entity)
31
+ @subexposure.valid? entity
32
+ end
33
+
34
+ def using_class
35
+ @using_class ||= if @using_class_name.respond_to? :constantize
36
+ @using_class_name.constantize
37
+ else
38
+ @using_class_name
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def using_options_for(options)
45
+ options.for_nesting(key)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'grape_entity/exposure/base'
4
+ require 'grape_entity/exposure/represent_exposure'
5
+ require 'grape_entity/exposure/block_exposure'
6
+ require 'grape_entity/exposure/delegator_exposure'
7
+ require 'grape_entity/exposure/formatter_exposure'
8
+ require 'grape_entity/exposure/formatter_block_exposure'
9
+ require 'grape_entity/exposure/nesting_exposure'
10
+ require 'grape_entity/condition'
11
+
12
+ module Grape
13
+ class Entity
14
+ module Exposure
15
+ class << self
16
+ def new(attribute, options)
17
+ conditions = compile_conditions(attribute, options)
18
+ base_args = [attribute, options, conditions]
19
+
20
+ passed_proc = options[:proc]
21
+ using_class = options[:using]
22
+ format_with = options[:format_with]
23
+
24
+ if using_class
25
+ build_class_exposure(base_args, using_class, passed_proc)
26
+ elsif passed_proc
27
+ build_block_exposure(base_args, passed_proc)
28
+ elsif format_with
29
+ build_formatter_exposure(base_args, format_with)
30
+ elsif options[:nesting]
31
+ build_nesting_exposure(base_args)
32
+ else
33
+ build_delegator_exposure(base_args)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def compile_conditions(attribute, options)
40
+ if_conditions = [
41
+ options[:if_extras],
42
+ options[:if]
43
+ ].compact.flatten.map { |cond| Condition.new_if(cond) }
44
+
45
+ unless_conditions = [
46
+ options[:unless_extras],
47
+ options[:unless]
48
+ ].compact.flatten.map { |cond| Condition.new_unless(cond) }
49
+
50
+ unless_conditions << expose_nil_condition(attribute, options) if options[:expose_nil] == false
51
+
52
+ if_conditions + unless_conditions
53
+ end
54
+
55
+ def expose_nil_condition(attribute, options)
56
+ Condition.new_unless(
57
+ proc do |object, _options|
58
+ if options[:proc].nil?
59
+ delegator = Delegator.new(object)
60
+ if is_a?(Grape::Entity) && delegator.accepts_options?
61
+ delegator.delegate(attribute, **self.class.delegation_opts).nil?
62
+ else
63
+ delegator.delegate(attribute).nil?
64
+ end
65
+ else
66
+ exec_with_object(options, &options[:proc]).nil?
67
+ end
68
+ end
69
+ )
70
+ end
71
+
72
+ def build_class_exposure(base_args, using_class, passed_proc)
73
+ exposure =
74
+ if passed_proc
75
+ build_block_exposure(base_args, passed_proc)
76
+ else
77
+ build_delegator_exposure(base_args)
78
+ end
79
+
80
+ RepresentExposure.new(*base_args, using_class, exposure)
81
+ end
82
+
83
+ def build_formatter_exposure(base_args, format_with)
84
+ if format_with.is_a? Symbol
85
+ FormatterExposure.new(*base_args, format_with)
86
+ elsif format_with.respond_to?(:call)
87
+ FormatterBlockExposure.new(*base_args, &format_with)
88
+ end
89
+ end
90
+
91
+ def build_nesting_exposure(base_args)
92
+ NestingExposure.new(*base_args)
93
+ end
94
+
95
+ def build_block_exposure(base_args, passed_proc)
96
+ BlockExposure.new(*base_args, &passed_proc)
97
+ end
98
+
99
+ def build_delegator_exposure(base_args)
100
+ DelegatorExposure.new(*base_args)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end