graphiti 1.2.16 → 1.3.9

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +96 -0
  3. data/.standard.yml +4 -4
  4. data/Appraisals +23 -17
  5. data/CHANGELOG.md +7 -1
  6. data/Guardfile +5 -5
  7. data/deprecated_generators/graphiti/generator_mixin.rb +1 -0
  8. data/deprecated_generators/graphiti/resource_generator.rb +1 -1
  9. data/gemfiles/{rails_5.gemfile → rails_5_2.gemfile} +2 -2
  10. data/gemfiles/{rails_5_graphiti_rails.gemfile → rails_5_2_graphiti_rails.gemfile} +3 -4
  11. data/gemfiles/rails_6.gemfile +1 -1
  12. data/gemfiles/rails_6_graphiti_rails.gemfile +2 -3
  13. data/gemfiles/{rails_4.gemfile → rails_7.gemfile} +2 -2
  14. data/gemfiles/rails_7_graphiti_rails.gemfile +19 -0
  15. data/graphiti.gemspec +16 -16
  16. data/lib/graphiti/adapters/abstract.rb +20 -5
  17. data/lib/graphiti/adapters/active_record/belongs_to_sideload.rb +1 -1
  18. data/lib/graphiti/adapters/active_record/has_many_sideload.rb +1 -1
  19. data/lib/graphiti/adapters/active_record/has_one_sideload.rb +1 -1
  20. data/lib/graphiti/adapters/active_record/{inferrence.rb → inference.rb} +2 -2
  21. data/lib/graphiti/adapters/active_record/many_to_many_sideload.rb +19 -0
  22. data/lib/graphiti/adapters/active_record.rb +119 -74
  23. data/lib/graphiti/adapters/graphiti_api.rb +1 -1
  24. data/lib/graphiti/adapters/null.rb +1 -1
  25. data/lib/graphiti/adapters/persistence/associations.rb +78 -0
  26. data/lib/graphiti/configuration.rb +3 -1
  27. data/lib/graphiti/debugger.rb +12 -8
  28. data/lib/graphiti/delegates/pagination.rb +47 -13
  29. data/lib/graphiti/deserializer.rb +3 -3
  30. data/lib/graphiti/errors.rb +109 -15
  31. data/lib/graphiti/extensions/extra_attribute.rb +4 -4
  32. data/lib/graphiti/extensions/temp_id.rb +1 -1
  33. data/lib/graphiti/filter_operators.rb +0 -1
  34. data/lib/graphiti/hash_renderer.rb +198 -21
  35. data/lib/graphiti/query.rb +105 -73
  36. data/lib/graphiti/railtie.rb +5 -5
  37. data/lib/graphiti/renderer.rb +19 -1
  38. data/lib/graphiti/request_validator.rb +10 -10
  39. data/lib/graphiti/request_validators/update_validator.rb +4 -5
  40. data/lib/graphiti/request_validators/validator.rb +38 -24
  41. data/lib/graphiti/resource/configuration.rb +35 -7
  42. data/lib/graphiti/resource/dsl.rb +34 -8
  43. data/lib/graphiti/resource/interface.rb +13 -3
  44. data/lib/graphiti/resource/links.rb +3 -3
  45. data/lib/graphiti/resource/persistence.rb +2 -1
  46. data/lib/graphiti/resource/polymorphism.rb +8 -2
  47. data/lib/graphiti/resource/remote.rb +2 -2
  48. data/lib/graphiti/resource/sideloading.rb +4 -4
  49. data/lib/graphiti/resource.rb +12 -1
  50. data/lib/graphiti/resource_proxy.rb +23 -3
  51. data/lib/graphiti/runner.rb +5 -5
  52. data/lib/graphiti/schema.rb +36 -11
  53. data/lib/graphiti/schema_diff.rb +44 -4
  54. data/lib/graphiti/scope.rb +8 -10
  55. data/lib/graphiti/scoping/base.rb +3 -3
  56. data/lib/graphiti/scoping/filter.rb +36 -15
  57. data/lib/graphiti/scoping/filter_group_validator.rb +78 -0
  58. data/lib/graphiti/scoping/paginate.rb +47 -3
  59. data/lib/graphiti/scoping/sort.rb +5 -7
  60. data/lib/graphiti/serializer.rb +49 -7
  61. data/lib/graphiti/sideload/belongs_to.rb +1 -1
  62. data/lib/graphiti/sideload/has_many.rb +19 -1
  63. data/lib/graphiti/sideload/many_to_many.rb +11 -4
  64. data/lib/graphiti/sideload/polymorphic_belongs_to.rb +3 -4
  65. data/lib/graphiti/sideload.rb +47 -23
  66. data/lib/graphiti/stats/dsl.rb +0 -1
  67. data/lib/graphiti/stats/payload.rb +12 -9
  68. data/lib/graphiti/types.rb +15 -15
  69. data/lib/graphiti/util/attribute_check.rb +1 -1
  70. data/lib/graphiti/util/class.rb +6 -0
  71. data/lib/graphiti/util/link.rb +10 -2
  72. data/lib/graphiti/util/persistence.rb +21 -78
  73. data/lib/graphiti/util/relationship_payload.rb +4 -4
  74. data/lib/graphiti/util/remote_params.rb +9 -4
  75. data/lib/graphiti/util/remote_serializer.rb +1 -0
  76. data/lib/graphiti/util/serializer_attributes.rb +41 -11
  77. data/lib/graphiti/util/simple_errors.rb +4 -4
  78. data/lib/graphiti/util/transaction_hooks_recorder.rb +1 -1
  79. data/lib/graphiti/version.rb +1 -1
  80. data/lib/graphiti.rb +6 -3
  81. metadata +46 -37
  82. data/.travis.yml +0 -59
@@ -2,6 +2,31 @@ module Graphiti
2
2
  module Errors
3
3
  class Base < StandardError; end
4
4
 
5
+ class UnreadableAttribute < Base
6
+ def initialize(resource_class, name)
7
+ @resource_class = resource_class
8
+ @name = name
9
+ end
10
+
11
+ def message
12
+ "#{@resource_class}: Requested field #{@name}, but not authorized to read it"
13
+ end
14
+ end
15
+
16
+ class NullRelation
17
+ attr_accessor :id, :errors, :pointer
18
+
19
+ def initialize(id, pointer)
20
+ @id = id
21
+ @pointer = pointer
22
+ @errors = Graphiti::Util::SimpleErrors.new(self)
23
+ end
24
+
25
+ def self.human_attribute_name(attr, options = {})
26
+ attr
27
+ end
28
+ end
29
+
5
30
  class AdapterNotImplemented < Base
6
31
  def initialize(adapter, attribute, method)
7
32
  @adapter = adapter
@@ -10,7 +35,7 @@ module Graphiti
10
35
  end
11
36
 
12
37
  def message
13
- <<-MSG
38
+ <<~MSG
14
39
  The adapter #{@adapter.class} does not implement method '#{@method}', which was requested for attribute '#{@attribute}'. Add this method to your adapter to support this filter operator.
15
40
  MSG
16
41
  end
@@ -24,7 +49,7 @@ module Graphiti
24
49
  end
25
50
 
26
51
  def message
27
- <<-MSG
52
+ <<~MSG
28
53
  #{@parent_resource_class} sideload :#{@name} - #{@message}
29
54
  MSG
30
55
  end
@@ -53,7 +78,7 @@ module Graphiti
53
78
  end
54
79
 
55
80
  def message
56
- <<-MSG
81
+ <<~MSG
57
82
  #{@resource_class}: Tried to pass block to .#{@method_name}, which only accepts a method name.
58
83
  MSG
59
84
  end
@@ -65,7 +90,7 @@ module Graphiti
65
90
  end
66
91
 
67
92
  def message
68
- <<-MSG
93
+ <<~MSG
69
94
  #{@resource_class}: Tried to perform write operation. Writes are not supported for remote resources - hit the endpoint directly.
70
95
  MSG
71
96
  end
@@ -80,7 +105,7 @@ module Graphiti
80
105
  end
81
106
 
82
107
  def message
83
- <<-MSG
108
+ <<~MSG
84
109
  #{@resource.class}: Tried to filter #{@filter_name.inspect} on operator #{@operator.inspect}, but not supported! Supported operators are #{@supported}.
85
110
  MSG
86
111
  end
@@ -93,7 +118,7 @@ module Graphiti
93
118
  end
94
119
 
95
120
  def message
96
- <<-MSG
121
+ <<~MSG
97
122
  #{@sideload.parent_resource.class.name}: tried to sideload #{@sideload.name.inspect}, but more than one #{@sideload.parent_resource.model.name} was passed!
98
123
 
99
124
  This is because you marked the sideload #{@sideload.name.inspect} with single: true
@@ -114,7 +139,7 @@ module Graphiti
114
139
  end
115
140
 
116
141
  def message
117
- <<-MSG
142
+ <<~MSG
118
143
  #{@resource.class.name}: tried to sort on attribute #{@attribute.inspect}, but passed #{@direction.inspect} when only #{@allowlist.inspect} is supported.
119
144
  MSG
120
145
  end
@@ -127,7 +152,7 @@ module Graphiti
127
152
  end
128
153
 
129
154
  def message
130
- <<-MSG
155
+ <<~MSG
131
156
  #{@resource_class.name}: called .on_extra_attribute #{@name.inspect}, but extra attribute #{@name.inspect} does not exist!
132
157
  MSG
133
158
  end
@@ -143,8 +168,13 @@ module Graphiti
143
168
  def message
144
169
  allow = @filter.values[0][:allow]
145
170
  deny = @filter.values[0][:deny]
146
- msg = <<-MSG
147
- #{@resource.class.name}: tried to filter on #{@filter.keys[0].inspect}, but passed invalid value #{@value.inspect}.
171
+ value_string = if @value == "(empty)"
172
+ "empty value"
173
+ else
174
+ "value #{@value.inspect}"
175
+ end
176
+ msg = <<~MSG
177
+ #{@resource.class.name}: tried to filter on #{@filter.keys[0].inspect}, but passed invalid #{value_string}.
148
178
  MSG
149
179
  msg << "\nAllowlist: #{allow.inspect}" if allow
150
180
  msg << "\nDenylist: #{deny.inspect}" if deny
@@ -160,7 +190,7 @@ module Graphiti
160
190
  end
161
191
 
162
192
  def message
163
- <<-MSG
193
+ <<~MSG
164
194
  #{@resource_class.name} You declared an attribute or filter of type "#{@enum_type}" without providing a list of permitted values, which is required.
165
195
 
166
196
  When declaring an attribute:
@@ -184,12 +214,12 @@ module Graphiti
184
214
  end
185
215
 
186
216
  def message
187
- <<-MSG
217
+ <<~MSG
188
218
  #{@resource_class.name}: Cannot link to sideload #{@sideload.name.inspect}!
189
219
 
190
220
  Make sure the endpoint "#{@sideload.resource.endpoint[:full_path]}" exists with action #{@action.inspect}, or customize the endpoint for #{@sideload.resource.class.name}.
191
221
 
192
- If you do not wish to generate a link, pass link: false or set self.relationship_links_by_default = false.
222
+ If you do not wish to generate a link, pass link: false or set self.autolink = false.
193
223
  MSG
194
224
  end
195
225
  end
@@ -316,14 +346,14 @@ module Graphiti
316
346
  sortable: "sort on",
317
347
  filterable: "filter on",
318
348
  readable: "read",
319
- writable: "write",
349
+ writable: "write"
320
350
  }[@flag]
321
351
  else
322
352
  {
323
353
  sortable: "add sort",
324
354
  filterable: "add filter",
325
355
  readable: "read",
326
- writable: "write",
356
+ writable: "write"
327
357
  }[@flag]
328
358
  end
329
359
  end
@@ -361,6 +391,20 @@ module Graphiti
361
391
  end
362
392
  end
363
393
 
394
+ class UndefinedIDLookup < Base
395
+ def initialize(resource_class)
396
+ @resource_class = resource_class
397
+ end
398
+
399
+ def message
400
+ <<~MSG
401
+ Tried to resolve #{@resource_class} with an :id filter, but the filter was nil.
402
+ This can result in unscoping a query, which can cause incorrect values to be
403
+ returned which may or may not bypass standard access controls.
404
+ MSG
405
+ end
406
+ end
407
+
364
408
  class UnknownAttribute < AttributeError
365
409
  def message
366
410
  "#{super}, but could not find an attribute with that name."
@@ -689,6 +733,12 @@ module Graphiti
689
733
  end
690
734
  end
691
735
 
736
+ class UnsupportedBeforeCursor < Base
737
+ def message
738
+ "Passing in page[before] and page[number] is not supported. Please create an issue if you need it!"
739
+ end
740
+ end
741
+
692
742
  class InvalidInclude < Base
693
743
  def initialize(resource, relationship)
694
744
  @resource = resource
@@ -722,6 +772,21 @@ module Graphiti
722
772
  end
723
773
 
724
774
  class RecordNotFound < Base
775
+ def initialize(resource = nil, id = nil, path = nil)
776
+ @resource = resource
777
+ @id = id
778
+ @path = path
779
+ end
780
+
781
+ def message
782
+ if !@resource.nil? && !@id.nil?
783
+ "The referenced resource '#{@resource}' with id '#{@id}' could not be found.".tap do |msg|
784
+ msg << " Referenced at '#{@path}'" unless @path.nil?
785
+ end
786
+ else
787
+ "Specified Record Not Found"
788
+ end
789
+ end
725
790
  end
726
791
 
727
792
  class RequiredFilter < Base
@@ -757,5 +822,34 @@ module Graphiti
757
822
 
758
823
  class ConflictRequest < InvalidRequest
759
824
  end
825
+
826
+ class FilterGroupInvalidRequirement < Base
827
+ def initialize(resource, valid_required_values)
828
+ @resource = resource
829
+ @valid_required_values = valid_required_values
830
+ end
831
+
832
+ def message
833
+ <<-MSG.gsub(/\s+/, " ").strip
834
+ The filter group required: value on resource #{@resource.class} must be one of the following:
835
+ #{@valid_required_values.join(", ")}
836
+ MSG
837
+ end
838
+ end
839
+
840
+ class FilterGroupMissingRequiredFilters < Base
841
+ def initialize(resource, filter_names, required)
842
+ @resource = resource
843
+ @filter_names = filter_names
844
+ @required_label = required == :all ? "All" : "One"
845
+ end
846
+
847
+ def message
848
+ <<-MSG.gsub(/\s+/, " ").strip
849
+ #{@required_label} of the following filters must be provided on resource #{@resource.type}:
850
+ #{@filter_names.join(", ")}
851
+ MSG
852
+ end
853
+ end
760
854
  end
761
855
  end
@@ -43,12 +43,12 @@ module Graphiti
43
43
  def extra_attribute(name, options = {}, &blk)
44
44
  allow_field = proc {
45
45
  if options[:if]
46
- next false unless instance_eval(&options[:if])
46
+ next false unless instance_exec(&options[:if])
47
47
  end
48
48
 
49
- @extra_fields &&
50
- @extra_fields[@_type] &&
51
- @extra_fields[@_type].include?(name)
49
+ next false unless @extra_fields
50
+
51
+ @extra_fields[@_type]&.include?(name) || @extra_fields[@resource&.type]&.include?(name)
52
52
  }
53
53
 
54
54
  attribute name, if: allow_field, &blk
@@ -13,7 +13,7 @@ module Graphiti
13
13
  # Common interface for jsonapi-rb extensions
14
14
  def as_jsonapi(*)
15
15
  super.tap do |hash|
16
- if (temp_id = @object.instance_variable_get(:'@_jsonapi_temp_id'))
16
+ if (temp_id = @object.instance_variable_get(:@_jsonapi_temp_id))
17
17
  hash[:'temp-id'] = temp_id
18
18
  end
19
19
  end
@@ -17,7 +17,6 @@ module Graphiti
17
17
  end
18
18
  end
19
19
 
20
- # rubocop: disable Style/MethodMissingSuper
21
20
  def method_missing(name, *args, &blk)
22
21
  @procs[name] = blk
23
22
  end
@@ -1,55 +1,232 @@
1
1
  module Graphiti
2
2
  module SerializableHash
3
- def to_hash(fields: nil, include: {})
3
+ def to_hash(fields: nil, include: {}, name_chain: [], graphql: false)
4
4
  {}.tap do |hash|
5
- fields_list = fields[jsonapi_type] if fields
5
+ if fields
6
+ fields_list = nil
7
+
8
+ # Dot syntax wins over jsonapi type
9
+ if name_chain.length > 0
10
+ fields_list = fields[name_chain.join(".").to_sym]
11
+ end
12
+
13
+ if fields_list.nil?
14
+ fields_list = fields[jsonapi_type]
15
+ end
16
+ end
17
+
18
+ # polymorphic resources - merge the PARENT type
19
+ if polymorphic_subclass?
20
+ if fields[@resource.type]
21
+ fields_list ||= []
22
+ fields_list |= fields[@resource.type]
23
+ end
24
+
25
+ if fields[jsonapi_type]
26
+ fields_list ||= []
27
+ fields_list |= fields[jsonapi_type]
28
+ end
29
+ end
30
+
6
31
  attrs = requested_attributes(fields_list).each_with_object({}) { |(k, v), h|
7
- h[k] = instance_eval(&v)
32
+ name = graphql ? k.to_s.camelize(:lower).to_sym : k
33
+ h[name] = instance_eval(&v)
34
+ }
35
+
36
+ # The main logic here is just !!include[k]
37
+ # But we also have special on__<type>--<name> includes
38
+ # Where we only include when matching the polymorphic type
39
+ rels = @_relationships.select { |k, v|
40
+ if include[k]
41
+ true
42
+ else
43
+ included = false
44
+ include.keys.each do |key|
45
+ split = key.to_s.split(/^on__/)
46
+ if split.length > 1
47
+ requested_type, key = split[1].split("--")
48
+ if requested_type.to_sym == jsonapi_type
49
+ included = k == key.to_sym
50
+ break
51
+ end
52
+ end
53
+ end
54
+ included
55
+ end
8
56
  }
9
- rels = @_relationships.select { |k, v| !!include[k] }
57
+
10
58
  rels.each_with_object({}) do |(k, v), h|
59
+ nested_include = include[k]
60
+
61
+ # This logic only fires if it's a special on__<type>--<name> include
62
+ unless include.has_key?(k)
63
+ include.keys.each do |include_key|
64
+ if k == include_key.to_s.split("--")[1].to_sym
65
+ nested_include = include[include_key]
66
+ break
67
+ end
68
+ end
69
+ end
70
+
11
71
  serializers = v.send(:resources)
12
- attrs[k] = if serializers.is_a?(Array)
13
- serializers.map do |rr| # use private method to avoid array casting
14
- rr.to_hash(fields: fields, include: include[k])
72
+ name = graphql ? k.to_s.camelize(:lower) : k
73
+ name_chain = name_chain.dup
74
+ name_chain << k unless name_chain.last == k
75
+
76
+ unless remote_resource? && serializers.nil?
77
+ payload = if serializers.is_a?(Array)
78
+ data = serializers.map { |rr|
79
+ rr.to_hash(fields: fields, include: nested_include, graphql: graphql, name_chain: name_chain)
80
+ }
81
+ graphql ? {nodes: data} : data
82
+ elsif serializers.nil?
83
+ if @resource.class.respond_to?(:sideload)
84
+ if @resource.class.sideload(k).type.to_s.include?("_many")
85
+ graphql ? {nodes: []} : []
86
+ end
87
+ end
88
+ else
89
+ serializers.to_hash(fields: fields, include: nested_include, graphql: graphql, name_chain: name_chain)
15
90
  end
16
- elsif serializers.nil?
17
- nil
18
- else
19
- serializers.to_hash(fields: fields, include: include[k])
91
+
92
+ attrs[name.to_sym] = payload
20
93
  end
21
94
  end
22
95
 
23
- hash[:id] = jsonapi_id
96
+ if !graphql || (fields_list || []).include?(:id)
97
+ hash[:id] = jsonapi_id
98
+ end
99
+
100
+ if (fields_list || []).include?(:_type)
101
+ hash[:_type] = jsonapi_type.to_s
102
+ end
103
+
104
+ if (fields_list || []).include?(:_cursor)
105
+ hash[:_cursor] = cursor
106
+ end
107
+
108
+ if (fields_list || []).include?(:__typename)
109
+ resource_class = @resource.class
110
+ if polymorphic_subclass?
111
+ resource_class = @resource.class.resource_for_type(jsonapi_type)
112
+ end
113
+ hash[:__typename] = ::Graphiti::Util::Class
114
+ .graphql_type_name(resource_class.name)
115
+ end
116
+
24
117
  hash.merge!(attrs) if attrs.any?
25
118
  end
26
119
  end
120
+
121
+ def polymorphic_subclass?
122
+ !remote_resource? &&
123
+ @resource.polymorphic? &&
124
+ @resource.type != jsonapi_type
125
+ end
126
+
127
+ # See hack in util/remote_serializer.rb
128
+ def remote_resource?
129
+ @resource == 1
130
+ end
27
131
  end
28
132
 
29
133
  class HashRenderer
30
- def initialize(resource)
134
+ def initialize(resource, graphql: false)
31
135
  @resource = resource
136
+ @graphql = graphql
32
137
  end
33
138
 
34
139
  def render(options)
35
140
  serializers = options[:data]
36
141
  opts = options.slice(:fields, :include)
37
- to_hash(serializers, opts).tap do |hash|
38
- hash.merge!(options.slice(:meta)) unless options[:meta].empty?
142
+ opts[:graphql] = @graphql
143
+ top_level_key = get_top_level_key(@resource, serializers.is_a?(Array))
144
+
145
+ hash = {top_level_key => {}}
146
+ nodes = get_nodes(serializers, opts)
147
+ add_nodes(hash, top_level_key, options, nodes, @graphql)
148
+ add_stats(hash, top_level_key, options, @graphql)
149
+ if @graphql
150
+ add_page_info(hash, serializers, top_level_key, options)
39
151
  end
152
+
153
+ hash
40
154
  end
41
155
 
42
156
  private
43
157
 
44
- def to_hash(serializers, opts)
45
- {}.tap do |hash|
46
- hash[:data] = if serializers.is_a?(Array)
47
- serializers.map do |s|
48
- s.to_hash(opts)
158
+ def get_top_level_key(resource, is_many)
159
+ key = :data
160
+
161
+ if @graphql
162
+ key = @resource.graphql_entrypoint
163
+ key = key.to_s.singularize.to_sym unless is_many
164
+ end
165
+
166
+ key
167
+ end
168
+
169
+ def get_nodes(serializers, opts)
170
+ if serializers.is_a?(Array)
171
+ serializers.each_with_index.map do |s, index|
172
+ s.to_hash(**opts)
173
+ end
174
+ else
175
+ serializers.to_hash(**opts)
176
+ end
177
+ end
178
+
179
+ def add_nodes(hash, top_level_key, opts, nodes, graphql)
180
+ payload = nodes
181
+ if graphql && nodes.is_a?(Array)
182
+ payload = {nodes: nodes}
183
+ end
184
+
185
+ # Don't render nodes if we only requested stats
186
+ unless graphql && opts[:fields].values == [[:stats]]
187
+ hash[top_level_key] = payload
188
+ end
189
+ end
190
+
191
+ def add_stats(hash, top_level_key, options, graphql)
192
+ if options[:meta] && !options[:meta].empty?
193
+ if @graphql
194
+ if (stats = options[:meta][:stats])
195
+ camelized = {}
196
+ stats.each_pair do |key, value|
197
+ camelized[key.to_s.camelize(:lower).to_sym] = value
198
+ end
199
+ hash[top_level_key][:stats] = camelized
49
200
  end
50
201
  else
51
- serializers.to_hash(opts)
202
+ hash.merge!(options.slice(:meta))
203
+ end
204
+ end
205
+ end
206
+
207
+ # NB - this is only for top-level right now
208
+ # The casing here is GQL-specific, we can update later if needed.
209
+ def add_page_info(hash, serializers, top_level_key, options)
210
+ if (fields = options[:fields].try(:[], :page_info))
211
+ info = {}
212
+
213
+ if fields.include?(:has_next_page)
214
+ info[:hasNextPage] = options[:proxy].pagination.has_next_page?
215
+ end
216
+
217
+ if fields.include?(:has_previous_page)
218
+ info[:hasPreviousPage] = options[:proxy].pagination.has_previous_page?
219
+ end
220
+
221
+ if fields.include?(:start_cursor)
222
+ info[:startCursor] = serializers.first.try(:cursor)
223
+ end
224
+
225
+ if fields.include?(:end_cursor)
226
+ info[:endCursor] = serializers.last.try(:cursor)
52
227
  end
228
+
229
+ hash[top_level_key][:pageInfo] = info
53
230
  end
54
231
  end
55
232
  end