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.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.rspec +3 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +868 -0
- data/Rakefile +2 -0
- data/forestadmin-jsonapi-serializers.gemspec +28 -0
- data/lib/jsonapi-serializers.rb +13 -0
- data/lib/jsonapi-serializers/attributes.rb +79 -0
- data/lib/jsonapi-serializers/serializer.rb +589 -0
- data/lib/jsonapi-serializers/version.rb +7 -0
- data/spec/serializer_spec.rb +1337 -0
- data/spec/spec_helper.rb +19 -0
- data/spec/support/factory.rb +44 -0
- data/spec/support/serializers.rb +264 -0
- metadata +160 -0
data/Rakefile
ADDED
@@ -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
|