forestadmin-jsonapi-serializers 2.0.0.pre.beta.2

Sign up to get free protection for your applications and to get access to all the features.
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