jsonapi-resources 0.3.3 → 0.4.0

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