joyful_jsonapi 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ ::ActiveRecord::Associations::Builder::HasOne.class_eval do
4
+ # Based on
5
+ # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/associations/builder/collection_association.rb#L50
6
+ # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/associations/builder/singular_association.rb#L11
7
+ def self.define_accessors(mixin, reflection)
8
+ super
9
+ name = reflection.name
10
+ mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
11
+ def #{name}_id
12
+ # if an attribute is already defined with this methods name we should just use it
13
+ return read_attribute(__method__) if has_attribute?(__method__)
14
+ association(:#{name}).reader.try(:id)
15
+ end
16
+ CODE
17
+ end
18
+ end
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Generates a serializer for the given model.
3
+
4
+ Example:
5
+ rails generate serializer Movie
6
+
7
+ This will create:
8
+ app/serializers/movie_serializer.rb
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/base'
4
+
5
+ class SerializerGenerator < Rails::Generators::NamedBase
6
+ source_root File.expand_path('templates', __dir__)
7
+
8
+ argument :attributes, type: :array, default: [], banner: 'field field'
9
+
10
+ def create_serializer_file
11
+ template 'serializer.rb.tt', File.join('app', 'serializers', class_path, "#{file_name}_serializer.rb")
12
+ end
13
+
14
+ private
15
+
16
+ def attributes_names
17
+ attributes.map { |a| a.name.to_sym.inspect }
18
+ end
19
+ end
@@ -0,0 +1,6 @@
1
+ <% module_namespacing do -%>
2
+ class <%= class_name %>Serializer
3
+ include JoyfulJsonapi::ObjectSerializer
4
+ attributes <%= attributes_names.join(", ") %>
5
+ end
6
+ <% end -%>
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JoyfulJsonapi
4
+ require 'joyful_jsonapi/object_serializer'
5
+ require 'joyful_jsonapi/error_serializer'
6
+ if defined?(::Rails)
7
+ require 'joyful_jsonapi/railtie'
8
+ elsif defined?(::ActiveRecord)
9
+ require 'extensions/has_one'
10
+ end
11
+ end
@@ -0,0 +1,29 @@
1
+ module JoyfulJsonapi
2
+ class Attribute
3
+ attr_reader :key, :method, :conditional_proc
4
+
5
+ def initialize(key:, method:, options: {})
6
+ @key = key
7
+ @method = method
8
+ @conditional_proc = options[:if]
9
+ end
10
+
11
+ def serialize(record, serialization_params, output_hash)
12
+ if include_attribute?(record, serialization_params)
13
+ output_hash[key] = if method.is_a?(Proc)
14
+ method.arity.abs == 1 ? method.call(record) : method.call(record, serialization_params)
15
+ else
16
+ record.public_send(method)
17
+ end
18
+ end
19
+ end
20
+
21
+ def include_attribute?(record, serialization_params)
22
+ if conditional_proc.present?
23
+ conditional_proc.call(record, serialization_params)
24
+ else
25
+ true
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,41 @@
1
+
2
+ module JoyfulJsonapi
3
+ class ErrorSerializer
4
+
5
+ def initialize(model)
6
+ @model = model
7
+ @model.valid?
8
+ end
9
+
10
+ def serializable_hash
11
+ { errors: errors_for(@model) }
12
+ end
13
+
14
+ def serialized_json(options = nil)
15
+ serializable_hash.to_json(options)
16
+ end
17
+
18
+ private
19
+
20
+ def errors_for(resource)
21
+ resource.errors.messages.flat_map do |field, errors|
22
+ build_hashes_for(field, errors)
23
+ end
24
+ end
25
+
26
+ def build_hashes_for(field, errors)
27
+ errors.map do |error_message|
28
+ build_hash_for(field, error_message)
29
+ end.flatten
30
+ end
31
+
32
+ def build_hash_for(field, error_message)
33
+ {}.tap do |hash|
34
+ hash[:status] = "422"
35
+ hash[:source] = { pointer: "/data/attributes/#{field}" }
36
+ hash[:detail] = error_message
37
+ end
38
+ end
39
+
40
+ end
41
+ end
@@ -0,0 +1,2 @@
1
+ require 'joyful_jsonapi/instrumentation/serializable_hash'
2
+ require 'joyful_jsonapi/instrumentation/serialized_json'
@@ -0,0 +1,15 @@
1
+ require 'active_support/notifications'
2
+
3
+ module JoyfulJsonapi
4
+ module ObjectSerializer
5
+
6
+ alias_method :serializable_hash_without_instrumentation, :serializable_hash
7
+
8
+ def serializable_hash
9
+ ActiveSupport::Notifications.instrument(SERIALIZABLE_HASH_NOTIFICATION, { name: self.class.name }) do
10
+ serializable_hash_without_instrumentation
11
+ end
12
+ end
13
+
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ require 'active_support/notifications'
2
+
3
+ module JoyfulJsonapi
4
+ module ObjectSerializer
5
+
6
+ alias_method :serialized_json_without_instrumentation, :serialized_json
7
+
8
+ def serialized_json
9
+ ActiveSupport::Notifications.instrument(SERIALIZED_JSON_NOTIFICATION, { name: self.class.name }) do
10
+ serialized_json_without_instrumentation
11
+ end
12
+ end
13
+
14
+ end
15
+ end
@@ -0,0 +1,2 @@
1
+ require 'joyful_jsonapi/instrumentation/skylight/normalizers/serializable_hash'
2
+ require 'joyful_jsonapi/instrumentation/skylight/normalizers/serialized_json'
@@ -0,0 +1,7 @@
1
+ require 'skylight'
2
+
3
+ SKYLIGHT_NORMALIZER_BASE_CLASS = begin
4
+ ::Skylight::Core::Normalizers::Normalizer
5
+ rescue NameError
6
+ ::Skylight::Normalizers::Normalizer
7
+ end
@@ -0,0 +1,22 @@
1
+ require 'joyful_jsonapi/instrumentation/skylight/normalizers/base'
2
+ require 'joyful_jsonapi/instrumentation/serializable_hash'
3
+
4
+ module JoyfulJsonapi
5
+ module Instrumentation
6
+ module Skylight
7
+ module Normalizers
8
+ class SerializableHash < SKYLIGHT_NORMALIZER_BASE_CLASS
9
+
10
+ register JoyfulJsonapi::ObjectSerializer::SERIALIZABLE_HASH_NOTIFICATION
11
+
12
+ CAT = "view.#{JoyfulJsonapi::ObjectSerializer::SERIALIZABLE_HASH_NOTIFICATION}".freeze
13
+
14
+ def normalize(trace, name, payload)
15
+ [ CAT, payload[:name], nil ]
16
+ end
17
+
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ require 'joyful_jsonapi/instrumentation/skylight/normalizers/base'
2
+ require 'joyful_jsonapi/instrumentation/serializable_hash'
3
+
4
+ module JoyfulJsonapi
5
+ module Instrumentation
6
+ module Skylight
7
+ module Normalizers
8
+ class SerializedJson < SKYLIGHT_NORMALIZER_BASE_CLASS
9
+
10
+ register JoyfulJsonapi::ObjectSerializer::SERIALIZED_JSON_NOTIFICATION
11
+
12
+ CAT = "view.#{JoyfulJsonapi::ObjectSerializer::SERIALIZED_JSON_NOTIFICATION}".freeze
13
+
14
+ def normalize(trace, name, payload)
15
+ [ CAT, payload[:name], nil ]
16
+ end
17
+
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,18 @@
1
+ module JoyfulJsonapi
2
+ class Link
3
+ attr_reader :key, :method
4
+
5
+ def initialize(key:, method:)
6
+ @key = key
7
+ @method = method
8
+ end
9
+
10
+ def serialize(record, serialization_params, output_hash)
11
+ output_hash[key] = if method.is_a?(Proc)
12
+ method.arity == 1 ? method.call(record) : method.call(record, serialization_params)
13
+ else
14
+ record.public_send(method)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ # Usage:
6
+ # class Movie
7
+ # def to_json(payload)
8
+ # JoyfulJsonapi::MultiToJson.to_json(payload)
9
+ # end
10
+ # end
11
+ module JoyfulJsonapi
12
+ module MultiToJson
13
+ # Result object pattern is from https://johnnunemaker.com/resilience-in-ruby/
14
+ # e.g. https://github.com/github/github-ds/blob/fbda5389711edfb4c10b6c6bad19311dfcb1bac1/lib/github/result.rb
15
+ class Result
16
+ def initialize(*rescued_exceptions)
17
+ @rescued_exceptions = if rescued_exceptions.empty?
18
+ [StandardError]
19
+ else
20
+ rescued_exceptions
21
+ end
22
+
23
+ @value = yield
24
+ @error = nil
25
+ rescue *rescued_exceptions => e
26
+ @error = e
27
+ end
28
+
29
+ def ok?
30
+ @error.nil?
31
+ end
32
+
33
+ def value!
34
+ if ok?
35
+ @value
36
+ else
37
+ raise @error
38
+ end
39
+ end
40
+
41
+ def rescue
42
+ return self if ok?
43
+
44
+ Result.new(*@rescued_exceptions) { yield(@error) }
45
+ end
46
+ end
47
+
48
+ def self.logger(device=nil)
49
+ return @logger = Logger.new(device) if device
50
+ @logger ||= Logger.new(IO::NULL)
51
+ end
52
+
53
+ # Encoder-compatible with default MultiJSON adapters and defaults
54
+ def self.to_json_method
55
+ encode_method = String.new(%(def _fast_to_json(object)\n ))
56
+ encode_method << Result.new(LoadError) {
57
+ require 'oj'
58
+ %(::Oj.dump(object, mode: :compat, time_format: :ruby, use_to_json: true))
59
+ }.rescue {
60
+ require 'yajl'
61
+ %(::Yajl::Encoder.encode(object))
62
+ }.rescue {
63
+ require 'jrjackson' unless defined?(::JrJackson)
64
+ %(::JrJackson::Json.dump(object))
65
+ }.rescue {
66
+ require 'json'
67
+ %(JSON.fast_generate(object, create_additions: false, quirks_mode: true))
68
+ }.rescue {
69
+ require 'gson'
70
+ %(::Gson::Encoder.new({}).encode(object))
71
+ }.rescue {
72
+ require 'active_support/json/encoding'
73
+ %(::ActiveSupport::JSON.encode(object))
74
+ }.rescue {
75
+ warn "No JSON encoder found. Falling back to `object.to_json`"
76
+ %(object.to_json)
77
+ }.value!
78
+ encode_method << "\nend"
79
+ end
80
+
81
+ def self.to_json(object)
82
+ _fast_to_json(object)
83
+ rescue NameError
84
+ define_to_json(JoyfulJsonapi::MultiToJson)
85
+ _fast_to_json(object)
86
+ end
87
+
88
+ def self.define_to_json(receiver)
89
+ cl = caller_locations[0]
90
+ method_body = to_json_method
91
+ logger.debug { "Defining #{receiver}._fast_to_json as #{method_body.inspect}" }
92
+ receiver.instance_eval method_body, cl.absolute_path, cl.lineno
93
+ end
94
+
95
+ def self.reset_to_json!
96
+ undef :_fast_to_json if method_defined?(:_fast_to_json)
97
+ logger.debug { "Undefining #{receiver}._fast_to_json" }
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/time'
4
+ require 'active_support/json'
5
+ require 'active_support/concern'
6
+ require 'active_support/inflector'
7
+ require 'active_support/core_ext/numeric/time'
8
+ require 'joyful_jsonapi/attribute'
9
+ require 'joyful_jsonapi/relationship'
10
+ require 'joyful_jsonapi/link'
11
+ require 'joyful_jsonapi/serialization_core'
12
+
13
+ module JoyfulJsonapi
14
+ module ObjectSerializer
15
+ extend ActiveSupport::Concern
16
+ include SerializationCore
17
+
18
+ SERIALIZABLE_HASH_NOTIFICATION = 'render.joyful_jsonapi.serializable_hash'
19
+ SERIALIZED_JSON_NOTIFICATION = 'render.joyful_jsonapi.serialized_json'
20
+
21
+ included do
22
+ # Set record_type based on the name of the serializer class
23
+ set_type(reflected_record_type) if reflected_record_type
24
+ end
25
+
26
+ def initialize(resource, options = {})
27
+ process_options(options)
28
+
29
+ @resource = resource
30
+ end
31
+
32
+ def serializable_hash
33
+ return hash_for_collection if is_collection?(@resource, @is_collection)
34
+
35
+ hash_for_one_record
36
+ end
37
+ alias_method :to_hash, :serializable_hash
38
+
39
+ def hash_for_one_record
40
+ serializable_hash = { data: nil }
41
+ serializable_hash[:meta] = @meta if @meta.present?
42
+ serializable_hash[:links] = @links if @links.present?
43
+
44
+ return serializable_hash unless @resource
45
+
46
+ serializable_hash[:data] = self.class.record_hash(@resource, @fieldsets[self.class.record_type.to_sym], @params)
47
+ serializable_hash[:included] = self.class.get_included_records(@resource, @includes, @known_included_objects, @fieldsets, @params) if @includes.present?
48
+ serializable_hash
49
+ end
50
+
51
+ def hash_for_collection
52
+ serializable_hash = {}
53
+
54
+ data = []
55
+ included = []
56
+ fieldset = @fieldsets[self.class.record_type.to_sym]
57
+ @resource.each do |record|
58
+ data << self.class.record_hash(record, fieldset, @params)
59
+ included.concat self.class.get_included_records(record, @includes, @known_included_objects, @fieldsets, @params) if @includes.present?
60
+ end
61
+
62
+ serializable_hash[:data] = data
63
+ serializable_hash[:included] = included if @includes.present?
64
+ serializable_hash[:meta] = @meta if @meta.present?
65
+ serializable_hash[:links] = @links if @links.present?
66
+ serializable_hash
67
+ end
68
+
69
+ def serialized_json
70
+ ActiveSupport::JSON.encode(serializable_hash)
71
+ end
72
+
73
+ private
74
+
75
+ def process_options(options)
76
+ @fieldsets = deep_symbolize(options[:fields].presence || {})
77
+ @params = {}
78
+
79
+ return if options.blank?
80
+
81
+ @known_included_objects = {}
82
+ @meta = options[:meta]
83
+ @links = options[:links]
84
+ @is_collection = options[:is_collection]
85
+ @params = options[:params] || {}
86
+ raise ArgumentError.new("`params` option passed to serializer must be a hash") unless @params.is_a?(Hash)
87
+
88
+ if options[:include].present?
89
+ @includes = options[:include].delete_if(&:blank?).map(&:to_sym)
90
+ self.class.validate_includes!(@includes)
91
+ end
92
+ end
93
+
94
+ def deep_symbolize(collection)
95
+ if collection.is_a? Hash
96
+ Hash[collection.map do |k, v|
97
+ [k.to_sym, deep_symbolize(v)]
98
+ end]
99
+ elsif collection.is_a? Array
100
+ collection.map { |i| deep_symbolize(i) }
101
+ else
102
+ collection.to_sym
103
+ end
104
+ end
105
+
106
+ def is_collection?(resource, force_is_collection = nil)
107
+ return force_is_collection unless force_is_collection.nil?
108
+
109
+ resource.respond_to?(:size) && !resource.respond_to?(:each_pair)
110
+ end
111
+
112
+ class_methods do
113
+
114
+ def inherited(subclass)
115
+ super(subclass)
116
+ subclass.attributes_to_serialize = attributes_to_serialize.dup if attributes_to_serialize.present?
117
+ subclass.relationships_to_serialize = relationships_to_serialize.dup if relationships_to_serialize.present?
118
+ subclass.cachable_relationships_to_serialize = cachable_relationships_to_serialize.dup if cachable_relationships_to_serialize.present?
119
+ subclass.uncachable_relationships_to_serialize = uncachable_relationships_to_serialize.dup if uncachable_relationships_to_serialize.present?
120
+ subclass.transform_method = transform_method
121
+ subclass.cache_length = cache_length
122
+ subclass.race_condition_ttl = race_condition_ttl
123
+ subclass.data_links = data_links.dup if data_links.present?
124
+ subclass.cached = cached
125
+ subclass.set_type(subclass.reflected_record_type) if subclass.reflected_record_type
126
+ subclass.meta_to_serialize = meta_to_serialize
127
+ end
128
+
129
+ def reflected_record_type
130
+ return @reflected_record_type if defined?(@reflected_record_type)
131
+
132
+ @reflected_record_type ||= begin
133
+ if self.name.end_with?('Serializer')
134
+ self.name.split('::').last.chomp('Serializer').underscore.to_sym
135
+ end
136
+ end
137
+ end
138
+
139
+ def set_key_transform(transform_name)
140
+ mapping = {
141
+ camel: :camelize,
142
+ camel_lower: [:camelize, :lower],
143
+ dash: :dasherize,
144
+ underscore: :underscore
145
+ }
146
+ self.transform_method = mapping[transform_name.to_sym]
147
+
148
+ # ensure that the record type is correctly transformed
149
+ if record_type
150
+ set_type(record_type)
151
+ elsif reflected_record_type
152
+ set_type(reflected_record_type)
153
+ end
154
+ end
155
+
156
+ def run_key_transform(input)
157
+ if self.transform_method.present?
158
+ input.to_s.send(*@transform_method).to_sym
159
+ else
160
+ input.to_sym
161
+ end
162
+ end
163
+
164
+ def use_hyphen
165
+ warn('DEPRECATION WARNING: use_hyphen is deprecated and will be removed from joyful_jsonapi 2.0 use (set_key_transform :dash) instead')
166
+ set_key_transform :dash
167
+ end
168
+
169
+ def set_type(type_name)
170
+ self.record_type = run_key_transform(type_name)
171
+ end
172
+
173
+ def set_id(id_name = nil, &block)
174
+ self.record_id = block || id_name
175
+ end
176
+
177
+ def cache_options(cache_options)
178
+ self.cached = cache_options[:enabled] || false
179
+ self.cache_length = cache_options[:cache_length] || 5.minutes
180
+ self.race_condition_ttl = cache_options[:race_condition_ttl] || 5.seconds
181
+ end
182
+
183
+ def attributes(*attributes_list, &block)
184
+ attributes_list = attributes_list.first if attributes_list.first.class.is_a?(Array)
185
+ options = attributes_list.last.is_a?(Hash) ? attributes_list.pop : {}
186
+ self.attributes_to_serialize = {} if self.attributes_to_serialize.nil?
187
+
188
+ attributes_list.each do |attr_name|
189
+ method_name = attr_name
190
+ key = run_key_transform(method_name)
191
+ attributes_to_serialize[key] = Attribute.new(
192
+ key: key,
193
+ method: block || method_name,
194
+ options: options
195
+ )
196
+ end
197
+ end
198
+
199
+ alias_method :attribute, :attributes
200
+
201
+ def add_relationship(relationship)
202
+ self.relationships_to_serialize = {} if relationships_to_serialize.nil?
203
+ self.cachable_relationships_to_serialize = {} if cachable_relationships_to_serialize.nil?
204
+ self.uncachable_relationships_to_serialize = {} if uncachable_relationships_to_serialize.nil?
205
+
206
+ if !relationship.cached
207
+ self.uncachable_relationships_to_serialize[relationship.name] = relationship
208
+ else
209
+ self.cachable_relationships_to_serialize[relationship.name] = relationship
210
+ end
211
+ self.relationships_to_serialize[relationship.name] = relationship
212
+ end
213
+
214
+ def has_many(relationship_name, options = {}, &block)
215
+ relationship = create_relationship(relationship_name, :has_many, options, block)
216
+ add_relationship(relationship)
217
+ end
218
+
219
+ def has_one(relationship_name, options = {}, &block)
220
+ relationship = create_relationship(relationship_name, :has_one, options, block)
221
+ add_relationship(relationship)
222
+ end
223
+
224
+ def belongs_to(relationship_name, options = {}, &block)
225
+ relationship = create_relationship(relationship_name, :belongs_to, options, block)
226
+ add_relationship(relationship)
227
+ end
228
+
229
+ def meta(&block)
230
+ self.meta_to_serialize = block
231
+ end
232
+
233
+ def create_relationship(base_key, relationship_type, options, block)
234
+ name = base_key.to_sym
235
+ if relationship_type == :has_many
236
+ base_serialization_key = base_key.to_s.singularize
237
+ base_key_sym = base_serialization_key.to_sym
238
+ id_postfix = '_ids'
239
+ else
240
+ base_serialization_key = base_key
241
+ base_key_sym = name
242
+ id_postfix = '_id'
243
+ end
244
+ Relationship.new(
245
+ key: options[:key] || run_key_transform(base_key),
246
+ name: name,
247
+ id_method_name: compute_id_method_name(
248
+ options[:id_method_name],
249
+ "#{base_serialization_key}#{id_postfix}".to_sym,
250
+ block
251
+ ),
252
+ record_type: options[:record_type] || run_key_transform(base_key_sym),
253
+ object_method_name: options[:object_method_name] || name,
254
+ object_block: block,
255
+ serializer: compute_serializer_name(options[:serializer] || base_key_sym),
256
+ relationship_type: relationship_type,
257
+ cached: options[:cached],
258
+ polymorphic: fetch_polymorphic_option(options),
259
+ conditional_proc: options[:if],
260
+ transform_method: @transform_method,
261
+ links: options[:links],
262
+ lazy_load_data: options[:lazy_load_data]
263
+ )
264
+ end
265
+
266
+ def compute_id_method_name(custom_id_method_name, id_method_name_from_relationship, block)
267
+ if block.present?
268
+ custom_id_method_name || :id
269
+ else
270
+ custom_id_method_name || id_method_name_from_relationship
271
+ end
272
+ end
273
+
274
+ def compute_serializer_name(serializer_key)
275
+ return serializer_key unless serializer_key.is_a? Symbol
276
+ namespace = self.name.gsub(/()?\w+Serializer$/, '')
277
+ serializer_name = serializer_key.to_s.classify + 'Serializer'
278
+ (namespace + serializer_name).to_sym
279
+ end
280
+
281
+ def fetch_polymorphic_option(options)
282
+ option = options[:polymorphic]
283
+ return false unless option.present?
284
+ return option if option.respond_to? :keys
285
+ {}
286
+ end
287
+
288
+ def link(link_name, link_method_name = nil, &block)
289
+ self.data_links = {} if self.data_links.nil?
290
+ link_method_name = link_name if link_method_name.nil?
291
+ key = run_key_transform(link_name)
292
+
293
+ self.data_links[key] = Link.new(
294
+ key: key,
295
+ method: block || link_method_name
296
+ )
297
+ end
298
+
299
+ def validate_includes!(includes)
300
+ return if includes.blank?
301
+
302
+ includes.detect do |include_item|
303
+ klass = self
304
+ parse_include_item(include_item).each do |parsed_include|
305
+ relationships_to_serialize = klass.relationships_to_serialize || {}
306
+ relationship_to_include = relationships_to_serialize[parsed_include]
307
+ raise ArgumentError, "#{parsed_include} is not specified as a relationship on #{klass.name}" unless relationship_to_include
308
+ klass = relationship_to_include.serializer.to_s.constantize unless relationship_to_include.polymorphic.is_a?(Hash)
309
+ end
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end