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
@@ -25,5 +25,6 @@ Gem::Specification.new do |spec|
25
25
  spec.add_development_dependency 'minitest-spec-rails'
26
26
  spec.add_development_dependency 'minitest-reporters'
27
27
  spec.add_development_dependency 'simplecov'
28
+ spec.add_development_dependency 'pry'
28
29
  spec.add_dependency 'rails', '>= 4.0'
29
30
  end
@@ -1,4 +1,6 @@
1
1
  require 'jsonapi/resource'
2
+ require 'jsonapi/response_document'
3
+ require 'jsonapi/acts_as_resource_controller'
2
4
  require 'jsonapi/resource_controller'
3
5
  require 'jsonapi/resources/version'
4
6
  require 'jsonapi/configuration'
@@ -6,3 +8,16 @@ require 'jsonapi/paginator'
6
8
  require 'jsonapi/formatter'
7
9
  require 'jsonapi/routing_ext'
8
10
  require 'jsonapi/mime_types'
11
+ require 'jsonapi/resource_serializer'
12
+ require 'jsonapi/exceptions'
13
+ require 'jsonapi/error'
14
+ require 'jsonapi/error_codes'
15
+ require 'jsonapi/request'
16
+ require 'jsonapi/operations_processor'
17
+ require 'jsonapi/active_record_operations_processor'
18
+ require 'jsonapi/association'
19
+ require 'jsonapi/include_directives'
20
+ require 'jsonapi/operation_result'
21
+ require 'jsonapi/operation_results'
22
+ require 'jsonapi/callbacks'
23
+
@@ -1,17 +1,28 @@
1
- require 'jsonapi/operations_processor'
1
+ class ActiveRecordOperationsProcessor < JSONAPI::OperationsProcessor
2
2
 
3
- module JSONAPI
4
- class ActiveRecordOperationsProcessor < OperationsProcessor
5
-
6
- private
7
- def transaction
3
+ private
4
+ def transaction
5
+ if @transactional
8
6
  ActiveRecord::Base.transaction do
9
7
  yield
10
8
  end
9
+ else
10
+ yield
11
11
  end
12
+ end
12
13
 
13
- def rollback
14
- raise ActiveRecord::Rollback
15
- end
14
+ def rollback
15
+ raise ActiveRecord::Rollback if @transactional
16
+ end
17
+
18
+ def process_operation(operation)
19
+ operation.apply(@context)
20
+ rescue ActiveRecord::DeleteRestrictionError => e
21
+ record_locked_error = JSONAPI::Exceptions::RecordLocked.new(e.message)
22
+ return JSONAPI::ErrorsOperationResult.new(record_locked_error.errors[0].code, record_locked_error.errors)
23
+
24
+ rescue ActiveRecord::RecordNotFound
25
+ record_not_found = JSONAPI::Exceptions::RecordNotFound.new(operation.associated_key)
26
+ return JSONAPI::ErrorsOperationResult.new(record_not_found.errors[0].code, record_not_found.errors)
16
27
  end
17
- end
28
+ end
@@ -0,0 +1,175 @@
1
+ require 'csv'
2
+
3
+ module JSONAPI
4
+ module ActsAsResourceController
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ before_filter :ensure_correct_media_type, only: [:create, :update, :create_association, :update_association]
9
+ before_filter :setup_request
10
+ after_filter :setup_response
11
+ end
12
+
13
+ def index
14
+ process_request_operations
15
+ end
16
+
17
+ def show
18
+ process_request_operations
19
+ end
20
+
21
+ def show_association
22
+ process_request_operations
23
+ end
24
+
25
+ def create
26
+ process_request_operations
27
+ end
28
+
29
+ def create_association
30
+ process_request_operations
31
+ end
32
+
33
+ def update_association
34
+ process_request_operations
35
+ end
36
+
37
+ def update
38
+ process_request_operations
39
+ end
40
+
41
+ def destroy
42
+ process_request_operations
43
+ end
44
+
45
+ def destroy_association
46
+ process_request_operations
47
+ end
48
+
49
+ def get_related_resource
50
+ process_request_operations
51
+ end
52
+
53
+ def get_related_resources
54
+ process_request_operations
55
+ end
56
+
57
+ # set the operations processor in the configuration or override this to use another operations processor
58
+ def create_operations_processor
59
+ JSONAPI.configuration.operations_processor.new
60
+ end
61
+
62
+ private
63
+ def resource_klass
64
+ @resource_klass ||= resource_klass_name.safe_constantize
65
+ end
66
+
67
+ def resource_serializer_klass
68
+ @resource_serializer_klass ||= JSONAPI::ResourceSerializer
69
+ end
70
+
71
+ def base_url
72
+ @base_url ||= request.protocol + request.host_with_port
73
+ end
74
+
75
+ def resource_klass_name
76
+ @resource_klass_name ||= "#{self.class.name.underscore.sub(/_controller$/, '').singularize}_resource".camelize
77
+ end
78
+
79
+ def ensure_correct_media_type
80
+ unless request.content_type == JSONAPI::MEDIA_TYPE
81
+ raise JSONAPI::Exceptions::UnsupportedMediaTypeError.new(request.content_type)
82
+ end
83
+ rescue => e
84
+ handle_exceptions(e)
85
+ end
86
+
87
+ def setup_request
88
+ @request = JSONAPI::Request.new(params, {
89
+ context: context,
90
+ key_formatter: key_formatter
91
+ })
92
+ render_errors(@request.errors) unless @request.errors.empty?
93
+ rescue => e
94
+ handle_exceptions(e)
95
+ end
96
+
97
+ def setup_response
98
+ if response.body.size > 0
99
+ response.headers['Content-Type'] = JSONAPI::MEDIA_TYPE
100
+ end
101
+ end
102
+
103
+ # override to set context
104
+ def context
105
+ {}
106
+ end
107
+
108
+ # Control by setting in an initializer:
109
+ # JSONAPI.configuration.json_key_format = :camelized_key
110
+ # JSONAPI.configuration.route = :camelized_route
111
+ #
112
+ # Override if you want to set a per controller key format.
113
+ # Must return a class derived from KeyFormatter.
114
+ def key_formatter
115
+ JSONAPI.configuration.key_formatter
116
+ end
117
+
118
+ def route_formatter
119
+ JSONAPI.configuration.route_formatter
120
+ end
121
+
122
+ def base_response_meta
123
+ {}
124
+ end
125
+
126
+ def render_errors(errors)
127
+ operation_results = JSONAPI::OperationResults.new()
128
+ result = JSONAPI::ErrorsOperationResult.new(errors[0].status, errors)
129
+ operation_results.add_result(result)
130
+
131
+ render_results(operation_results)
132
+ end
133
+
134
+ def render_results(operation_results)
135
+ response_doc = create_response_document(operation_results)
136
+ render status: response_doc.status, json: response_doc.contents
137
+ end
138
+
139
+ def create_response_document(operation_results)
140
+ JSONAPI::ResponseDocument.new(
141
+ operation_results,
142
+ {
143
+ primary_resource_klass: resource_klass,
144
+ include_directives: @request ? @request.include_directives : nil,
145
+ fields: @request ? @request.fields : nil,
146
+ base_url: base_url,
147
+ key_formatter: key_formatter,
148
+ route_formatter: route_formatter,
149
+ base_meta: base_response_meta,
150
+ resource_serializer_klass: resource_serializer_klass
151
+ }
152
+ )
153
+ end
154
+
155
+ def process_request_operations
156
+ operation_results = create_operations_processor.process(@request)
157
+ render_results(operation_results)
158
+ rescue => e
159
+ handle_exceptions(e)
160
+ end
161
+
162
+ # override this to process other exceptions
163
+ # Note: Be sure to either call super(e) or handle JSONAPI::Exceptions::Error and raise unhandled exceptions
164
+ def handle_exceptions(e)
165
+ case e
166
+ when JSONAPI::Exceptions::Error
167
+ render_errors(e.errors)
168
+ else # raise all other exceptions
169
+ # :nocov:
170
+ raise e
171
+ # :nocov:
172
+ end
173
+ end
174
+ end
175
+ end
@@ -1,4 +1,6 @@
1
1
  require 'jsonapi/formatter'
2
+ require 'jsonapi/operations_processor'
3
+ require 'jsonapi/active_record_operations_processor'
2
4
 
3
5
  module JSONAPI
4
6
  class Configuration
@@ -6,6 +8,7 @@ module JSONAPI
6
8
  :key_formatter,
7
9
  :route_format,
8
10
  :route_formatter,
11
+ :operations_processor,
9
12
  :allowed_request_params,
10
13
  :default_paginator,
11
14
  :default_page_size,
@@ -19,6 +22,9 @@ module JSONAPI
19
22
  #:underscored_route, :camelized_route, :dasherized_route, or custom
20
23
  self.route_format = :dasherized_route
21
24
 
25
+ #:basic, :active_record, or custom
26
+ self.operations_processor = :active_record
27
+
22
28
  self.allowed_request_params = [:include, :fields, :format, :controller, :action, :sort, :page]
23
29
 
24
30
  # :none, :offset, :paged, or a custom paginator name
@@ -39,6 +45,11 @@ module JSONAPI
39
45
  @route_formatter = JSONAPI::Formatter.formatter_for(format)
40
46
  end
41
47
 
48
+ def operations_processor=(operations_processor)
49
+ @operations_processor_name = operations_processor
50
+ @operations_processor = JSONAPI::OperationsProcessor.operations_processor_for(@operations_processor_name)
51
+ end
52
+
42
53
  def allowed_request_params=(allowed_request_params)
43
54
  @allowed_request_params = allowed_request_params
44
55
  end
@@ -17,8 +17,9 @@ module JSONAPI
17
17
  TYPE_MISMATCH = 116
18
18
  INVALID_PAGE_OBJECT = 117
19
19
  INVALID_PAGE_VALUE = 118
20
- INVALID_SORT_FORMAT = 119
21
- INVALID_FIELD_FORMAT = 120
20
+ INVALID_FIELD_FORMAT = 119
21
+ INVALID_FILTERS_SYNTAX = 120
22
+ SAVE_FAILED = 121
22
23
  FORBIDDEN = 403
23
24
  RECORD_NOT_FOUND = 404
24
25
  UNSUPPORTED_MEDIA_TYPE = 415
@@ -43,10 +44,12 @@ module JSONAPI
43
44
  TYPE_MISMATCH => 'TYPE_MISMATCH',
44
45
  INVALID_PAGE_OBJECT => 'INVALID_PAGE_OBJECT',
45
46
  INVALID_PAGE_VALUE => 'INVALID_PAGE_VALUE',
46
- INVALID_SORT_FORMAT => 'INVALID_SORT_FORMAT',
47
47
  INVALID_FIELD_FORMAT => 'INVALID_FIELD_FORMAT',
48
+ INVALID_FILTERS_SYNTAX => 'INVALID_FILTERS_SYNTAX',
49
+ SAVE_FAILED => 'SAVE_FAILED',
48
50
  FORBIDDEN => 'FORBIDDEN',
49
51
  RECORD_NOT_FOUND => 'RECORD_NOT_FOUND',
50
52
  UNSUPPORTED_MEDIA_TYPE => 'UNSUPPORTED_MEDIA_TYPE',
51
- LOCKED => 'LOCKED' }
53
+ LOCKED => 'LOCKED'
54
+ }
52
55
  end
@@ -67,6 +67,20 @@ module JSONAPI
67
67
  end
68
68
  end
69
69
 
70
+ class InvalidFiltersSyntax < Error
71
+ attr_accessor :filters
72
+ def initialize(filters)
73
+ @filters = filters
74
+ end
75
+
76
+ def errors
77
+ [JSONAPI::Error.new(code: JSONAPI::INVALID_FILTERS_SYNTAX,
78
+ status: :bad_request,
79
+ title: 'Invalid filters syntax',
80
+ detail: "#{filters} is not a valid syntax for filtering.")]
81
+ end
82
+ end
83
+
70
84
  class FilterNotAllowed < Error
71
85
  attr_accessor :filter
72
86
  def initialize(filter)
@@ -188,21 +202,6 @@ module JSONAPI
188
202
  end
189
203
  end
190
204
 
191
- class InvalidSortFormat < Error
192
- attr_accessor :sort_criteria, :resource
193
- def initialize(resource, sort_criteria)
194
- @resource = resource
195
- @sort_criteria = sort_criteria
196
- end
197
-
198
- def errors
199
- [JSONAPI::Error.new(code: JSONAPI::INVALID_SORT_FORMAT,
200
- status: :bad_request,
201
- title: 'Invalid sort format',
202
- detail: "#{sort_criteria} must start with a direction (+ or -)")]
203
- end
204
- end
205
-
206
205
  class ParametersNotAllowed < Error
207
206
  attr_accessor :params
208
207
  def initialize(params)
@@ -306,6 +305,15 @@ module JSONAPI
306
305
  end
307
306
  end
308
307
 
308
+ class SaveFailed < Error
309
+ def errors
310
+ [JSONAPI::Error.new(code: JSONAPI::SAVE_FAILED,
311
+ status: :unprocessable_entity,
312
+ title: 'Save failed or was cancelled',
313
+ detail: 'Save failed or was cancelled')]
314
+ end
315
+ end
316
+
309
317
  class InvalidPageObject < Error
310
318
  def errors
311
319
  [JSONAPI::Error.new(code: JSONAPI::INVALID_PAGE_OBJECT,
@@ -11,7 +11,7 @@ module JSONAPI
11
11
 
12
12
  def formatter_for(format)
13
13
  formatter_class_name = "#{format.to_s.camelize}Formatter"
14
- formatter_class_name.safe_constantize if formatter_class_name
14
+ formatter_class_name.safe_constantize
15
15
  end
16
16
  end
17
17
  end
@@ -42,11 +42,11 @@ module JSONAPI
42
42
 
43
43
  class ValueFormatter < Formatter
44
44
  class << self
45
- def format(raw_value, context)
45
+ def format(raw_value)
46
46
  super(raw_value)
47
47
  end
48
48
 
49
- def unformat(value, context)
49
+ def unformat(value)
50
50
  super(value)
51
51
  end
52
52
 
@@ -87,7 +87,7 @@ end
87
87
 
88
88
  class DefaultValueFormatter < JSONAPI::ValueFormatter
89
89
  class << self
90
- def format(raw_value, context)
90
+ def format(raw_value)
91
91
  raw_value
92
92
  end
93
93
  end
@@ -95,7 +95,7 @@ end
95
95
 
96
96
  class IdValueFormatter < JSONAPI::ValueFormatter
97
97
  class << self
98
- def format(raw_value, context)
98
+ def format(raw_value)
99
99
  return if raw_value.nil?
100
100
  raw_value.to_s
101
101
  end
@@ -0,0 +1,67 @@
1
+ module JSONAPI
2
+ class IncludeDirectives
3
+
4
+ # Construct an IncludeDirectives Hash from an array of dot separated include strings.
5
+ # For example ['posts.comments.tags']
6
+ # will transform into =>
7
+ # {
8
+ # :posts=>{
9
+ # :include=>true,
10
+ # :include_related=>{
11
+ # :comments=>{
12
+ # :include=>true,
13
+ # :include_related=>{
14
+ # :tags=>{
15
+ # :include=>true
16
+ # }
17
+ # }
18
+ # }
19
+ # }
20
+ # }
21
+ # }
22
+
23
+ def initialize(includes_array)
24
+ @include_directives_hash = {include_related: {}}
25
+ includes_array.each do |include|
26
+ parse_include(include)
27
+ end
28
+ end
29
+
30
+ def include_directives
31
+ @include_directives_hash
32
+ end
33
+
34
+ def model_includes
35
+ get_includes(@include_directives_hash)
36
+ end
37
+
38
+ private
39
+ def get_related(current_path)
40
+ current = @include_directives_hash
41
+ current_path.split('.').each do |fragment|
42
+ fragment = fragment.to_sym
43
+ current[:include_related][fragment] ||= {include: false, include_related: {}}
44
+ current = current[:include_related][fragment]
45
+ end
46
+ current
47
+ end
48
+
49
+ def get_includes(directive)
50
+ directive[:include_related].map do |name, directive|
51
+ sub = get_includes(directive)
52
+ sub.any? ? { name => sub } : name
53
+ end
54
+ end
55
+
56
+ def parse_include(include)
57
+ parts = include.split('.')
58
+ local_path = ''
59
+
60
+ parts.each do |name|
61
+ local_path += local_path.length > 0 ? ".#{name}" : name
62
+ related = get_related(local_path)
63
+ related[:include] = true
64
+ end
65
+ end
66
+ end
67
+ end