joyful_jsonapi 0.0.1

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,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