jsonapi-resources 0.1.1 → 0.2.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.
@@ -72,12 +72,6 @@ module JSONAPI
72
72
  end
73
73
  end
74
74
 
75
- def create_has_one_link(association_type, association_key_value)
76
- change :create_has_one_link do
77
- _create_has_one_link(association_type, association_key_value)
78
- end
79
- end
80
-
81
75
  def replace_has_one_link(association_type, association_key_value)
82
76
  change :replace_has_one_link do
83
77
  _replace_has_one_link(association_type, association_key_value)
@@ -148,19 +142,6 @@ module JSONAPI
148
142
  @save_needed = true
149
143
  end
150
144
 
151
- def _create_has_one_link(association_type, association_key_value)
152
- association = self.class._associations[association_type]
153
-
154
- # ToDo: Add option to skip relations that already exist instead of returning an error?
155
- relation = @model.send("#{association.foreign_key}")
156
- if relation.nil?
157
- send("#{association.foreign_key}=", association_key_value)
158
- else
159
- raise JSONAPI::Exceptions::HasOneRelationExists.new
160
- end
161
- @save_needed = true
162
- end
163
-
164
145
  def _replace_has_one_link(association_type, association_key_value)
165
146
  association = self.class._associations[association_type]
166
147
 
@@ -215,6 +196,8 @@ module JSONAPI
215
196
  type = base.name.demodulize.sub(/Resource$/, '').underscore
216
197
  base._type = type.pluralize.to_sym
217
198
 
199
+ base.attribute :id, format: :id
200
+
218
201
  check_reserved_resource_name(base._type, base.name)
219
202
 
220
203
  # If eager loading is on this is how all the resource types are setup
@@ -223,7 +206,7 @@ module JSONAPI
223
206
  @@resource_types[base._type] ||= base.name.demodulize
224
207
  end
225
208
 
226
- attr_accessor :_attributes, :_associations, :_allowed_filters , :_type
209
+ attr_accessor :_attributes, :_associations, :_allowed_filters , :_type, :_paginator
227
210
 
228
211
  def create(context)
229
212
  self.new(self.create_model, context)
@@ -251,6 +234,7 @@ module JSONAPI
251
234
  def attribute(attr, options = {})
252
235
  check_reserved_attribute_name(attr)
253
236
 
237
+ @_attributes ||= {}
254
238
  @_attributes[attr] = options
255
239
  define_method attr do
256
240
  @model.send(attr)
@@ -298,7 +282,7 @@ module JSONAPI
298
282
 
299
283
  # Override in your resource to filter the updateable keys
300
284
  def updateable_fields(context = nil)
301
- _updateable_associations | _attributes.keys
285
+ _updateable_associations | _attributes.keys - [_primary_key]
302
286
  end
303
287
 
304
288
  # Override in your resource to filter the createable keys
@@ -315,22 +299,27 @@ module JSONAPI
315
299
  _associations.keys | _attributes.keys
316
300
  end
317
301
 
318
- def apply_filter(records, filter, value)
319
- records.where(filter => value)
302
+ def apply_pagination(records, paginator)
303
+ if paginator
304
+ records = paginator.apply(records)
305
+ end
306
+ records
320
307
  end
321
308
 
322
- # Override this method if you have more complex requirements than this basic find method provides
323
- def find(filters, options = {})
324
- context = options[:context]
325
- sort_params = options.fetch(:sort_params) { [] }
326
- includes = []
309
+ def apply_sort(records, order_options)
310
+ records.order(order_options)
311
+ end
327
312
 
328
- records = records(options)
313
+ def apply_filter(records, filter, value)
314
+ records.where(filter => value)
315
+ end
329
316
 
317
+ def apply_filters(records, filters)
318
+ required_includes = []
330
319
  filters.each do |filter, value|
331
320
  if _associations.include?(filter)
332
321
  if _associations[filter].is_a?(JSONAPI::Association::HasMany)
333
- includes.push(filter)
322
+ required_includes.push(filter)
334
323
  records = apply_filter(records, "#{filter}.#{_associations[filter].primary_key}", value)
335
324
  else
336
325
  records = apply_filter(records, "#{_associations[filter].foreign_key}", value)
@@ -339,10 +328,22 @@ module JSONAPI
339
328
  records = apply_filter(records, filter, value)
340
329
  end
341
330
  end
331
+ records.includes(required_includes)
332
+ end
333
+
334
+ # Override this method if you have more complex requirements than this basic find method provides
335
+ def find(filters, options = {})
336
+ context = options[:context]
337
+ sort_criteria = options.fetch(:sort_criteria) { [] }
342
338
 
343
339
  resources = []
344
- order_options = construct_order_options(sort_params)
345
- records.order(order_options).includes(includes).each do |model|
340
+
341
+ records = records(options)
342
+ records = apply_filters(records, filters)
343
+ records = apply_sort(records, construct_order_options(sort_criteria))
344
+ records = apply_pagination(records, options[:paginator])
345
+
346
+ records.each do |model|
346
347
  resources.push self.new(model, context)
347
348
  end
348
349
 
@@ -480,6 +481,14 @@ module JSONAPI
480
481
  return class_name
481
482
  end
482
483
 
484
+ def _paginator
485
+ @_paginator ||= JSONAPI.configuration.default_paginator
486
+ end
487
+
488
+ def paginator(paginator)
489
+ @_paginator = paginator
490
+ end
491
+
483
492
  # :nocov:
484
493
  if RUBY_VERSION >= '2.0'
485
494
  def _model_class
@@ -500,8 +509,13 @@ module JSONAPI
500
509
  @module_path ||= self.name =~ /::[^:]+\Z/ ? ($`.freeze.gsub('::', '/') + '/').downcase : ''
501
510
  end
502
511
 
503
- private
512
+ def construct_order_options(sort_params)
513
+ sort_params.each_with_object({}) { |sort, order_hash|
514
+ order_hash[sort[:field]] = sort[:direction]
515
+ }
516
+ end
504
517
 
518
+ private
505
519
  def check_reserved_resource_name(type, name)
506
520
  if [:ids, :types, :hrefs, :links].include?(type)
507
521
  warn "[NAME COLLISION] `#{name}` is a reserved resource name."
@@ -551,14 +565,21 @@ module JSONAPI
551
565
  end
552
566
  end unless method_defined?(attr)
553
567
  elsif @_associations[attr].is_a?(JSONAPI::Association::HasMany)
554
- define_method attr do
568
+ define_method attr do |options = {}|
555
569
  type_name = self.class._associations[attr].type.to_s
556
570
  resource_class = self.class.resource_for(self.class.module_path + type_name)
571
+ filters = options.fetch(:filters, {})
572
+ sort_criteria = options.fetch(:sort_criteria, {})
573
+ paginator = options.fetch(:paginator, nil)
574
+
557
575
  resources = []
558
576
  if resource_class
559
- associated_models = @model.send attr
560
- associated_models.each do |associated_model|
561
- resources.push resource_class.new(associated_model, @context)
577
+ records = @model.send attr
578
+ records = self.class.apply_filters(records, filters)
579
+ records = self.class.apply_sort(records, self.class.construct_order_options(sort_criteria))
580
+ records = self.class.apply_pagination(records, paginator)
581
+ records.each do |record|
582
+ resources.push resource_class.new(record, @context)
562
583
  end
563
584
  end
564
585
  return resources
@@ -566,16 +587,6 @@ module JSONAPI
566
587
  end
567
588
  end
568
589
  end
569
-
570
- def construct_order_options(sort_params)
571
- sort_params.each_with_object({}) { |sort_key, order_hash|
572
- if sort_key.starts_with?('-')
573
- order_hash[sort_key.slice(1..-1)] = :desc
574
- else
575
- order_hash[sort_key] = :asc
576
- end
577
- }
578
- end
579
590
  end
580
591
  end
581
592
  end
@@ -18,32 +18,40 @@ module JSONAPI
18
18
  after_filter :setup_response
19
19
 
20
20
  def index
21
- render json: JSONAPI::ResourceSerializer.new(resource_klass).serialize_to_hash(
22
- resource_klass.find(resource_klass.verify_filters(@request.filters, context),
23
- context: context, sort_params: @request.sort_params),
24
- include: @request.include,
25
- fields: @request.fields,
26
- attribute_formatters: attribute_formatters,
27
- key_formatter: key_formatter)
21
+ serializer = JSONAPI::ResourceSerializer.new(resource_klass,
22
+ include: @request.include,
23
+ fields: @request.fields,
24
+ base_url: base_url,
25
+ key_formatter: key_formatter,
26
+ route_formatter: route_formatter)
27
+
28
+ resource_records = resource_klass.find(resource_klass.verify_filters(@request.filters, context),
29
+ context: context,
30
+ sort_criteria: @request.sort_criteria,
31
+ paginator: @request.paginator)
32
+
33
+ render json: serializer.serialize_to_hash(resource_records)
28
34
  rescue => e
29
35
  handle_exceptions(e)
30
36
  end
31
37
 
32
38
  def show
39
+ serializer = JSONAPI::ResourceSerializer.new(resource_klass,
40
+ include: @request.include,
41
+ fields: @request.fields,
42
+ base_url: base_url,
43
+ key_formatter: key_formatter,
44
+ route_formatter: route_formatter)
45
+
33
46
  keys = parse_key_array(params[resource_klass._primary_key])
34
47
 
35
- resources = if keys.length > 1
36
- resource_klass.find_by_keys(keys, context: context)
37
- else
38
- resource_klass.find_by_key(keys[0], context: context)
39
- end
40
-
41
- render json: JSONAPI::ResourceSerializer.new(resource_klass).serialize_to_hash(
42
- resources,
43
- include: @request.include,
44
- fields: @request.fields,
45
- attribute_formatters: attribute_formatters,
46
- key_formatter: key_formatter)
48
+ resource_records = if keys.length > 1
49
+ resource_klass.find_by_keys(keys, context: context)
50
+ else
51
+ resource_klass.find_by_key(keys[0], context: context)
52
+ end
53
+
54
+ render json: serializer.serialize_to_hash(resource_records)
47
55
  rescue => e
48
56
  handle_exceptions(e)
49
57
  end
@@ -56,7 +64,14 @@ module JSONAPI
56
64
  parent_resource = resource_klass.find_by_key(parent_key, context: context)
57
65
 
58
66
  association = resource_klass._association(association_type)
59
- render json: { key_formatter.format(association_type) => parent_resource.send(association.foreign_key)}
67
+
68
+ serializer = JSONAPI::ResourceSerializer.new(resource_klass,
69
+ fields: @request.fields,
70
+ base_url: base_url,
71
+ key_formatter: key_formatter,
72
+ route_formatter: route_formatter)
73
+
74
+ render json: serializer.serialize_to_links_hash(parent_resource, association)
60
75
  rescue => e
61
76
  # :nocov:
62
77
  handle_exceptions(e)
@@ -87,6 +102,41 @@ module JSONAPI
87
102
  process_request_operations
88
103
  end
89
104
 
105
+ def get_related_resource
106
+ association_type = params[:association]
107
+ source_resource = @request.source_klass.find_by_key(@request.source_id, context: context)
108
+
109
+ serializer = JSONAPI::ResourceSerializer.new(@request.source_klass,
110
+ include: @request.include,
111
+ fields: @request.fields,
112
+ base_url: base_url,
113
+ key_formatter: key_formatter,
114
+ route_formatter: route_formatter)
115
+
116
+ render json: serializer.serialize_to_hash(source_resource.send(association_type))
117
+ end
118
+
119
+ def get_related_resources
120
+ association_type = params[:association]
121
+ source_resource = @request.source_klass.find_by_key(@request.source_id, context: context)
122
+
123
+ related_resources = source_resource.send(association_type,
124
+ {
125
+ filters: @request.source_klass.verify_filters(@request.filters, context),
126
+ sort_criteria: @request.sort_criteria,
127
+ paginator: @request.paginator
128
+ })
129
+
130
+ serializer = JSONAPI::ResourceSerializer.new(@request.source_klass,
131
+ include: @request.include,
132
+ fields: @request.fields,
133
+ base_url: base_url,
134
+ key_formatter: key_formatter,
135
+ route_formatter: route_formatter)
136
+
137
+ render json: serializer.serialize_to_hash(related_resources)
138
+ end
139
+
90
140
  # Override this to use another operations processor
91
141
  def create_operations_processor
92
142
  JSONAPI::ActiveRecordOperationsProcessor.new
@@ -105,6 +155,10 @@ module JSONAPI
105
155
  end
106
156
  # :nocov:
107
157
 
158
+ def base_url
159
+ @base_url ||= request.protocol + request.host_with_port
160
+ end
161
+
108
162
  def resource_klass_name
109
163
  @resource_klass_name ||= "#{self.class.name.sub(/Controller$/, '').singularize}Resource"
110
164
  end
@@ -149,6 +203,7 @@ module JSONAPI
149
203
 
150
204
  # Control by setting in an initializer:
151
205
  # JSONAPI.configuration.json_key_format = :camelized_key
206
+ # JSONAPI.configuration.route = :camelized_route
152
207
  #
153
208
  # Override if you want to set a per controller key format.
154
209
  # Must return a class derived from KeyFormatter.
@@ -156,9 +211,8 @@ module JSONAPI
156
211
  JSONAPI.configuration.key_formatter
157
212
  end
158
213
 
159
- # override to setup custom attribute_formatters
160
- def attribute_formatters
161
- {}
214
+ def route_formatter
215
+ JSONAPI.configuration.route_formatter
162
216
  end
163
217
 
164
218
  def render_errors(errors)
@@ -185,13 +239,15 @@ module JSONAPI
185
239
  render status: errors[0].status, json: {errors: errors}
186
240
  else
187
241
  if results.length > 0 && resources.length > 0
242
+ serializer = JSONAPI::ResourceSerializer.new(resource_klass,
243
+ include: @request.include,
244
+ fields: @request.fields,
245
+ base_url: base_url,
246
+ key_formatter: key_formatter,
247
+ route_formatter: route_formatter)
248
+
188
249
  render status: results[0].code,
189
- json: JSONAPI::ResourceSerializer.new(resource_klass).serialize_to_hash(
190
- resources.length > 1 ? resources : resources[0],
191
- include: @request.include,
192
- fields: @request.fields,
193
- attribute_formatters: attribute_formatters,
194
- key_formatter: key_formatter)
250
+ json: serializer.serialize_to_hash(resources.length > 1 ? resources : resources[0])
195
251
  else
196
252
  render status: results[0].code, json: nil
197
253
  end
@@ -1,12 +1,7 @@
1
1
  module JSONAPI
2
2
  class ResourceSerializer
3
3
 
4
- def initialize(primary_resource_klass)
5
- @primary_resource_klass = primary_resource_klass
6
- @primary_class_name = @primary_resource_klass._type
7
- end
8
-
9
- # Converts a single resource, or an array of resources to a hash, conforming to the JSONAPI structure
4
+ # Options can include
10
5
  # include:
11
6
  # Purpose: determines which objects will be side loaded with the source objects in a linked section
12
7
  # Example: ['comments','author','comments.tags','author.posts']
@@ -14,26 +9,33 @@ module JSONAPI
14
9
  # Purpose: determines which fields are serialized for a resource type. This encompasses both attributes and
15
10
  # association ids in the links section for a resource. Fields are global for a resource type.
16
11
  # Example: { people: [:id, :email, :comments], posts: [:id, :title, :author], comments: [:id, :body, :post]}
17
- def serialize_to_hash(source, options = {})
18
- is_resource_collection = source.respond_to?(:to_ary)
12
+ # key_formatter: KeyFormatter class to override the default configuration
13
+ # base_url: a string to prepend to generated resource links
19
14
 
20
- @fields = options.fetch(:fields, {})
21
- include = options.fetch(:include, [])
15
+ def initialize(primary_resource_klass, options = {})
16
+ @primary_resource_klass = primary_resource_klass
17
+ @primary_class_name = @primary_resource_klass._type
22
18
 
19
+ @fields = options.fetch(:fields, {})
20
+ @include = options.fetch(:include, [])
23
21
  @key_formatter = options.fetch(:key_formatter, JSONAPI.configuration.key_formatter)
22
+ @route_formatter = options.fetch(:route_formatter, JSONAPI.configuration.route_formatter)
23
+ @base_url = options.fetch(:base_url, '')
24
+ end
25
+
26
+ # Converts a single resource, or an array of resources to a hash, conforming to the JSONAPI structure
27
+ def serialize_to_hash(source)
28
+ is_resource_collection = source.respond_to?(:to_ary)
24
29
 
25
30
  @linked_objects = {}
26
31
 
27
- requested_associations = parse_includes(include)
32
+ requested_associations = parse_includes(@include)
28
33
 
29
34
  process_primary(source, requested_associations)
30
35
 
31
- linked_hash = {}
36
+ linked_objects = []
32
37
  primary_objects = []
33
- @linked_objects.each do |class_name, objects|
34
- class_name = class_name.to_sym
35
-
36
- linked_objects = []
38
+ @linked_objects.each_value do |objects|
37
39
  objects.each_value do |object|
38
40
  if object[:primary]
39
41
  primary_objects.push(object[:object_hash])
@@ -41,20 +43,20 @@ module JSONAPI
41
43
  linked_objects.push(object[:object_hash])
42
44
  end
43
45
  end
44
- linked_hash[format_key(class_name)] = linked_objects unless linked_objects.empty?
45
46
  end
46
47
 
47
- if is_resource_collection
48
- primary_hash = {format_key(@primary_class_name) => primary_objects}
49
- else
50
- primary_hash = {format_key(@primary_class_name) => primary_objects[0]}
51
- end
48
+ primary_hash = {data: is_resource_collection ? primary_objects : primary_objects[0]}
52
49
 
53
- if linked_hash.size > 0
54
- primary_hash.merge({linked: linked_hash})
50
+ if linked_objects.size > 0
51
+ primary_hash[:linked] = linked_objects
55
52
  else
56
53
  primary_hash
57
54
  end
55
+ primary_hash
56
+ end
57
+
58
+ def serialize_to_links_hash(source, requested_association)
59
+ {data: link_object(source, requested_association, true)}
58
60
  end
59
61
 
60
62
  private
@@ -111,6 +113,10 @@ module JSONAPI
111
113
  def object_hash(source, requested_associations)
112
114
  obj_hash = attribute_hash(source)
113
115
  links = links_hash(source, requested_associations)
116
+
117
+ # ToDo: Do we format these required keys
118
+ obj_hash[format_key('type')] = format_value(source.class._type.to_s, :default, source)
119
+ obj_hash[format_key('id')] ||= format_value(source.id, :id, source)
114
120
  obj_hash.merge!({links: links}) unless links.empty?
115
121
  return obj_hash
116
122
  end
@@ -152,30 +158,33 @@ module JSONAPI
152
158
  field_set = Set.new(fields)
153
159
 
154
160
  included_associations = source.fetchable_fields & associations.keys
155
- associations.each_with_object({}) do |(name, association), hash|
156
- if included_associations.include? name
157
161
 
158
- if field_set.include?(name)
159
- hash[format_key(name)] = foreign_key_value(source, association)
160
- end
162
+ links = {}
163
+ links[:self] = self_href(source)
161
164
 
165
+ associations.each_with_object(links) do |(name, association), hash|
166
+ if included_associations.include? name
162
167
  ia = requested_associations.is_a?(Hash) ? requested_associations[name] : nil
163
168
 
164
- include_linked_object = ia && ia[:include]
169
+ include_linkage = ia && ia[:include]
165
170
  include_linked_children = ia && ia[:include_children]
166
171
 
172
+ if field_set.include?(name)
173
+ hash[format_key(name)] = link_object(source, association, include_linkage)
174
+ end
175
+
167
176
  type = association.type
168
177
 
169
178
  # If the object has been serialized once it will be in the related objects list,
170
179
  # but it's possible all children won't have been captured. So we must still go
171
180
  # through the associations.
172
- if include_linked_object || include_linked_children
181
+ if include_linkage || include_linked_children
173
182
  if association.is_a?(JSONAPI::Association::HasOne)
174
183
  resource = source.send(name)
175
184
  if resource
176
185
  id = resource.id
177
186
  associations_only = already_serialized?(type, id)
178
- if include_linked_object && !associations_only
187
+ if include_linkage && !associations_only
179
188
  add_linked_object(type, id, object_hash(resource, ia[:include_related]))
180
189
  elsif include_linked_children || associations_only
181
190
  links_hash(resource, ia[:include_related])
@@ -186,7 +195,7 @@ module JSONAPI
186
195
  resources.each do |resource|
187
196
  id = resource.id
188
197
  associations_only = already_serialized?(type, id)
189
- if include_linked_object && !associations_only
198
+ if include_linkage && !associations_only
190
199
  add_linked_object(type, id, object_hash(resource, ia[:include_related]))
191
200
  elsif include_linked_children || associations_only
192
201
  links_hash(resource, ia[:include_related])
@@ -198,11 +207,57 @@ module JSONAPI
198
207
  end
199
208
  end
200
209
 
210
+ def formatted_module_path(source)
211
+ source.class.name =~ /::[^:]+\Z/ ? (@route_formatter.format($`).freeze.gsub('::', '/') + '/').downcase : ''
212
+ end
213
+
214
+ def self_href(source)
215
+ "#{@base_url}/#{formatted_module_path(source)}#{@route_formatter.format(source.class._type.to_s)}/#{source.id}"
216
+ end
217
+
201
218
  def already_serialized?(type, id)
202
219
  type = format_key(type)
203
220
  return @linked_objects.key?(type) && @linked_objects[type].key?(id)
204
221
  end
205
222
 
223
+ def format_route(route)
224
+ @route_formatter.format(route.to_s)
225
+ end
226
+
227
+ def link_object_has_one(source, association)
228
+ route = association.name
229
+
230
+ link_object_hash = {}
231
+ link_object_hash[:self] = "#{self_href(source)}/links/#{format_route(route)}"
232
+ link_object_hash[:resource] = "#{self_href(source)}/#{format_route(route)}"
233
+ # ToDo: Get correct formatting figured out
234
+ link_object_hash[:type] = format_route(association.type)
235
+ link_object_hash[:id] = foreign_key_value(source, association)
236
+ link_object_hash
237
+ end
238
+
239
+ def link_object_has_many(source, association, include_linkage)
240
+ route = association.name
241
+
242
+ link_object_hash = {}
243
+ link_object_hash[:self] = "#{self_href(source)}/links/#{format_route(route)}"
244
+ link_object_hash[:resource] = "#{self_href(source)}/#{format_route(route)}"
245
+ if include_linkage
246
+ # ToDo: Get correct formatting figured out
247
+ link_object_hash[:type] = format_route(association.type)
248
+ link_object_hash[:ids] = foreign_key_value(source, association)
249
+ end
250
+ link_object_hash
251
+ end
252
+
253
+ def link_object(source, association, include_linkage = false)
254
+ if association.is_a?(JSONAPI::Association::HasOne)
255
+ link_object_has_one(source, association)
256
+ elsif association.is_a?(JSONAPI::Association::HasMany)
257
+ link_object_has_many(source, association, include_linkage)
258
+ end
259
+ end
260
+
206
261
  # Extracts the foreign key value for an association.
207
262
  def foreign_key_value(source, association)
208
263
  foreign_key = association.foreign_key