jsonapi-serializers 0.1.0

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.
@@ -0,0 +1,435 @@
1
+ require 'set'
2
+ require 'active_support/inflector'
3
+
4
+ module JSONAPI
5
+ module Serializer
6
+ def self.included(target)
7
+ target.send(:include, InstanceMethods)
8
+ target.extend ClassMethods
9
+ target.class_eval do
10
+ include JSONAPI::Attributes
11
+ end
12
+ end
13
+
14
+ module ClassMethods
15
+ def serialize(object, options = {})
16
+ # Since this is being called on the class directly and not the module, override the
17
+ # serializer option to be the current class.
18
+ options[:serializer] = self
19
+
20
+ JSONAPI::Serializer.serialize(object, options)
21
+ end
22
+ end
23
+
24
+ module InstanceMethods
25
+ attr_accessor :object
26
+ attr_accessor :context
27
+
28
+ def initialize(object, options = {})
29
+ @object = object
30
+ @context = options[:context] || {}
31
+
32
+ # Internal serializer options, not exposed through attr_accessor. No touchie.
33
+ @_include_linkages = options[:include_linkages] || []
34
+ end
35
+
36
+ # Override this to customize the JSON:API "id" for this object.
37
+ # Always return a string from this method to conform with the JSON:API spec.
38
+ def id
39
+ object.id.to_s
40
+ end
41
+
42
+ # Override this to customize the JSON:API "type" for this object.
43
+ # By default, the type is the object's class name lowercased, pluralized, and dasherized,
44
+ # per the spec naming recommendations: http://jsonapi.org/recommendations/#naming
45
+ # For example, 'MyApp::LongCommment' will become the 'long-comments' type.
46
+ def type
47
+ object.class.name.demodulize.tableize.dasherize
48
+ end
49
+
50
+ # Override this to customize how attribute names are formatted.
51
+ # By default, attribute names are dasherized per the spec naming recommendations:
52
+ # http://jsonapi.org/recommendations/#naming
53
+ def format_name(attribute_name)
54
+ attribute_name.to_s.dasherize
55
+ end
56
+
57
+ # The opposite of format_name. Override this if you override format_name.
58
+ def unformat_name(attribute_name)
59
+ attribute_name.to_s.underscore
60
+ end
61
+
62
+ # Override this to provide resource-object metadata.
63
+ # http://jsonapi.org/format/#document-structure-resource-objects
64
+ def meta
65
+ end
66
+
67
+ def self_link
68
+ "/#{type}/#{id}"
69
+ end
70
+
71
+ def relationship_self_link(attribute_name)
72
+ "#{self_link}/links/#{format_name(attribute_name)}"
73
+ end
74
+
75
+ def relationship_related_link(attribute_name)
76
+ "#{self_link}/#{format_name(attribute_name)}"
77
+ end
78
+
79
+ def links
80
+ data = {}
81
+ data.merge!({'self' => self_link}) if self_link
82
+
83
+ # Merge in data for has_one relationships.
84
+ has_one_relationships.each do |attribute_name, object|
85
+ formatted_attribute_name = format_name(attribute_name)
86
+ data[formatted_attribute_name] = {
87
+ 'self' => relationship_self_link(attribute_name),
88
+ 'related' => relationship_related_link(attribute_name),
89
+ }
90
+ if @_include_linkages.include?(formatted_attribute_name)
91
+ if object.nil?
92
+ # Spec: Resource linkage MUST be represented as one of the following:
93
+ # - null for empty to-one relationships.
94
+ # http://jsonapi.org/format/#document-structure-resource-relationships
95
+ data[formatted_attribute_name].merge!({'linkage' => nil})
96
+ else
97
+ related_object_serializer = JSONAPI::Serializer.find_serializer(object)
98
+ data[formatted_attribute_name].merge!({
99
+ 'linkage' => {
100
+ 'type' => related_object_serializer.type.to_s,
101
+ 'id' => related_object_serializer.id.to_s,
102
+ },
103
+ })
104
+ end
105
+ end
106
+ end
107
+
108
+ # Merge in data for has_many relationships.
109
+ has_many_relationships.each do |attribute_name, objects|
110
+ formatted_attribute_name = format_name(attribute_name)
111
+ data[formatted_attribute_name] = {
112
+ 'self' => relationship_self_link(attribute_name),
113
+ 'related' => relationship_related_link(attribute_name),
114
+ }
115
+ # Spec: Resource linkage MUST be represented as one of the following:
116
+ # - an empty array ([]) for empty to-many relationships.
117
+ # - an array of linkage objects for non-empty to-many relationships.
118
+ # http://jsonapi.org/format/#document-structure-resource-relationships
119
+ if @_include_linkages.include?(formatted_attribute_name)
120
+ data[formatted_attribute_name].merge!({'linkage' => []})
121
+ objects = objects || []
122
+ objects.each do |obj|
123
+ related_object_serializer = JSONAPI::Serializer.find_serializer(obj)
124
+ data[formatted_attribute_name]['linkage'] << {
125
+ 'type' => related_object_serializer.type.to_s,
126
+ 'id' => related_object_serializer.id.to_s,
127
+ }
128
+ end
129
+ end
130
+ end
131
+ data
132
+ end
133
+
134
+ def attributes
135
+ attributes = {}
136
+ self.class.attributes_map.each do |attribute_name, attr_data|
137
+ next if !should_include_attr?(attr_data[:options][:if], attr_data[:options][:unless])
138
+ value = evaluate_attr_or_block(attribute_name, attr_data[:attr_or_block])
139
+ attributes[format_name(attribute_name)] = value
140
+ end
141
+ attributes
142
+ end
143
+
144
+ def has_one_relationships
145
+ return {} if self.class.to_one_associations.nil?
146
+ data = {}
147
+ self.class.to_one_associations.each do |attribute_name, attr_data|
148
+ next if !should_include_attr?(attr_data[:options][:if], attr_data[:options][:unless])
149
+ data[attribute_name] = evaluate_attr_or_block(attribute_name, attr_data[:attr_or_block])
150
+ end
151
+ data
152
+ end
153
+
154
+ def has_many_relationships
155
+ return {} if self.class.to_many_associations.nil?
156
+ data = {}
157
+ self.class.to_many_associations.each do |attribute_name, attr_data|
158
+ next if !should_include_attr?(attr_data[:options][:if], attr_data[:options][:unless])
159
+ data[attribute_name] = evaluate_attr_or_block(attribute_name, attr_data[:attr_or_block])
160
+ end
161
+ data
162
+ end
163
+
164
+ def should_include_attr?(if_method_name, unless_method_name)
165
+ # Allow "if: :show_title?" and "unless: :hide_title?" attribute options.
166
+ show_attr = true
167
+ show_attr &&= send(if_method_name) if if_method_name
168
+ show_attr &&= !send(unless_method_name) if unless_method_name
169
+ show_attr
170
+ end
171
+ protected :should_include_attr?
172
+
173
+ def evaluate_attr_or_block(attribute_name, attr_or_block)
174
+ if attr_or_block.is_a?(Proc)
175
+ # A custom block was given, call it to get the value.
176
+ instance_eval(&attr_or_block)
177
+ else
178
+ # Default behavior, call a method by the name of the attribute.
179
+ object.send(attr_or_block)
180
+ end
181
+ end
182
+ protected :evaluate_attr_or_block
183
+ end
184
+
185
+ def self.find_serializer_class_name(object)
186
+ "#{object.class.name}Serializer"
187
+ end
188
+
189
+ def self.find_serializer_class(object)
190
+ class_name = find_serializer_class_name(object)
191
+ class_name.constantize
192
+ end
193
+
194
+ def self.find_serializer(object)
195
+ find_serializer_class(object).new(object)
196
+ end
197
+
198
+ def self.serialize(objects, options = {})
199
+ # Normalize option strings to symbols.
200
+ options[:is_collection] = options.delete('is_collection') || options[:is_collection] || false
201
+ options[:include] = options.delete('include') || options[:include]
202
+ options[:serializer] = options.delete('serializer') || options[:serializer]
203
+ options[:context] = options.delete('context') || options[:context] || {}
204
+
205
+ # Normalize includes.
206
+ includes = options[:include]
207
+ includes = (includes.is_a?(String) ? includes.split(',') : includes).uniq if includes
208
+
209
+ # An internal-only structure that is passed through serializers as they are created.
210
+ passthrough_options = {
211
+ context: options[:context],
212
+ serializer: options[:serializer],
213
+ include: includes
214
+ }
215
+
216
+ if options[:is_collection] && !objects.respond_to?(:each)
217
+ raise JSONAPI::Serializer::AmbiguousCollectionError.new(
218
+ 'Attempted to serialize a single object as a collection.')
219
+ end
220
+
221
+ # Automatically include linkage data for any relation that is also included.
222
+ if includes
223
+ direct_children_includes = includes.reject { |key| key.include?('.') }
224
+ passthrough_options[:include_linkages] = direct_children_includes
225
+ end
226
+
227
+ # Spec: Primary data MUST be either:
228
+ # - a single resource object or null, for requests that target single resources.
229
+ # - an array of resource objects or an empty array ([]), for resource collections.
230
+ # http://jsonapi.org/format/#document-structure-top-level
231
+ if options[:is_collection] && !objects.any?
232
+ primary_data = []
233
+ elsif !options[:is_collection] && objects.nil?
234
+ primary_data = nil
235
+ elsif options[:is_collection]
236
+ # Have object collection.
237
+ passthrough_options[:serializer] ||= find_serializer_class(objects.first)
238
+ primary_data = serialize_primary_multi(objects, passthrough_options)
239
+ else
240
+ # Duck-typing check for a collection being passed without is_collection true.
241
+ # We always must be told if serializing a collection because the JSON:API spec distinguishes
242
+ # how to serialize null single resources vs. empty collections.
243
+ if objects.respond_to?(:each)
244
+ raise JSONAPI::Serializer::AmbiguousCollectionError.new(
245
+ 'Must provide `is_collection: true` to `serialize` when serializing collections.')
246
+ end
247
+ # Have single object.
248
+ passthrough_options[:serializer] ||= find_serializer_class(objects)
249
+ primary_data = serialize_primary(objects, passthrough_options)
250
+ end
251
+ result = {
252
+ 'data' => primary_data,
253
+ }
254
+
255
+ # If 'include' relationships are given, recursively find and include each object.
256
+ if includes
257
+ relationship_data = {}
258
+ inclusion_tree = parse_relationship_paths(includes)
259
+
260
+ # Given all the primary objects (either the single root object or collection of objects),
261
+ # recursively search and find related associations that were specified as includes.
262
+ objects = options[:is_collection] ? objects.to_a : [objects]
263
+ serializers = []
264
+ objects.compact.each do |obj|
265
+ # Use the mutability of relationship_data as the return datastructure to take advantage
266
+ # of the internal special merging logic.
267
+ find_recursive_relationships(obj, inclusion_tree, relationship_data)
268
+ end
269
+
270
+ result['included'] = relationship_data.map do |_, data|
271
+ passthrough_options = {}
272
+ passthrough_options[:serializer] = find_serializer_class(data[:object])
273
+ passthrough_options[:include_linkages] = data[:include_linkages]
274
+ serialize_primary(data[:object], passthrough_options)
275
+ end
276
+ end
277
+ result
278
+ end
279
+
280
+ def self.serialize_primary(object, options = {})
281
+ serializer_class = options.fetch(:serializer)
282
+
283
+ # Spec: Primary data MUST be either:
284
+ # - a single resource object or null, for requests that target single resources.
285
+ # http://jsonapi.org/format/#document-structure-top-level
286
+ return if object.nil?
287
+
288
+ serializer = serializer_class.new(object, options)
289
+ data = {
290
+ 'id' => serializer.id.to_s,
291
+ 'type' => serializer.type.to_s,
292
+ 'attributes' => serializer.attributes,
293
+ }
294
+
295
+ # Merge in optional top-level members if they are non-nil.
296
+ # http://jsonapi.org/format/#document-structure-resource-objects
297
+ data.merge!({'attributes' => serializer.attributes}) if !serializer.attributes.nil?
298
+ data.merge!({'links' => serializer.links}) if !serializer.links.nil?
299
+ data.merge!({'meta' => serializer.meta}) if !serializer.meta.nil?
300
+ data
301
+ end
302
+ class << self; protected :serialize_primary; end
303
+
304
+ def self.serialize_primary_multi(objects, options = {})
305
+ # Spec: Primary data MUST be either:
306
+ # - an array of resource objects or an empty array ([]), for resource collections.
307
+ # http://jsonapi.org/format/#document-structure-top-level
308
+ return [] if !objects.any?
309
+
310
+ objects.map { |obj| serialize_primary(obj, options) }
311
+ end
312
+ class << self; protected :serialize_primary_multi; end
313
+
314
+ # Recursively find object relationships and returns a tree of related objects.
315
+ # Example return:
316
+ # {
317
+ # ['comments', '1'] => {object: <Comment>, include_linkages: ['author']},
318
+ # ['users', '1'] => {object: <User>, include_linkages: []},
319
+ # ['users', '2'] => {object: <User>, include_linkages: []},
320
+ # }
321
+ def self.find_recursive_relationships(root_object, root_inclusion_tree, results)
322
+ root_inclusion_tree.each do |attribute_name, child_inclusion_tree|
323
+ # Skip the sentinal value, but we need to preserve it for siblings.
324
+ next if attribute_name == :_include
325
+
326
+ serializer = JSONAPI::Serializer.find_serializer(root_object)
327
+ unformatted_attr_name = serializer.unformat_name(attribute_name).to_sym
328
+
329
+ # We know the name of this relationship, but we don't know where it is stored internally.
330
+ # Check if it is a has_one or has_many relationship.
331
+ object = nil
332
+ is_collection = false
333
+ is_valid_attr = false
334
+ if serializer.has_one_relationships.has_key?(unformatted_attr_name)
335
+ is_valid_attr = true
336
+ object = serializer.has_one_relationships[unformatted_attr_name]
337
+ elsif serializer.has_many_relationships.has_key?(unformatted_attr_name)
338
+ is_valid_attr = true
339
+ is_collection = true
340
+ object = serializer.has_many_relationships[unformatted_attr_name]
341
+ end
342
+ if !is_valid_attr
343
+ raise JSONAPI::Serializer::InvalidIncludeError.new(
344
+ "'#{attribute_name}' is not a valid include.")
345
+ end
346
+
347
+ # We're finding relationships for compound documents, so skip anything that doesn't exist.
348
+ next if object.nil?
349
+
350
+ # We only include parent values if the sential value _include is set. This satifies the
351
+ # spec note: A request for comments.author should not automatically also include comments
352
+ # in the response. This can happen if the client already has the comments locally, and now
353
+ # wants to fetch the associated authors without fetching the comments again.
354
+ # http://jsonapi.org/format/#fetching-includes
355
+ objects = is_collection ? object : [object]
356
+ if child_inclusion_tree[:_include] == true
357
+ # Include the current level objects if the _include attribute exists.
358
+ # If it is not set, that indicates that this is an inner path and not a leaf and will
359
+ # be followed by the recursion below.
360
+ objects.each do |obj|
361
+ obj_serializer = JSONAPI::Serializer.find_serializer(obj)
362
+ # Use keys of ['posts', '1'] for the results to enforce uniqueness.
363
+ # Spec: A compound document MUST NOT include more than one resource object for each
364
+ # type and id pair.
365
+ # http://jsonapi.org/format/#document-structure-compound-documents
366
+ key = [obj_serializer.type, obj_serializer.id]
367
+
368
+ # This is special: we know at this level if a child of this parent will also been
369
+ # included in the compound document, so we can compute exactly what linkages should
370
+ # be included by the object at this level. This satisfies this part of the spec:
371
+ #
372
+ # Spec: Resource linkage in a compound document allows a client to link together
373
+ # all of the included resource objects without having to GET any relationship URLs.
374
+ # http://jsonapi.org/format/#document-structure-resource-relationships
375
+ current_child_includes = []
376
+ inclusion_names = child_inclusion_tree.keys.reject { |k| k == :_include }
377
+ inclusion_names.each do |inclusion_name|
378
+ if child_inclusion_tree[inclusion_name][:_include]
379
+ current_child_includes << inclusion_name
380
+ end
381
+ end
382
+
383
+ # Special merge: we might see this object multiple times in the course of recursion,
384
+ # so merge the include_linkages each time we see it to load all the relevant linkages.
385
+ current_child_includes += results[key] && results[key][:include_linkages] || []
386
+ current_child_includes.uniq!
387
+ results[key] = {object: obj, include_linkages: current_child_includes}
388
+ end
389
+ end
390
+
391
+ # Recurse deeper!
392
+ if !child_inclusion_tree.empty?
393
+ # For each object we just loaded, find all deeper recursive relationships.
394
+ objects.each do |obj|
395
+ find_recursive_relationships(obj, child_inclusion_tree, results)
396
+ end
397
+ end
398
+ end
399
+ nil
400
+ end
401
+ class << self; protected :find_recursive_relationships; end
402
+
403
+ # Takes a list of relationship paths and returns a hash as deep as the given paths.
404
+ # The _include: true is a sentinal value that specifies whether the parent level should
405
+ # be included.
406
+ #
407
+ # Example:
408
+ # Given: ['author', 'comments', 'comments.user']
409
+ # Returns: {
410
+ # 'author' => {_include: true},
411
+ # 'comments' => {_include: true, 'user' => {_include: true}},
412
+ # }
413
+ def self.parse_relationship_paths(paths)
414
+ relationships = {}
415
+ paths.each { |path| merge_relationship_path(path, relationships) }
416
+ relationships
417
+ end
418
+ class << self; protected :parse_relationship_paths; end
419
+
420
+ def self.merge_relationship_path(path, data)
421
+ parts = path.split('.', 2)
422
+ current_level = parts[0].strip
423
+ data[current_level] ||= {}
424
+
425
+ if parts.length == 1
426
+ # Leaf node.
427
+ data[current_level].merge!({_include: true})
428
+ elsif parts.length == 2
429
+ # Need to recurse more.
430
+ merge_relationship_path(parts[1], data[current_level])
431
+ end
432
+ end
433
+ class << self; protected :merge_relationship_path; end
434
+ end
435
+ end
@@ -0,0 +1,5 @@
1
+ module JSONAPI
2
+ module Serializer
3
+ VERSION = '0.1.0'
4
+ end
5
+ end
@@ -0,0 +1,11 @@
1
+ require "jsonapi-serializers/version"
2
+ require "jsonapi-serializers/attributes"
3
+ require "jsonapi-serializers/serializer"
4
+
5
+ module JSONAPI
6
+ module Serializer
7
+ class Error < Exception; end
8
+ class AmbiguousCollectionError < Error; end
9
+ class InvalidIncludeError < Error; end
10
+ end
11
+ end