jsonapi-resources 0.3.3 → 0.4.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +274 -102
  3. data/jsonapi-resources.gemspec +1 -0
  4. data/lib/jsonapi-resources.rb +15 -0
  5. data/lib/jsonapi/active_record_operations_processor.rb +21 -10
  6. data/lib/jsonapi/acts_as_resource_controller.rb +175 -0
  7. data/lib/jsonapi/configuration.rb +11 -0
  8. data/lib/jsonapi/error_codes.rb +7 -4
  9. data/lib/jsonapi/exceptions.rb +23 -15
  10. data/lib/jsonapi/formatter.rb +5 -5
  11. data/lib/jsonapi/include_directives.rb +67 -0
  12. data/lib/jsonapi/operation.rb +185 -65
  13. data/lib/jsonapi/operation_result.rb +38 -5
  14. data/lib/jsonapi/operation_results.rb +33 -0
  15. data/lib/jsonapi/operations_processor.rb +49 -9
  16. data/lib/jsonapi/paginator.rb +31 -17
  17. data/lib/jsonapi/request.rb +347 -163
  18. data/lib/jsonapi/resource.rb +159 -56
  19. data/lib/jsonapi/resource_controller.rb +1 -234
  20. data/lib/jsonapi/resource_serializer.rb +55 -69
  21. data/lib/jsonapi/resources/version.rb +1 -1
  22. data/lib/jsonapi/response_document.rb +87 -0
  23. data/lib/jsonapi/routing_ext.rb +17 -11
  24. data/test/controllers/controller_test.rb +602 -326
  25. data/test/fixtures/active_record.rb +96 -6
  26. data/test/fixtures/line_items.yml +7 -1
  27. data/test/fixtures/numeros_telefone.yml +3 -0
  28. data/test/fixtures/purchase_orders.yml +6 -0
  29. data/test/integration/requests/request_test.rb +129 -60
  30. data/test/integration/routes/routes_test.rb +17 -17
  31. data/test/test_helper.rb +23 -5
  32. data/test/unit/jsonapi_request/jsonapi_request_test.rb +48 -0
  33. data/test/unit/operation/operations_processor_test.rb +242 -54
  34. data/test/unit/resource/resource_test.rb +108 -2
  35. data/test/unit/serializer/include_directives_test.rb +108 -0
  36. data/test/unit/serializer/response_document_test.rb +61 -0
  37. data/test/unit/serializer/serializer_test.rb +679 -520
  38. metadata +26 -2
@@ -1,5 +1,3 @@
1
- require 'jsonapi/configuration'
2
- require 'jsonapi/association'
3
1
  require 'jsonapi/callbacks'
4
2
 
5
3
  module JSONAPI
@@ -37,19 +35,26 @@ module JSONAPI
37
35
  end
38
36
 
39
37
  def change(callback)
38
+ completed = false
39
+
40
40
  if @changing
41
41
  run_callbacks callback do
42
- yield
42
+ completed = (yield == :completed)
43
43
  end
44
44
  else
45
45
  run_callbacks is_new? ? :create : :update do
46
46
  @changing = true
47
47
  run_callbacks callback do
48
- yield
49
- save if @save_needed || is_new?
48
+ completed = (yield == :completed)
49
+ end
50
+
51
+ if @save_needed || is_new?
52
+ completed = (save == :completed)
50
53
  end
51
54
  end
52
55
  end
56
+
57
+ return completed ? :completed : :accepted
53
58
  end
54
59
 
55
60
  def remove
@@ -100,7 +105,7 @@ module JSONAPI
100
105
  end
101
106
 
102
107
  # Override this on a resource to customize how the associated records
103
- # are fetched for a model. Particularly helpful for authoriztion.
108
+ # are fetched for a model. Particularly helpful for authorization.
104
109
  def records_for(association_name, options = {})
105
110
  model.send association_name
106
111
  end
@@ -112,15 +117,42 @@ module JSONAPI
112
117
  end
113
118
  end
114
119
 
120
+ # Override this on a resource to return a different result code. Any
121
+ # value other than :completed will result in operations returning
122
+ # `:accepted`
123
+ #
124
+ # For example to return `:accepted` if your model does not immediately
125
+ # save resources to the database you could override `_save` as follows:
126
+ #
127
+ # ```
128
+ # def _save
129
+ # super
130
+ # return :accepted
131
+ # end
132
+ # ```
115
133
  def _save
116
- @model.save!
117
- @save_needed = false
118
- rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
119
- raise JSONAPI::Exceptions::ValidationErrors.new(e.record.errors.messages)
134
+ unless @model.valid?
135
+ raise JSONAPI::Exceptions::ValidationErrors.new(@model.errors.messages)
136
+ end
137
+
138
+ if defined? @model.save
139
+ saved = @model.save
140
+ unless saved
141
+ raise JSONAPI::Exceptions::SaveFailed.new
142
+ end
143
+ else
144
+ saved = true
145
+ end
146
+
147
+ @save_needed = !saved
148
+
149
+ return :completed
120
150
  end
121
151
 
122
152
  def _remove
123
153
  @model.destroy
154
+
155
+ return :completed
124
156
  end
125
157
 
126
158
  def _create_has_many_links(association_type, association_key_values)
@@ -137,6 +169,8 @@ module JSONAPI
137
169
  raise JSONAPI::Exceptions::HasManyRelationExists.new(association_key_value)
138
170
  end
139
171
  end
172
+
173
+ return :completed
140
174
  end
141
175
 
142
176
  def _replace_has_many_links(association_type, association_key_values)
@@ -144,6 +178,8 @@ module JSONAPI
144
178
 
145
179
  send("#{association.foreign_key}=", association_key_values)
146
180
  @save_needed = true
181
+
182
+ return :completed
147
183
  end
148
184
 
149
185
  def _replace_has_one_link(association_type, association_key_value)
@@ -151,12 +187,16 @@ module JSONAPI
151
187
 
152
188
  send("#{association.foreign_key}=", association_key_value)
153
189
  @save_needed = true
190
+
191
+ return :completed
154
192
  end
155
193
 
156
194
  def _remove_has_many_link(association_type, key)
157
195
  association = self.class._associations[association_type]
158
196
 
159
197
  @model.send(association.type).delete(key)
198
+
199
+ return :completed
160
200
  end
161
201
 
162
202
  def _remove_has_one_link(association_type)
@@ -164,6 +204,8 @@ module JSONAPI
164
204
 
165
205
  send("#{association.foreign_key}=", nil)
166
206
  @save_needed = true
207
+
208
+ return :completed
167
209
  end
168
210
 
169
211
  def _replace_fields(field_data)
@@ -189,6 +231,8 @@ module JSONAPI
189
231
  field_data[:has_many].each do |association_type, values|
190
232
  replace_has_many_links(association_type, values)
191
233
  end if field_data[:has_many]
234
+
235
+ return :completed
192
236
  end
193
237
 
194
238
  class << self
@@ -242,6 +286,10 @@ module JSONAPI
242
286
  def attribute(attr, options = {})
243
287
  check_reserved_attribute_name(attr)
244
288
 
289
+ if (attr.to_sym == :id) && (options[:format].nil?)
290
+ ActiveSupport::Deprecation.warn('Id without format is no longer supported. Please remove ids from attributes, or specify a format.')
291
+ end
292
+
245
293
  @_attributes ||= {}
246
294
  @_attributes[attr] = options
247
295
  define_method attr do
@@ -270,25 +318,40 @@ module JSONAPI
270
318
  end
271
319
 
272
320
  def filters(*attrs)
273
- @_allowed_filters.merge(attrs)
321
+ @_allowed_filters.merge!(attrs.inject( Hash.new ) { |h, attr| h[attr] = {}; h })
274
322
  end
275
323
 
276
- def filter(attr)
277
- @_allowed_filters.add(attr.to_sym)
324
+ def filter(attr, *args)
325
+ @_allowed_filters[attr.to_sym] = args.extract_options!
278
326
  end
279
327
 
280
328
  def primary_key(key)
281
329
  @_primary_key = key.to_sym
282
330
  end
283
331
 
284
- # Override in your resource to filter the updateable keys
285
- def updateable_fields(context = nil)
286
- _updateable_associations | _attributes.keys - [_primary_key]
332
+ # TODO: remove this after the createable_fields and updateable_fields are phased out
333
+ # :nocov:
334
+ def method_missing(method, *args)
335
+ if method.to_s.match /createable_fields/
336
+ ActiveSupport::Deprecation.warn("`createable_fields` is deprecated, please use `creatable_fields` instead")
337
+ self.creatable_fields(*args)
338
+ elsif method.to_s.match /updateable_fields/
339
+ ActiveSupport::Deprecation.warn("`updateable_fields` is deprecated, please use `updatable_fields` instead")
340
+ self.updatable_fields(*args)
341
+ else
342
+ super
343
+ end
344
+ end
345
+ # :nocov:
346
+
347
+ # Override in your resource to filter the updatable keys
348
+ def updatable_fields(context = nil)
349
+ _updatable_associations | _attributes.keys - [:id]
287
350
  end
288
351
 
289
- # Override in your resource to filter the createable keys
290
- def createable_fields(context = nil)
291
- _updateable_associations | _attributes.keys
352
+ # Override in your resource to filter the creatable keys
353
+ def creatable_fields(context = nil)
354
+ _updatable_associations | _attributes.keys
292
355
  end
293
356
 
294
357
  # Override in your resource to filter the sortable keys
@@ -300,50 +363,86 @@ module JSONAPI
300
363
  _associations.keys | _attributes.keys
301
364
  end
302
365
 
303
- def apply_pagination(records, paginator)
366
+ def apply_includes(records, directives)
367
+ records = records.includes(*directives.model_includes) if directives
368
+ records
369
+ end
370
+
371
+ def apply_pagination(records, paginator, order_options)
304
372
  if paginator
305
- records = paginator.apply(records)
373
+ records = paginator.apply(records, order_options)
306
374
  end
307
375
  records
308
376
  end
309
377
 
310
378
  def apply_sort(records, order_options)
311
- records.order(order_options)
379
+ if order_options.any?
380
+ records.order(order_options)
381
+ else
382
+ records
383
+ end
312
384
  end
313
385
 
314
- def apply_filter(records, filter, value)
386
+ def apply_filter(records, filter, value, options = {})
315
387
  records.where(filter => value)
316
388
  end
317
389
 
318
- def apply_filters(records, filters)
390
+ def apply_filters(records, filters, options = {})
319
391
  required_includes = []
320
- filters.each do |filter, value|
321
- if _associations.include?(filter)
322
- if _associations[filter].is_a?(JSONAPI::Association::HasMany)
323
- required_includes.push(filter)
324
- records = apply_filter(records, "#{filter}.#{_associations[filter].primary_key}", value)
392
+
393
+ if filters
394
+ filters.each do |filter, value|
395
+ if _associations.include?(filter)
396
+ if _associations[filter].is_a?(JSONAPI::Association::HasMany)
397
+ required_includes.push(filter)
398
+ records = apply_filter(records, "#{filter}.#{_associations[filter].primary_key}", value, options)
399
+ else
400
+ records = apply_filter(records, "#{_associations[filter].foreign_key}", value, options)
401
+ end
325
402
  else
326
- records = apply_filter(records, "#{_associations[filter].foreign_key}", value)
403
+ records = apply_filter(records, filter, value, options)
327
404
  end
328
- else
329
- records = apply_filter(records, filter, value)
330
405
  end
331
406
  end
332
- records.includes(required_includes)
407
+
408
+ if required_includes.any?
409
+ records.includes(required_includes)
410
+ elsif records.respond_to? :to_ary
411
+ records
412
+ else
413
+ records.all
414
+ end
415
+ end
416
+
417
+ def filter_records(filters, options)
418
+ include_directives = options[:include_directives]
419
+
420
+ records = records(options)
421
+ records = apply_includes(records, include_directives)
422
+ apply_filters(records, filters, options)
423
+ end
424
+
425
+ def sort_records(records, order_options)
426
+ apply_sort(records, order_options)
427
+ end
428
+
429
+ def find_count(filters, options = {})
430
+ filter_records(filters, options).count
333
431
  end
334
432
 
335
433
  # Override this method if you have more complex requirements than this basic find method provides
336
434
  def find(filters, options = {})
337
435
  context = options[:context]
338
- sort_criteria = options.fetch(:sort_criteria) { [] }
339
436
 
340
- resources = []
437
+ records = filter_records(filters, options)
341
438
 
342
- records = records(options)
343
- records = apply_filters(records, filters)
344
- records = apply_sort(records, construct_order_options(sort_criteria))
345
- records = apply_pagination(records, options[:paginator])
439
+ sort_criteria = options.fetch(:sort_criteria) { [] }
440
+ order_options = construct_order_options(sort_criteria)
441
+ records = sort_records(records, order_options)
442
+
443
+ records = apply_pagination(records, options[:paginator], order_options)
346
444
 
445
+ resources = []
347
446
  records.each do |model|
348
447
  resources.push self.new(model, context)
349
448
  end
@@ -353,7 +452,10 @@ module JSONAPI
353
452
 
354
453
  def find_by_key(key, options = {})
355
454
  context = options[:context]
356
- model = records(options).where({_primary_key => key}).first
455
+ include_directives = options[:include_directives]
456
+ records = records(options)
457
+ records = apply_includes(records, include_directives)
458
+ model = records.where({_primary_key => key}).first
357
459
  if model.nil?
358
460
  raise JSONAPI::Exceptions::RecordNotFound.new(key)
359
461
  end
@@ -394,7 +496,7 @@ module JSONAPI
394
496
  def verify_key(key, context = nil)
395
497
  key && Integer(key)
396
498
  rescue
397
- raise JSONAPI::Exceptions::InvalidFieldValue.new(_primary_key, key)
499
+ raise JSONAPI::Exceptions::InvalidFieldValue.new(:id, key)
398
500
  end
399
501
 
400
502
  # override to allow for key processing and checking
@@ -419,13 +521,8 @@ module JSONAPI
419
521
  default_attribute_options.merge(@_attributes[attr])
420
522
  end
421
523
 
422
- def _updateable_associations
423
- associations = []
424
-
425
- @_associations.each do |key, association|
426
- associations.push(key)
427
- end
428
- associations
524
+ def _updatable_associations
525
+ @_associations.map { |key, association| key }
429
526
  end
430
527
 
431
528
  def _has_association?(type)
@@ -451,13 +548,13 @@ module JSONAPI
451
548
  end
452
549
 
453
550
  def _allowed_filters
454
- !@_allowed_filters.nil? ? @_allowed_filters : Set.new([_primary_key])
551
+ !@_allowed_filters.nil? ? @_allowed_filters : { :id => {} }
455
552
  end
456
553
 
457
554
  def _resource_name_from_type(type)
458
555
  class_name = @@resource_types[type]
459
556
  if class_name.nil?
460
- class_name = type.to_s.singularize.camelize + 'Resource'
557
+ class_name = "#{type.to_s.singularize}_resource".camelize
461
558
  @@resource_types[type] = class_name
462
559
  end
463
560
  return class_name
@@ -476,7 +573,7 @@ module JSONAPI
476
573
  end
477
574
 
478
575
  def _allowed_filter?(filter)
479
- _allowed_filters.include?(filter)
576
+ !_allowed_filters[filter].nil?
480
577
  end
481
578
 
482
579
  def module_path
@@ -484,8 +581,11 @@ module JSONAPI
484
581
  end
485
582
 
486
583
  def construct_order_options(sort_params)
584
+ return {} unless sort_params
585
+
487
586
  sort_params.each_with_object({}) { |sort, order_hash|
488
- order_hash[sort[:field]] = sort[:direction]
587
+ field = sort[:field] == 'id' ? _primary_key : sort[:field]
588
+ order_hash[field] = sort[:direction]
489
589
  }
490
590
  end
491
591
 
@@ -554,14 +654,16 @@ module JSONAPI
554
654
  resource_class = Resource.resource_for(self.class.module_path + type_name)
555
655
  filters = options.fetch(:filters, {})
556
656
  sort_criteria = options.fetch(:sort_criteria, {})
557
- paginator = options.fetch(:paginator, nil)
657
+ paginator = options[:paginator]
558
658
 
559
659
  resources = []
660
+
560
661
  if resource_class
561
662
  records = public_send(associated_records_method_name)
562
- records = self.class.apply_filters(records, filters)
563
- records = self.class.apply_sort(records, self.class.construct_order_options(sort_criteria))
564
- records = self.class.apply_pagination(records, paginator)
663
+ records = resource_class.apply_filters(records, filters, options)
664
+ order_options = self.class.construct_order_options(sort_criteria)
665
+ records = resource_class.apply_sort(records, order_options)
666
+ records = resource_class.apply_pagination(records, paginator, order_options)
565
667
  records.each do |record|
566
668
  resources.push resource_class.new(record, @context)
567
669
  end
@@ -572,5 +674,6 @@ module JSONAPI
572
674
  end
573
675
  end
574
676
  end
677
+
575
678
  end
576
679
  end
@@ -1,238 +1,5 @@
1
- require 'jsonapi/resource_serializer'
2
- require 'action_controller'
3
- require 'jsonapi/exceptions'
4
- require 'jsonapi/error'
5
- require 'jsonapi/error_codes'
6
- require 'jsonapi/request'
7
- require 'jsonapi/operations_processor'
8
- require 'jsonapi/active_record_operations_processor'
9
- require 'csv'
10
-
11
1
  module JSONAPI
12
2
  class ResourceController < ActionController::Base
13
- before_filter :ensure_correct_media_type, only: [:create, :update, :create_association, :update_association]
14
- before_filter :setup_request
15
- after_filter :setup_response
16
-
17
- def index
18
- serializer = JSONAPI::ResourceSerializer.new(resource_klass,
19
- include: @request.include,
20
- fields: @request.fields,
21
- base_url: base_url,
22
- key_formatter: key_formatter,
23
- route_formatter: route_formatter)
24
-
25
- resource_records = resource_klass.find(resource_klass.verify_filters(@request.filters, context),
26
- context: context,
27
- sort_criteria: @request.sort_criteria,
28
- paginator: @request.paginator)
29
-
30
- render json: serializer.serialize_to_hash(resource_records)
31
- rescue => e
32
- handle_exceptions(e)
33
- end
34
-
35
- def show
36
- serializer = JSONAPI::ResourceSerializer.new(resource_klass,
37
- include: @request.include,
38
- fields: @request.fields,
39
- base_url: base_url,
40
- key_formatter: key_formatter,
41
- route_formatter: route_formatter)
42
-
43
- key = resource_klass.verify_key(params[resource_klass._primary_key], context)
44
-
45
- resource_record = resource_klass.find_by_key(key, context: context)
46
-
47
- render json: serializer.serialize_to_hash(resource_record)
48
- rescue => e
49
- handle_exceptions(e)
50
- end
51
-
52
- def show_association
53
- association_type = params[:association]
54
-
55
- parent_key = resource_klass.verify_key(params[resource_klass._as_parent_key], context)
56
-
57
- parent_resource = resource_klass.find_by_key(parent_key, context: context)
58
-
59
- association = resource_klass._association(association_type)
60
-
61
- serializer = JSONAPI::ResourceSerializer.new(resource_klass,
62
- fields: @request.fields,
63
- base_url: base_url,
64
- key_formatter: key_formatter,
65
- route_formatter: route_formatter)
66
-
67
- render json: serializer.serialize_to_links_hash(parent_resource, association)
68
- rescue => e
69
- handle_exceptions(e)
70
- end
71
-
72
- def create
73
- process_request_operations
74
- end
75
-
76
- def create_association
77
- process_request_operations
78
- end
79
-
80
- def update_association
81
- process_request_operations
82
- end
83
-
84
- def update
85
- process_request_operations
86
- end
87
-
88
- def destroy
89
- process_request_operations
90
- end
91
-
92
- def destroy_association
93
- process_request_operations
94
- end
95
-
96
- def get_related_resource
97
- association_type = params[:association]
98
- source_resource = @request.source_klass.find_by_key(@request.source_id, context: context)
99
-
100
- serializer = JSONAPI::ResourceSerializer.new(@request.source_klass,
101
- include: @request.include,
102
- fields: @request.fields,
103
- base_url: base_url,
104
- key_formatter: key_formatter,
105
- route_formatter: route_formatter)
106
-
107
- render json: serializer.serialize_to_hash(source_resource.send(association_type))
108
- end
109
-
110
- def get_related_resources
111
- association_type = params[:association]
112
- source_resource = @request.source_klass.find_by_key(@request.source_id, context: context)
113
-
114
- related_resources = source_resource.send(association_type,
115
- {
116
- filters: @request.source_klass.verify_filters(@request.filters, context),
117
- sort_criteria: @request.sort_criteria,
118
- paginator: @request.paginator
119
- })
120
-
121
- serializer = JSONAPI::ResourceSerializer.new(@request.source_klass,
122
- include: @request.include,
123
- fields: @request.fields,
124
- base_url: base_url,
125
- key_formatter: key_formatter,
126
- route_formatter: route_formatter)
127
-
128
- render json: serializer.serialize_to_hash(related_resources)
129
- end
130
-
131
- # Override this to use another operations processor
132
- def create_operations_processor
133
- JSONAPI::ActiveRecordOperationsProcessor.new
134
- end
135
-
136
- private
137
- def resource_klass
138
- @resource_klass ||= resource_klass_name.safe_constantize
139
- end
140
-
141
- def base_url
142
- @base_url ||= request.protocol + request.host_with_port
143
- end
144
-
145
- def resource_klass_name
146
- @resource_klass_name ||= "#{self.class.name.sub(/Controller$/, '').singularize}Resource"
147
- end
148
-
149
- def ensure_correct_media_type
150
- unless request.content_type == JSONAPI::MEDIA_TYPE
151
- raise JSONAPI::Exceptions::UnsupportedMediaTypeError.new(request.content_type)
152
- end
153
- rescue => e
154
- handle_exceptions(e)
155
- end
156
-
157
- def setup_request
158
- @request = JSONAPI::Request.new(params, {
159
- context: context,
160
- key_formatter: key_formatter
161
- })
162
- render_errors(@request.errors) unless @request.errors.empty?
163
- rescue => e
164
- handle_exceptions(e)
165
- end
166
-
167
- def setup_response
168
- if response.body.size > 0
169
- response.headers['Content-Type'] = JSONAPI::MEDIA_TYPE
170
- end
171
- end
172
-
173
- # override to set context
174
- def context
175
- {}
176
- end
177
-
178
- # Control by setting in an initializer:
179
- # JSONAPI.configuration.json_key_format = :camelized_key
180
- # JSONAPI.configuration.route = :camelized_route
181
- #
182
- # Override if you want to set a per controller key format.
183
- # Must return a class derived from KeyFormatter.
184
- def key_formatter
185
- JSONAPI.configuration.key_formatter
186
- end
187
-
188
- def route_formatter
189
- JSONAPI.configuration.route_formatter
190
- end
191
-
192
- def render_errors(errors)
193
- render(json: {errors: errors}, status: errors[0].status)
194
- end
195
-
196
- def process_request_operations
197
- results = create_operations_processor.process(@request)
198
- errors = results.select(&:has_errors?).flat_map(&:errors).compact
199
- resources = results.reject(&:has_errors?).flat_map(&:resource).compact
200
-
201
- status, json = case
202
- when errors.any?
203
- [errors[0].status, {errors: errors}]
204
- when results.any? && resources.any?
205
- res = resources.length > 1 ? resources : resources[0]
206
- [results[0].code, processing_serializer.serialize_to_hash(res)]
207
- else
208
- [results[0].code, nil]
209
- end
210
-
211
- render status: status, json: json
212
- rescue => e
213
- handle_exceptions(e)
214
- end
215
-
216
- def processing_serializer
217
- JSONAPI::ResourceSerializer.new(resource_klass,
218
- include: @request.include,
219
- fields: @request.fields,
220
- base_url: base_url,
221
- key_formatter: key_formatter,
222
- route_formatter: route_formatter)
223
- end
224
-
225
- # override this to process other exceptions
226
- # Note: Be sure to either call super(e) or handle JSONAPI::Exceptions::Error and raise unhandled exceptions
227
- def handle_exceptions(e)
228
- case e
229
- when JSONAPI::Exceptions::Error
230
- render_errors(e.errors)
231
- else # raise all other exceptions
232
- # :nocov:
233
- raise e
234
- # :nocov:
235
- end
236
- end
3
+ include JSONAPI::ActsAsResourceController
237
4
  end
238
5
  end