jsonapi-resources 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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