forestadmin-jsonapi-serializers 2.0.0.pre.beta.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.
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require "bundler/gem_tasks"
2
+
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'jsonapi-serializers/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "forestadmin-jsonapi-serializers"
8
+ spec.version = ForestAdmin::JSONAPI::Serializer::VERSION
9
+ spec.authors = ["Mike Fotinakis"]
10
+ spec.email = ["mike@fotinakis.com"]
11
+ spec.summary = %q{Pure Ruby readonly serializers for the JSON:API spec.}
12
+ spec.description = %q{Pure Ruby readonly serializers for the JSON:API spec.}
13
+ spec.homepage = "https://github.com/forestadmin/jsonapi-serializers"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "activesupport"
22
+ spec.add_development_dependency "bundler"
23
+ spec.add_development_dependency "rake", "~> 10.0"
24
+ spec.add_development_dependency "rspec", "~> 3.2"
25
+ spec.add_development_dependency "factory_girl", "~> 4.5"
26
+ spec.add_development_dependency "activemodel", "~> 4.2"
27
+ spec.add_development_dependency "pry"
28
+ end
@@ -0,0 +1,13 @@
1
+ require "jsonapi-serializers/version"
2
+ require "jsonapi-serializers/attributes"
3
+ require "jsonapi-serializers/serializer"
4
+
5
+ module ForestAdmin
6
+ module JSONAPI
7
+ module Serializer
8
+ class Error < Exception; end
9
+ class AmbiguousCollectionError < Error; end
10
+ class InvalidIncludeError < Error; end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,79 @@
1
+ module ForestAdmin
2
+ module JSONAPI
3
+ module Attributes
4
+ def self.included(target)
5
+ target.send(:include, InstanceMethods)
6
+ target.extend ClassMethods
7
+
8
+ target.class_eval do
9
+ def self.inherited(target)
10
+ [:attributes_map, :to_one_associations, :to_many_associations]
11
+ .each{|k|
12
+ key = "@#{k}"
13
+ attr = self.instance_variable_get(key)
14
+ target.instance_variable_set(key, attr.dup) if attr
15
+ }
16
+ end
17
+ end
18
+ end
19
+
20
+ module InstanceMethods
21
+ end
22
+
23
+ module ClassMethods
24
+ attr_accessor :attributes_map
25
+ attr_accessor :to_one_associations
26
+ attr_accessor :to_many_associations
27
+
28
+ def attribute(name, options = {}, &block)
29
+ add_attribute(name, options, &block)
30
+ end
31
+
32
+ def attributes(*names)
33
+ names.each { |name| add_attribute(name) }
34
+ end
35
+
36
+ def has_one(name, options = {}, &block)
37
+ add_to_one_association(name, options, &block)
38
+ end
39
+
40
+ def has_many(name, options = {}, &block)
41
+ add_to_many_association(name, options, &block)
42
+ end
43
+
44
+ def add_attribute(name, options = {}, &block)
45
+ # Blocks are optional and can override the default attribute discovery. They are just
46
+ # stored here, but evaluated by the Serializer within the instance context.
47
+ @attributes_map ||= {}
48
+ @attributes_map[name] = {
49
+ attr_or_block: block_given? ? block : name,
50
+ options: options,
51
+ }
52
+ end
53
+ private :add_attribute
54
+
55
+ def add_to_one_association(name, options = {}, &block)
56
+ options[:include_links] = options.fetch(:include_links, true)
57
+ options[:include_data] = options.fetch(:include_data, false)
58
+ @to_one_associations ||= {}
59
+ @to_one_associations[name] = {
60
+ attr_or_block: block_given? ? block : name,
61
+ options: options,
62
+ }
63
+ end
64
+ private :add_to_one_association
65
+
66
+ def add_to_many_association(name, options = {}, &block)
67
+ options[:include_links] = options.fetch(:include_links, true)
68
+ options[:include_data] = options.fetch(:include_data, false)
69
+ @to_many_associations ||= {}
70
+ @to_many_associations[name] = {
71
+ attr_or_block: block_given? ? block : name,
72
+ options: options,
73
+ }
74
+ end
75
+ private :add_to_many_association
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,589 @@
1
+ require 'set'
2
+ require 'active_support/inflector'
3
+ require 'active_support/notifications'
4
+
5
+ module ForestAdmin
6
+ module JSONAPI
7
+ module Serializer
8
+ def self.included(target)
9
+ target.send(:include, InstanceMethods)
10
+ target.extend ClassMethods
11
+ target.class_eval do
12
+ include ForestAdmin::JSONAPI::Attributes
13
+ end
14
+ end
15
+
16
+ module ClassMethods
17
+ def serialize(object, options = {})
18
+ # Since this is being called on the class directly and not the module, override the
19
+ # serializer option to be the current class.
20
+ options[:serializer] = self
21
+
22
+ ForestAdmin::JSONAPI::Serializer.serialize(object, options)
23
+ end
24
+ end
25
+
26
+ module InstanceMethods
27
+ @@class_names = {}
28
+ @@formatted_attribute_names = {}
29
+ @@unformatted_attribute_names = {}
30
+
31
+ attr_accessor :object
32
+ attr_accessor :context
33
+ attr_accessor :base_url
34
+
35
+ def initialize(object, options = {})
36
+ @object = object
37
+ @options = options
38
+ @context = options[:context] || {}
39
+ @base_url = options[:base_url]
40
+
41
+ # Internal serializer options, not exposed through attr_accessor. No touchie.
42
+ @_fields = options[:fields] || {}
43
+ @_include_linkages = options[:include_linkages] || []
44
+ end
45
+
46
+ # Override this to customize the JSON:API "id" for this object.
47
+ # Always return a string from this method to conform with the JSON:API spec.
48
+ def id
49
+ object.id.to_s
50
+ end
51
+
52
+ # Override this to customize the JSON:API "type" for this object.
53
+ # By default, the type is the object's class name lowercased, pluralized, and dasherized,
54
+ # per the spec naming recommendations: http://jsonapi.org/recommendations/#naming
55
+ # For example, 'MyApp::LongCommment' will become the 'long-comments' type.
56
+ def type
57
+ class_name = object.class.name
58
+ @@class_names[class_name] ||= class_name.demodulize.tableize.dasherize.freeze
59
+ end
60
+
61
+ # Override this to customize how attribute names are formatted.
62
+ # By default, attribute names are dasherized per the spec naming recommendations:
63
+ # http://jsonapi.org/recommendations/#naming
64
+ def format_name(attribute_name)
65
+ attr_name = attribute_name.to_s
66
+ @@formatted_attribute_names[attr_name] ||= attr_name.dasherize.freeze
67
+ end
68
+
69
+ # The opposite of format_name. Override this if you override format_name.
70
+ def unformat_name(attribute_name)
71
+ attr_name = attribute_name.to_s
72
+ @@unformatted_attribute_names[attr_name] ||= attr_name.underscore.freeze
73
+ end
74
+
75
+ # Override this to provide resource-object jsonapi object containing the version in use.
76
+ # http://jsonapi.org/format/#document-jsonapi-object
77
+ def jsonapi
78
+ end
79
+
80
+ # Override this to provide resource-object metadata.
81
+ # http://jsonapi.org/format/#document-structure-resource-objects
82
+ def meta
83
+ end
84
+
85
+ # Override this to set a base URL (http://example.com) for all links. No trailing slash.
86
+ def base_url
87
+ @base_url
88
+ end
89
+
90
+ def self_link
91
+ "#{base_url}/#{type}/#{id}"
92
+ end
93
+
94
+ def relationship_self_link(attribute_name)
95
+ "#{self_link}/relationships/#{format_name(attribute_name)}"
96
+ end
97
+
98
+ def relationship_related_link(attribute_name)
99
+ "#{self_link}/#{format_name(attribute_name)}"
100
+ end
101
+
102
+ def links
103
+ data = {}
104
+ data['self'] = self_link if self_link
105
+ data
106
+ end
107
+
108
+ def relationships
109
+ data = {}
110
+ # Merge in data for has_one relationships.
111
+ has_one_relationships.each do |attribute_name, attr_data|
112
+ formatted_attribute_name = format_name(attribute_name)
113
+
114
+ data[formatted_attribute_name] = {}
115
+
116
+ if attr_data[:options][:include_links]
117
+ links_self = relationship_self_link(attribute_name)
118
+ links_related = relationship_related_link(attribute_name)
119
+ data[formatted_attribute_name]['links'] = {} if links_self || links_related
120
+ data[formatted_attribute_name]['links']['self'] = links_self if links_self
121
+ data[formatted_attribute_name]['links']['related'] = links_related if links_related
122
+ end
123
+
124
+ if @_include_linkages.include?(formatted_attribute_name) || attr_data[:options][:include_data]
125
+ object = has_one_relationship(attribute_name, attr_data)
126
+ if object.nil?
127
+ # Spec: Resource linkage MUST be represented as one of the following:
128
+ # - null for empty to-one relationships.
129
+ # http://jsonapi.org/format/#document-structure-resource-relationships
130
+ data[formatted_attribute_name]['data'] = nil
131
+ else
132
+ related_object_serializer = ForestAdmin::JSONAPI::Serializer.find_serializer(object, @options)
133
+ data[formatted_attribute_name]['data'] = {
134
+ 'type' => related_object_serializer.type.to_s,
135
+ 'id' => related_object_serializer.id.to_s,
136
+ }
137
+ end
138
+ end
139
+ end
140
+
141
+ # Merge in data for has_many relationships.
142
+ has_many_relationships.each do |attribute_name, attr_data|
143
+ formatted_attribute_name = format_name(attribute_name)
144
+
145
+ data[formatted_attribute_name] = {}
146
+
147
+ if attr_data[:options][:include_links]
148
+ links_self = relationship_self_link(attribute_name)
149
+ links_related = relationship_related_link(attribute_name)
150
+ data[formatted_attribute_name]['links'] = {} if links_self || links_related
151
+ data[formatted_attribute_name]['links']['self'] = links_self if links_self
152
+ data[formatted_attribute_name]['links']['related'] = links_related if links_related
153
+ end
154
+
155
+ # Spec: Resource linkage MUST be represented as one of the following:
156
+ # - an empty array ([]) for empty to-many relationships.
157
+ # - an array of linkage objects for non-empty to-many relationships.
158
+ # http://jsonapi.org/format/#document-structure-resource-relationships
159
+ if @_include_linkages.include?(formatted_attribute_name) || attr_data[:options][:include_data]
160
+ data[formatted_attribute_name]['data'] = []
161
+ objects = has_many_relationship(attribute_name, attr_data) || []
162
+ objects.each do |obj|
163
+ related_object_serializer = ForestAdmin::JSONAPI::Serializer.find_serializer(obj, @options)
164
+ data[formatted_attribute_name]['data'] << {
165
+ 'type' => related_object_serializer.type.to_s,
166
+ 'id' => related_object_serializer.id.to_s,
167
+ }
168
+ end
169
+ end
170
+ end
171
+ data
172
+ end
173
+
174
+ def attributes
175
+ return {} if self.class.attributes_map.nil?
176
+ attributes = {}
177
+ self.class.attributes_map.each do |attribute_name, attr_data|
178
+ next if !should_include_attr?(attribute_name, attr_data)
179
+ value = evaluate_attr_or_block(attribute_name, attr_data[:attr_or_block])
180
+ attributes[format_name(attribute_name)] = value
181
+ end
182
+ attributes
183
+ end
184
+
185
+ def has_one_relationships
186
+ return {} if self.class.to_one_associations.nil?
187
+ data = {}
188
+ self.class.to_one_associations.each do |attribute_name, attr_data|
189
+ next if !should_include_attr?(attribute_name, attr_data)
190
+ data[attribute_name] = attr_data
191
+ end
192
+ data
193
+ end
194
+
195
+ def has_one_relationship(attribute_name, attr_data)
196
+ evaluate_attr_or_block(attribute_name, attr_data[:attr_or_block])
197
+ end
198
+
199
+ def has_many_relationships
200
+ return {} if self.class.to_many_associations.nil?
201
+ data = {}
202
+ self.class.to_many_associations.each do |attribute_name, attr_data|
203
+ next if !should_include_attr?(attribute_name, attr_data)
204
+ data[attribute_name] = attr_data
205
+ end
206
+ data
207
+ end
208
+
209
+ def has_many_relationship(attribute_name, attr_data)
210
+ evaluate_attr_or_block(attribute_name, attr_data[:attr_or_block])
211
+ end
212
+
213
+ def should_include_attr?(attribute_name, attr_data)
214
+ # Allow "if: :show_title?" and "unless: :hide_title?" attribute options.
215
+ if_method_name = attr_data[:options][:if]
216
+ unless_method_name = attr_data[:options][:unless]
217
+ formatted_attribute_name = format_name(attribute_name).to_sym
218
+ show_attr = true
219
+ show_attr &&= send(if_method_name) if if_method_name
220
+ show_attr &&= !send(unless_method_name) if unless_method_name
221
+ show_attr &&= @_fields[type.to_s].include?(formatted_attribute_name) if @_fields[type.to_s]
222
+ show_attr
223
+ end
224
+ protected :should_include_attr?
225
+
226
+ def evaluate_attr_or_block(attribute_name, attr_or_block)
227
+ if attr_or_block.is_a?(Proc)
228
+ # A custom block was given, call it to get the value.
229
+ instance_eval(&attr_or_block)
230
+ else
231
+ # Default behavior, call a method by the name of the attribute.
232
+ object.send(attr_or_block)
233
+ end
234
+ end
235
+ protected :evaluate_attr_or_block
236
+ end
237
+
238
+ def self.find_serializer_class_name(object, options)
239
+ if options[:namespace]
240
+ "#{options[:namespace]}::#{object.class.name}Serializer"
241
+ else
242
+ "#{object.class.name}Serializer"
243
+ end
244
+ end
245
+
246
+ def self.find_serializer_class(object, options)
247
+ if object.respond_to?(:jsonapi_serializer_class_name)
248
+ object.jsonapi_serializer_class_name.to_s.constantize
249
+ else
250
+ memoized_serializer_classes[object.class][options[:namespace]] ||=
251
+ find_serializer_class_name(object, options).constantize
252
+ end
253
+ end
254
+
255
+ def self.memoized_serializer_classes
256
+ @memoized_serializer_classes ||= {}.tap do |hash|
257
+ hash.default_proc = ->(hash, key) { hash[key] = {} }
258
+ end
259
+ end
260
+
261
+ def self.find_serializer(object, options)
262
+ find_serializer_class(object, options).new(object, options)
263
+ end
264
+
265
+ def self.serialize(objects, options = {})
266
+ # Normalize option strings to symbols.
267
+ options[:is_collection] = options.delete('is_collection') || options[:is_collection] || false
268
+ options[:include] = options.delete('include') || options[:include]
269
+ options[:serializer] = options.delete('serializer') || options[:serializer]
270
+ options[:namespace] = options.delete('namespace') || options[:namespace]
271
+ options[:context] = options.delete('context') || options[:context] || {}
272
+ options[:skip_collection_check] = options.delete('skip_collection_check') || options[:skip_collection_check] || false
273
+ options[:base_url] = options.delete('base_url') || options[:base_url]
274
+ options[:jsonapi] = options.delete('jsonapi') || options[:jsonapi]
275
+ options[:meta] = options.delete('meta') || options[:meta]
276
+ options[:links] = options.delete('links') || options[:links]
277
+ options[:fields] = options.delete('fields') || options[:fields] || {}
278
+
279
+ # Deprecated: use serialize_errors method instead
280
+ options[:errors] = options.delete('errors') || options[:errors]
281
+
282
+ # Normalize includes.
283
+ includes = options[:include]
284
+ includes = (includes.is_a?(String) ? includes.split(',') : includes).uniq if includes
285
+
286
+ # Transforms input so that the comma-separated fields are separate symbols in array
287
+ # and keys are stringified
288
+ # Example:
289
+ # {posts: 'title,author,long_comments'} => {'posts' => [:title, :author, :long_comments]}
290
+ # {posts: ['title', 'author', 'long_comments'} => {'posts' => [:title, :author, :long_comments]}
291
+ #
292
+ fields = {}
293
+ # Normalize fields to accept a comma-separated string or an array of strings.
294
+ options[:fields].map do |type, whitelisted_fields|
295
+ whitelisted_fields = [whitelisted_fields] if whitelisted_fields.is_a?(Symbol)
296
+ whitelisted_fields = whitelisted_fields.split(',') if whitelisted_fields.is_a?(String)
297
+ fields[type.to_s] = whitelisted_fields.map(&:to_sym)
298
+ end
299
+
300
+ # An internal-only structure that is passed through serializers as they are created.
301
+ passthrough_options = {
302
+ context: options[:context],
303
+ serializer: options[:serializer],
304
+ namespace: options[:namespace],
305
+ include: includes,
306
+ fields: fields,
307
+ base_url: options[:base_url]
308
+ }
309
+
310
+ if !options[:skip_collection_check] && options[:is_collection] && !objects.respond_to?(:each)
311
+ raise ForestAdmin::JSONAPI::Serializer::AmbiguousCollectionError.new(
312
+ 'Attempted to serialize a single object as a collection.')
313
+ end
314
+
315
+ # Automatically include linkage data for any relation that is also included.
316
+ if includes
317
+ include_linkages = includes.map { |key| key.to_s.split('.').first }
318
+ passthrough_options[:include_linkages] = include_linkages
319
+ end
320
+
321
+ # Spec: Primary data MUST be either:
322
+ # - a single resource object or null, for requests that target single resources.
323
+ # - an array of resource objects or an empty array ([]), for resource collections.
324
+ # http://jsonapi.org/format/#document-structure-top-level
325
+ if options[:is_collection] && !objects.any?
326
+ primary_data = []
327
+ elsif !options[:is_collection] && objects.nil?
328
+ primary_data = nil
329
+ elsif options[:is_collection]
330
+ # Have object collection.
331
+ primary_data = serialize_primary_multi(objects, passthrough_options)
332
+ else
333
+ # Duck-typing check for a collection being passed without is_collection true.
334
+ # We always must be told if serializing a collection because the JSON:API spec distinguishes
335
+ # how to serialize null single resources vs. empty collections.
336
+ if !options[:skip_collection_check] && objects.respond_to?(:each)
337
+ raise ForestAdmin::JSONAPI::Serializer::AmbiguousCollectionError.new(
338
+ 'Must provide `is_collection: true` to `serialize` when serializing collections.')
339
+ end
340
+ # Have single object.
341
+ primary_data = serialize_primary(objects, passthrough_options)
342
+ end
343
+ result = {
344
+ 'data' => primary_data,
345
+ }
346
+ result['jsonapi'] = options[:jsonapi] if options[:jsonapi]
347
+ result['meta'] = options[:meta] if options[:meta]
348
+ result['links'] = options[:links] if options[:links]
349
+ result['errors'] = options[:errors] if options[:errors]
350
+
351
+ # If 'include' relationships are given, recursively find and include each object.
352
+ if includes
353
+ relationship_data = {}
354
+ inclusion_tree = parse_relationship_paths(includes)
355
+
356
+ # Given all the primary objects (either the single root object or collection of objects),
357
+ # recursively search and find related associations that were specified as includes.
358
+ objects = options[:is_collection] ? objects.to_a : [objects]
359
+ objects.compact.each do |obj|
360
+ # Use the mutability of relationship_data as the return datastructure to take advantage
361
+ # of the internal special merging logic.
362
+ find_recursive_relationships(obj, inclusion_tree, relationship_data, passthrough_options)
363
+ end
364
+
365
+ result['included'] = relationship_data.map do |_, data|
366
+ included_passthrough_options = {}
367
+ included_passthrough_options[:base_url] = passthrough_options[:base_url]
368
+ included_passthrough_options[:context] = passthrough_options[:context]
369
+ included_passthrough_options[:fields] = passthrough_options[:fields]
370
+ included_passthrough_options[:serializer] = find_serializer_class(data[:object], options)
371
+ included_passthrough_options[:namespace] = passthrough_options[:namespace]
372
+ included_passthrough_options[:include_linkages] = data[:include_linkages]
373
+ serialize_primary(data[:object], included_passthrough_options)
374
+ end
375
+ end
376
+ result
377
+ end
378
+
379
+ def self.serialize_errors(raw_errors)
380
+ if is_activemodel_errors?(raw_errors)
381
+ {'errors' => activemodel_errors(raw_errors)}
382
+ else
383
+ {'errors' => raw_errors}
384
+ end
385
+ end
386
+
387
+ def self.activemodel_errors(raw_errors)
388
+ raw_errors.to_hash(full_messages: true).inject([]) do |result, (attribute, messages)|
389
+ result += messages.map { |message| single_error(attribute.to_s, message) }
390
+ end
391
+ end
392
+
393
+ def self.is_activemodel_errors?(raw_errors)
394
+ raw_errors.respond_to?(:to_hash) && raw_errors.respond_to?(:full_messages)
395
+ end
396
+
397
+ def self.single_error(attribute, message)
398
+ {
399
+ 'source' => {
400
+ 'pointer' => "/data/attributes/#{attribute.dasherize}"
401
+ },
402
+ 'detail' => message
403
+ }
404
+ end
405
+
406
+ def self.serialize_primary(object, options = {})
407
+ ActiveSupport::Notifications.instrument(
408
+ 'render.jsonapi_serializers.serialize_primary',
409
+ {class_name: object&.class&.name}
410
+ ) do
411
+ serializer_class = options[:serializer] || find_serializer_class(object, options)
412
+
413
+ # Spec: Primary data MUST be either:
414
+ # - a single resource object or null, for requests that target single resources.
415
+ # http://jsonapi.org/format/#document-structure-top-level
416
+ return if object.nil?
417
+
418
+ serializer = serializer_class.new(object, options)
419
+ data = {
420
+ 'type' => serializer.type.to_s,
421
+ }
422
+
423
+ # "The id member is not required when the resource object originates at the client
424
+ # and represents a new resource to be created on the server."
425
+ # http://jsonapi.org/format/#document-resource-objects
426
+ # We'll assume that if the id is blank, it means the resource is to be created.
427
+ data['id'] = serializer.id.to_s if serializer.id && !serializer.id.empty?
428
+
429
+ # Merge in optional top-level members if they are non-nil.
430
+ # http://jsonapi.org/format/#document-structure-resource-objects
431
+ # Call the methods once now to avoid calling them twice when evaluating the if's below.
432
+ attributes = serializer.attributes
433
+ links = serializer.links
434
+ relationships = serializer.relationships
435
+ jsonapi = serializer.jsonapi
436
+ meta = serializer.meta
437
+ data['attributes'] = attributes if !attributes.empty?
438
+ data['links'] = links if !links.empty?
439
+ data['relationships'] = relationships if !relationships.empty?
440
+ data['jsonapi'] = jsonapi if !jsonapi.nil?
441
+ data['meta'] = meta if !meta.nil?
442
+ data
443
+ end
444
+ end
445
+ class << self; protected :serialize_primary; end
446
+
447
+ def self.serialize_primary_multi(objects, options = {})
448
+ # Spec: Primary data MUST be either:
449
+ # - an array of resource objects or an empty array ([]), for resource collections.
450
+ # http://jsonapi.org/format/#document-structure-top-level
451
+ return [] if !objects.any?
452
+
453
+ objects.map { |obj| serialize_primary(obj, options) }
454
+ end
455
+ class << self; protected :serialize_primary_multi; end
456
+
457
+ # Recursively find object relationships and returns a tree of related objects.
458
+ # Example return:
459
+ # {
460
+ # ['comments', '1'] => {object: <Comment>, include_linkages: ['author']},
461
+ # ['users', '1'] => {object: <User>, include_linkages: []},
462
+ # ['users', '2'] => {object: <User>, include_linkages: []},
463
+ # }
464
+ def self.find_recursive_relationships(root_object, root_inclusion_tree, results, options)
465
+ ActiveSupport::Notifications.instrument(
466
+ 'render.jsonapi_serializers.find_recursive_relationships',
467
+ {class_name: root_object.class.name},
468
+ ) do
469
+ root_inclusion_tree.each do |attribute_name, child_inclusion_tree|
470
+ # Skip the sentinal value, but we need to preserve it for siblings.
471
+ next if attribute_name == :_include
472
+
473
+ serializer = ForestAdmin::JSONAPI::Serializer.find_serializer(root_object, options)
474
+ unformatted_attr_name = serializer.unformat_name(attribute_name).to_sym
475
+
476
+ # We know the name of this relationship, but we don't know where it is stored internally.
477
+ # Check if it is a has_one or has_many relationship.
478
+ object = nil
479
+ is_collection = false
480
+ is_valid_attr = false
481
+ if serializer.has_one_relationships.has_key?(unformatted_attr_name)
482
+ is_valid_attr = true
483
+ attr_data = serializer.has_one_relationships[unformatted_attr_name]
484
+ object = serializer.has_one_relationship(unformatted_attr_name, attr_data)
485
+ elsif serializer.has_many_relationships.has_key?(unformatted_attr_name)
486
+ is_valid_attr = true
487
+ is_collection = true
488
+ attr_data = serializer.has_many_relationships[unformatted_attr_name]
489
+ object = serializer.has_many_relationship(unformatted_attr_name, attr_data)
490
+ end
491
+
492
+ if !is_valid_attr
493
+ raise ForestAdmin::JSONAPI::Serializer::InvalidIncludeError.new(
494
+ "'#{attribute_name}' is not a valid include.")
495
+ end
496
+
497
+ if attribute_name != serializer.format_name(attribute_name)
498
+ expected_name = serializer.format_name(attribute_name)
499
+
500
+ raise ForestAdmin::JSONAPI::Serializer::InvalidIncludeError.new(
501
+ "'#{attribute_name}' is not a valid include. Did you mean '#{expected_name}' ?"
502
+ )
503
+ end
504
+
505
+ # We're finding relationships for compound documents, so skip anything that doesn't exist.
506
+ next if object.nil?
507
+
508
+ # Full linkage: a request for comments.author MUST automatically include comments
509
+ # in the response.
510
+ objects = is_collection ? object : [object]
511
+ if child_inclusion_tree[:_include] == true
512
+ # Include the current level objects if the _include attribute exists.
513
+ # If it is not set, that indicates that this is an inner path and not a leaf and will
514
+ # be followed by the recursion below.
515
+ objects.each do |obj|
516
+ obj_serializer = ForestAdmin::JSONAPI::Serializer.find_serializer(obj, options)
517
+ # Use keys of ['posts', '1'] for the results to enforce uniqueness.
518
+ # Spec: A compound document MUST NOT include more than one resource object for each
519
+ # type and id pair.
520
+ # http://jsonapi.org/format/#document-structure-compound-documents
521
+ key = [obj_serializer.type, obj_serializer.id]
522
+
523
+ # This is special: we know at this level if a child of this parent will also been
524
+ # included in the compound document, so we can compute exactly what linkages should
525
+ # be included by the object at this level. This satisfies this part of the spec:
526
+ #
527
+ # Spec: Resource linkage in a compound document allows a client to link together
528
+ # all of the included resource objects without having to GET any relationship URLs.
529
+ # http://jsonapi.org/format/#document-structure-resource-relationships
530
+ current_child_includes = []
531
+ inclusion_names = child_inclusion_tree.keys.reject { |k| k == :_include }
532
+ inclusion_names.each do |inclusion_name|
533
+ if child_inclusion_tree[inclusion_name][:_include]
534
+ current_child_includes << inclusion_name
535
+ end
536
+ end
537
+
538
+ # Special merge: we might see this object multiple times in the course of recursion,
539
+ # so merge the include_linkages each time we see it to load all the relevant linkages.
540
+ current_child_includes += results[key] && results[key][:include_linkages] || []
541
+ current_child_includes.uniq!
542
+ results[key] = {object: obj, include_linkages: current_child_includes}
543
+ end
544
+ end
545
+
546
+ # Recurse deeper!
547
+ if !child_inclusion_tree.empty?
548
+ # For each object we just loaded, find all deeper recursive relationships.
549
+ objects.each do |obj|
550
+ find_recursive_relationships(obj, child_inclusion_tree, results, options)
551
+ end
552
+ end
553
+ end
554
+ end
555
+ nil
556
+ end
557
+ class << self; protected :find_recursive_relationships; end
558
+
559
+ # Takes a list of relationship paths and returns a hash as deep as the given paths.
560
+ # The _include: true is a sentinal value that specifies whether the parent level should
561
+ # be included.
562
+ #
563
+ # Example:
564
+ # Given: ['author', 'comments', 'comments.user']
565
+ # Returns: {
566
+ # 'author' => {_include: true},
567
+ # 'comments' => {_include: true, 'user' => {_include: true}},
568
+ # }
569
+ def self.parse_relationship_paths(paths)
570
+ relationships = {}
571
+ paths.each { |path| merge_relationship_path(path, relationships) }
572
+ relationships
573
+ end
574
+ class << self; protected :parse_relationship_paths; end
575
+
576
+ def self.merge_relationship_path(path, data)
577
+ parts = path.split('.', 2)
578
+ current_level = parts[0].strip
579
+ data[current_level] ||= {_include: true}
580
+
581
+ if parts.length == 2
582
+ # Need to recurse more.
583
+ merge_relationship_path(parts[1], data[current_level])
584
+ end
585
+ end
586
+ class << self; protected :merge_relationship_path; end
587
+ end
588
+ end
589
+ end