jsonapi-resources 0.0.1

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.
@@ -0,0 +1,15 @@
1
+ module JSONAPI
2
+ class OperationResult
3
+ attr_accessor :code, :errors, :resource
4
+
5
+ def initialize(code, resource = nil, errors = [])
6
+ @code = code
7
+ @resource = resource
8
+ @errors = errors
9
+ end
10
+
11
+ def has_errors?
12
+ errors.count > 0
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,47 @@
1
+ require 'jsonapi/operation_result'
2
+
3
+ module JSONAPI
4
+ class OperationsProcessor
5
+
6
+ def process(request)
7
+ @results = []
8
+ @resources = []
9
+
10
+ context = request.context
11
+
12
+ transaction {
13
+ request.operations.each do |operation|
14
+ before_operation(context, operation)
15
+
16
+ result = operation.apply(context)
17
+
18
+ after_operation(context, result)
19
+
20
+ @results.push(result)
21
+ if result.has_errors?
22
+ rollback
23
+ end
24
+ end
25
+ }
26
+ @results
27
+ end
28
+
29
+ def before_operation(context, operation)
30
+ end
31
+
32
+ def after_operation(context, result)
33
+ end
34
+
35
+ private
36
+
37
+ # The base OperationsProcessor provides no transaction support
38
+ # Override the transaction and rollback methods to provide transaction support.
39
+ # For ActiveRecord transactions you can use the ActiveRecordOperationsProcessor
40
+ def transaction
41
+ yield
42
+ end
43
+
44
+ def rollback
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,254 @@
1
+ require 'jsonapi/resource_for'
2
+ require 'jsonapi/operation'
3
+
4
+ module JSONAPI
5
+ class Request
6
+ include ResourceFor
7
+
8
+ attr_accessor :fields, :includes, :filters, :errors, :operations, :resource_klass, :context
9
+
10
+ def initialize(context = nil, params = nil)
11
+ @context = context
12
+ @errors = []
13
+ @operations = []
14
+ @fields = {}
15
+ @includes = []
16
+ @filters = {}
17
+
18
+ setup(params) if params
19
+ end
20
+
21
+ def setup(params)
22
+ @resource_klass ||= self.class.resource_for(params[:controller]) if params[:controller]
23
+
24
+ unless params.nil?
25
+ case params[:action]
26
+ when 'index'
27
+ parse_fields(params)
28
+ parse_includes(params)
29
+ parse_filters(params)
30
+ when 'show_associations'
31
+ when 'show'
32
+ parse_fields(params)
33
+ parse_includes(params)
34
+ when 'create'
35
+ parse_fields(params)
36
+ parse_includes(params)
37
+ parse_add_operation(params)
38
+ when 'create_association'
39
+ parse_fields(params)
40
+ parse_includes(params)
41
+ parse_add_association_operation(params)
42
+ when 'update'
43
+ parse_fields(params)
44
+ parse_includes(params)
45
+ parse_replace_operation(params)
46
+ when 'destroy'
47
+ parse_remove_operation(params)
48
+ when 'destroy_association'
49
+ parse_remove_association_operation(params)
50
+ end
51
+ end
52
+ end
53
+
54
+ def parse_fields(params)
55
+ fields = {}
56
+
57
+ # Extract the fields for each type from the fields parameters
58
+ unless params[:fields].nil?
59
+ if params[:fields].is_a?(String)
60
+ value = params[:fields]
61
+ resource_fields = value.split(',').map {|s| s.to_sym } unless value.nil? || value.empty?
62
+ type = @resource_klass._serialize_as
63
+ fields[type] = resource_fields
64
+ elsif params[:fields].is_a?(ActionController::Parameters)
65
+ params[:fields].each do |param, value|
66
+ resource_fields = value.split(',').map {|s| s.to_sym } unless value.nil? || value.empty?
67
+ type = param.to_sym
68
+ fields[type] = resource_fields
69
+ end
70
+ end
71
+ end
72
+
73
+ # Validate the fields
74
+ fields.each do |type, values|
75
+ fields[type] = []
76
+ type_resource = self.class.resource_for(type)
77
+ if type_resource.nil? || !(@resource_klass._type == type || @resource_klass._has_association?(type))
78
+ @errors.concat(JSONAPI::Exceptions::InvalidResource.new(type).errors)
79
+ else
80
+ unless values.nil?
81
+ values.each do |field|
82
+ if type_resource._validate_field(field)
83
+ fields[type].push field
84
+ else
85
+ @errors.concat(JSONAPI::Exceptions::InvalidField.new(type, field).errors)
86
+ end
87
+ end
88
+ else
89
+ @errors.concat(JSONAPI::Exceptions::InvalidField.new(type, 'nil').errors)
90
+ end
91
+ end
92
+ end
93
+
94
+ @fields = fields
95
+ end
96
+
97
+ def parse_includes(params)
98
+ includes = params[:include]
99
+ included_resources = []
100
+ included_resources += CSV.parse_line(includes) unless includes.nil? || includes.empty?
101
+ @includes = included_resources
102
+ end
103
+
104
+ def parse_filters(params)
105
+ # Coerce :ids -> :id
106
+ if params[:ids]
107
+ params[:id] = params[:ids]
108
+ params.delete(:ids)
109
+ end
110
+
111
+ filters = {}
112
+ params.each do |key, value|
113
+ filter = key.to_sym
114
+
115
+ if [:include, :fields, :format, :controller, :action, :sort].include?(filter)
116
+ # Ignore non-filter parameters
117
+ elsif @resource_klass._allowed_filter?(filter)
118
+ filters[filter] = value
119
+ else
120
+ @errors.concat(JSONAPI::Exceptions::FilterNotAllowed.new(filter).errors)
121
+ end
122
+ end
123
+ @filters = filters
124
+ end
125
+
126
+ def parse_add_operation(params)
127
+ object_params_raw = params.require(@resource_klass._type)
128
+
129
+ if object_params_raw.is_a?(Array)
130
+ object_params_raw.each do |p|
131
+ @operations.push JSONAPI::CreateResourceOperation.new(@resource_klass,
132
+ @resource_klass.verify_create_params(p, @context))
133
+ end
134
+ else
135
+ @operations.push JSONAPI::CreateResourceOperation.new(@resource_klass,
136
+ @resource_klass.verify_create_params(object_params_raw,
137
+ @context))
138
+ end
139
+ rescue ActionController::ParameterMissing => e
140
+ @errors.concat(JSONAPI::Exceptions::ParameterMissing.new(e.param).errors)
141
+ rescue JSONAPI::Exceptions::Error => e
142
+ @errors.concat(e.errors)
143
+ end
144
+
145
+ def parse_add_association_operation(params)
146
+ association_type = params[:association].to_sym
147
+
148
+ parent_key = params[resource_klass._as_parent_key]
149
+
150
+ if params[association_type].nil?
151
+ raise ActionController::ParameterMissing.new(association_type)
152
+ end
153
+
154
+ object_params = {links: {association_type => params[association_type]}}
155
+ verified_param_set = @resource_klass.verify_update_params(object_params, @context)
156
+
157
+ association = resource_klass._association(association_type)
158
+
159
+ if association.is_a?(JSONAPI::Association::HasOne)
160
+ @operations.push JSONAPI::ReplaceHasOneAssociationOperation.new(resource_klass,
161
+ parent_key,
162
+ association_type,
163
+ verified_param_set[:has_one].values[0])
164
+ else
165
+ @operations.push JSONAPI::CreateHasManyAssociationOperation.new(resource_klass,
166
+ parent_key,
167
+ association_type,
168
+ verified_param_set[:has_many].values[0])
169
+ end
170
+ rescue ActionController::ParameterMissing => e
171
+ @errors.concat(JSONAPI::Exceptions::ParameterMissing.new(e.param).errors)
172
+ end
173
+
174
+ def parse_replace_operation(params)
175
+ object_params_raw = params.require(@resource_klass._type)
176
+
177
+ keys = params[@resource_klass._key]
178
+ if object_params_raw.is_a?(Array)
179
+ if keys.count != object_params_raw.count
180
+ raise JSONAPI::Exceptions::CountMismatch
181
+ end
182
+
183
+ object_params_raw.each do |object_params|
184
+ if object_params[@resource_klass._key].nil?
185
+ raise JSONAPI::Exceptions::MissingKey.new
186
+ end
187
+
188
+ if !keys.include?(object_params[@resource_klass._key])
189
+ raise JSONAPI::Exceptions::KeyNotIncludedInURL.new(object_params[@resource_klass._key])
190
+ end
191
+ @operations.push JSONAPI::ReplaceFieldsOperation.new(@resource_klass,
192
+ object_params[@resource_klass._key],
193
+ @resource_klass.verify_update_params(object_params,
194
+ @context))
195
+ end
196
+ else
197
+ if !object_params_raw[@resource_klass._key].nil? && keys != object_params_raw[@resource_klass._key]
198
+ raise JSONAPI::Exceptions::KeyNotIncludedInURL.new(object_params_raw[@resource_klass._key])
199
+ end
200
+
201
+ @operations.push JSONAPI::ReplaceFieldsOperation.new(@resource_klass,
202
+ params[@resource_klass._key],
203
+ @resource_klass.verify_update_params(object_params_raw,
204
+ @context))
205
+ end
206
+
207
+ rescue ActionController::ParameterMissing => e
208
+ @errors.concat(JSONAPI::Exceptions::ParameterMissing.new(e.param).errors)
209
+ rescue JSONAPI::Exceptions::Error => e
210
+ @errors.concat(e.errors)
211
+ end
212
+
213
+ def parse_remove_operation(params)
214
+ keys = parse_key_array(params.permit(@resource_klass._key)[@resource_klass._key])
215
+
216
+ keys.each do |key|
217
+ @operations.push JSONAPI::RemoveResourceOperation.new(@resource_klass, key)
218
+ end
219
+ rescue ActionController::UnpermittedParameters => e
220
+ @errors.concat(JSONAPI::Exceptions::ParametersNotAllowed.new(e.params).errors)
221
+ rescue JSONAPI::Exceptions::Error => e
222
+ @errors.concat(e.errors)
223
+ end
224
+
225
+ def parse_remove_association_operation(params)
226
+ association_type = params[:association].to_sym
227
+
228
+ parent_key = params[resource_klass._as_parent_key]
229
+
230
+ association = resource_klass._association(association_type)
231
+ if association.is_a?(JSONAPI::Association::HasMany)
232
+ keys = parse_key_array(params[:keys])
233
+ keys.each do |key|
234
+ @operations.push JSONAPI::RemoveHasManyAssociationOperation.new(resource_klass,
235
+ parent_key,
236
+ association_type,
237
+ key)
238
+ end
239
+ else
240
+ @operations.push JSONAPI::RemoveHasOneAssociationOperation.new(resource_klass,
241
+ parent_key,
242
+ association_type)
243
+ end
244
+ end
245
+
246
+ def parse_key_array(raw)
247
+ keys = []
248
+ raw.split(/,/).collect do |key|
249
+ keys.push @resource_klass.verify_key(key, context)
250
+ end
251
+ return keys
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,417 @@
1
+ require 'jsonapi/resource_for'
2
+ require 'jsonapi/association'
3
+ require 'action_dispatch/routing/mapper'
4
+
5
+ module JSONAPI
6
+ class Resource
7
+ include ResourceFor
8
+
9
+ @@resource_types = {}
10
+
11
+ attr_reader :object
12
+
13
+ def initialize(object = create_new_object)
14
+ @object = object
15
+ end
16
+
17
+ def create_new_object
18
+ self.class._model_class.new
19
+ end
20
+
21
+ def remove(context)
22
+ @object.destroy
23
+ end
24
+
25
+ def create_has_many_link(association_type, association_key_value, context)
26
+ association = self.class._associations[association_type]
27
+ related_resource = self.class.resource_for(association.serialize_type_name).find_by_key(association_key_value, context)
28
+
29
+ @object.send(association.serialize_type_name) << related_resource.object
30
+ end
31
+
32
+ def replace_has_many_links(association_type, association_key_values, context)
33
+ association = self.class._associations[association_type]
34
+
35
+ @object.send("#{association.key}=", association_key_values)
36
+ end
37
+
38
+ def replace_has_one_link(association_type, association_key_value, context)
39
+ association = self.class._associations[association_type]
40
+
41
+ @object.send("#{association.key}=", association_key_value)
42
+ end
43
+
44
+ def remove_has_many_link(association_type, key, context)
45
+ association = self.class._associations[association_type]
46
+
47
+ @object.send(association.serialize_type_name).delete(key)
48
+ end
49
+
50
+ def remove_has_one_link(association_type, context)
51
+ association = self.class._associations[association_type]
52
+
53
+ @object.send("#{association.key}=", nil)
54
+ end
55
+
56
+ def replace_fields(field_data, context)
57
+ field_data[:attributes].each do |attribute, value|
58
+ send "#{attribute}=", value
59
+ end
60
+
61
+ field_data[:has_one].each do |association_type, value|
62
+ if value.nil?
63
+ remove_has_one_link(association_type, context)
64
+ else
65
+ replace_has_one_link(association_type, value, context)
66
+ end
67
+ end if field_data[:has_one]
68
+
69
+ field_data[:has_many].each do |association_type, values|
70
+ replace_has_many_links(association_type, values, context)
71
+ end if field_data[:has_many]
72
+ end
73
+
74
+ def save
75
+ @object.save!
76
+ rescue ActiveRecord::RecordInvalid => e
77
+ errors = []
78
+ e.record.errors.messages.each do |element|
79
+ element[1].each do |message|
80
+ errors.push(JSONAPI::Error.new(
81
+ code: JSONAPI::VALIDATION_ERROR,
82
+ status: :bad_request,
83
+ title: "#{element[0]} - #{message}",
84
+ detail: "can't be blank",
85
+ path: "\\#{element[0]}"))
86
+ end
87
+ end
88
+ raise JSONAPI::Exceptions::ValidationErrors.new(errors)
89
+ end
90
+
91
+ # Override this on a resource instance to override the fetchable keys
92
+ def fetchable(keys, context = nil)
93
+ keys
94
+ end
95
+
96
+ class << self
97
+ def inherited(base)
98
+ base._attributes = (_attributes || Set.new).dup
99
+ base._associations = (_associations || {}).dup
100
+ base._allowed_filters = (_allowed_filters || Set.new).dup
101
+
102
+ type = base.name.demodulize.sub(/Resource$/, '').underscore
103
+ base._type = type.pluralize.to_sym
104
+ # If eager loading is on this is how all the resource types are setup
105
+ # If eager loading is off some resource types will be initialized in
106
+ # _resource_name_from_type
107
+ @@resource_types[base._type] ||= base.name.demodulize
108
+ end
109
+
110
+ attr_accessor :_attributes, :_associations, :_allowed_filters , :_type
111
+
112
+ def routing_options(options)
113
+ @_routing_resource_options = options
114
+ end
115
+
116
+ def routing_resource_options
117
+ @_routing_resource_options ||= {}
118
+ end
119
+
120
+ # Methods used in defining a resource class
121
+ def attributes(*attrs)
122
+ @_attributes.merge attrs
123
+ attrs.each do |attr|
124
+ attribute(attr)
125
+ end
126
+ end
127
+
128
+ def attribute(attr)
129
+ @_attributes.add attr
130
+ define_method attr do
131
+ @object.method(attr).call
132
+ end unless method_defined?(attr)
133
+
134
+ define_method "#{attr}=" do |value|
135
+ @object.send "#{attr}=", value
136
+ end unless method_defined?("#{attr}=")
137
+ end
138
+
139
+ def has_one(*attrs)
140
+ _associate(Association::HasOne, *attrs)
141
+ end
142
+
143
+ def has_many(*attrs)
144
+ _associate(Association::HasMany, *attrs)
145
+ end
146
+
147
+ def model_name(model)
148
+ @_model_name = model.to_sym
149
+ end
150
+
151
+ def filters(*attrs)
152
+ @_allowed_filters.merge(attrs)
153
+ end
154
+
155
+ def filter(attr)
156
+ @_allowed_filters.add(attr.to_sym)
157
+ end
158
+
159
+ def key(key)
160
+ @_key = key.to_sym
161
+ end
162
+
163
+ # Override in your resource to filter the updateable keys
164
+ def updateable(keys, context = nil)
165
+ keys
166
+ end
167
+
168
+ # Override in your resource to filter the createable keys
169
+ def createable(keys, context = nil)
170
+ keys
171
+ end
172
+
173
+ # Override this method if you have more complex requirements than this basic find method provides
174
+ def find(filters, context = nil)
175
+ includes = []
176
+ where_filters = {}
177
+
178
+ filters.each do |filter, value|
179
+ if _associations.include?(filter)
180
+ if _associations[filter].is_a?(JSONAPI::Association::HasMany)
181
+ includes.push(filter.to_sym)
182
+ where_filters["#{filter}.#{_associations[filter].primary_key}"] = value
183
+ else
184
+ where_filters["#{_associations[filter].key}"] = value
185
+ end
186
+ else
187
+ where_filters[filter] = value
188
+ end
189
+ end
190
+
191
+ resources = []
192
+ _model_class.where(where_filters).includes(includes).each do |object|
193
+ resources.push self.new(object)
194
+ end
195
+
196
+ return resources
197
+ end
198
+
199
+ def find_by_key(key, context = nil)
200
+ obj = _model_class.where({_key => key}).first
201
+ if obj.nil?
202
+ raise JSONAPI::Exceptions::RecordNotFound.new(key)
203
+ end
204
+ self.new(obj)
205
+ end
206
+
207
+ def verify_create_params(object_params, context = nil)
208
+ verify_params(object_params, createable(_updateable_associations | _attributes.to_a), context)
209
+ end
210
+
211
+ def verify_update_params(object_params, context = nil)
212
+ verify_params(object_params, updateable(_updateable_associations | _attributes.to_a), context)
213
+ end
214
+
215
+ def verify_params(object_params, allowed_params, context)
216
+ # push links into top level param list with attributes in order to check for invalid params
217
+ if object_params[:links]
218
+ object_params[:links].each do |link, value|
219
+ object_params[link] = value
220
+ end
221
+ object_params.delete(:links)
222
+ end
223
+ verify_permitted_params(object_params, allowed_params)
224
+
225
+ checked_attributes = {}
226
+ checked_has_one_associations = {}
227
+ checked_has_many_associations = {}
228
+
229
+ object_params.each do |key, value|
230
+ param = key.to_sym
231
+
232
+ association = _associations[param]
233
+
234
+ if association.is_a?(JSONAPI::Association::HasOne)
235
+ checked_has_one_associations[param.to_sym] = resource_for(association.serialize_type_name).verify_key(value, context)
236
+ elsif association.is_a?(JSONAPI::Association::HasMany)
237
+ keys = []
238
+ value.each do |value|
239
+ keys.push(resource_for(association.serialize_type_name).verify_key(value, context))
240
+ end
241
+ checked_has_many_associations[param.to_sym] = keys
242
+ else
243
+ checked_attributes[param] = value
244
+ end
245
+ end
246
+
247
+ return {
248
+ attributes: checked_attributes,
249
+ has_one: checked_has_one_associations,
250
+ has_many: checked_has_many_associations
251
+ }
252
+ end
253
+
254
+ def verify_filters(filters, context = nil)
255
+ verified_filters = {}
256
+ filters.each do |filter, raw_value|
257
+ verified_filter = verify_filter(filter, raw_value, context)
258
+ verified_filters[verified_filter[0]] = verified_filter[1]
259
+ end
260
+ verified_filters
261
+ end
262
+
263
+ def is_filter_association?(filter)
264
+ filter == _serialize_as || _associations.include?(filter)
265
+ end
266
+
267
+ def verify_filter(filter, raw, context = nil)
268
+ filter_values = []
269
+ filter_values += CSV.parse_line(raw) unless raw.nil? || raw.empty?
270
+
271
+ if is_filter_association?(filter)
272
+ verify_association_filter(filter, filter_values, context)
273
+ else
274
+ verify_custom_filter(filter, filter_values, context)
275
+ end
276
+ end
277
+
278
+ # override to allow for key processing and checking
279
+ def verify_key(key, context = nil)
280
+ return key
281
+ end
282
+
283
+ # override to allow for custom filters
284
+ def verify_custom_filter(filter, value, context = nil)
285
+ return filter, value
286
+ end
287
+
288
+ # override to allow for custom association logic, such as uuids, multiple keys or permission checks on keys
289
+ def verify_association_filter(filter, raw, context = nil)
290
+ return filter, raw
291
+ end
292
+
293
+ # quasi private class methods
294
+ def _updateable_associations
295
+ associations = []
296
+
297
+ @_associations.each do |key, association|
298
+ if association.is_a?(JSONAPI::Association::HasOne) || association.treat_as_set
299
+ associations.push(key)
300
+ end
301
+ end
302
+ associations
303
+ end
304
+
305
+ def _has_association?(type)
306
+ @_associations.has_key?(type)
307
+ end
308
+
309
+ def _association(type)
310
+ type = type.to_sym unless type.is_a?(Symbol)
311
+ @_associations[type]
312
+ end
313
+
314
+ def _model_name
315
+ @_model_name ||= self.name.demodulize.sub(/Resource$/, '')
316
+ end
317
+
318
+ def _serialize_as
319
+ @_serialize_as ||= self._type
320
+ end
321
+
322
+ def _key
323
+ @_key ||= :id
324
+ end
325
+
326
+ def _as_parent_key
327
+ @_as_parent_key ||= "#{_serialize_as.to_s.singularize}_#{_key}"
328
+ end
329
+
330
+ def _allowed_filters
331
+ !@_allowed_filters.nil? ? @_allowed_filters : Set.new([_key])
332
+ end
333
+
334
+ def _resource_name_from_type(type)
335
+ class_name = @@resource_types[type]
336
+ if class_name.nil?
337
+ class_name = type.to_s.singularize.camelize + 'Resource'
338
+ @@resource_types[type] = class_name
339
+ end
340
+ return class_name
341
+ end
342
+
343
+ if RUBY_VERSION >= '2.0'
344
+ def _model_class
345
+ @model ||= Object.const_get(_model_name)
346
+ end
347
+ else
348
+ def _model_class
349
+ @model ||= _model_name.to_s.safe_constantize
350
+ end
351
+ end
352
+
353
+ def _allowed_filter?(filter)
354
+ _allowed_filters.include?(filter.to_sym)
355
+ end
356
+
357
+ def _validate_field(field)
358
+ _attributes.include?(field) || _associations.key?(field)
359
+ end
360
+
361
+ private
362
+
363
+ def _associate(klass, *attrs)
364
+ options = attrs.extract_options!
365
+
366
+ attrs.each do |attr|
367
+ @_associations[attr] = klass.new(attr, options)
368
+
369
+ if @_associations[attr].is_a?(JSONAPI::Association::HasOne)
370
+ key = @_associations[attr].key
371
+
372
+ define_method key do
373
+ @object.method(key).call
374
+ end unless method_defined?(key)
375
+
376
+ define_method "_#{attr}_object" do
377
+ type_name = self.class._associations[attr].serialize_type_name
378
+ resource_class = self.class.resource_for(type_name)
379
+ if resource_class
380
+ associated_object = @object.send attr
381
+ return resource_class.new(associated_object)
382
+ end
383
+ end
384
+ elsif @_associations[attr].is_a?(JSONAPI::Association::HasMany)
385
+ key = @_associations[attr].key
386
+
387
+ define_method key do
388
+ @object.method(key).call
389
+ end unless method_defined?(key)
390
+
391
+ define_method "_#{attr}_objects" do
392
+ type_name = self.class._associations[attr].serialize_type_name
393
+ resource_class = self.class.resource_for(type_name)
394
+ resources = []
395
+ if resource_class
396
+ associated_objects = @object.send attr
397
+ associated_objects.each do |associated_object|
398
+ resources.push resource_class.new(associated_object)
399
+ end
400
+ end
401
+ return resources
402
+ end
403
+ end
404
+ end
405
+ end
406
+
407
+ def verify_permitted_params(params, allowed_param_set)
408
+ params_not_allowed = []
409
+ params.keys.each do |key|
410
+ param = key.to_sym
411
+ params_not_allowed.push(param) unless allowed_param_set.include?(param)
412
+ end
413
+ raise JSONAPI::Exceptions::ParametersNotAllowed.new(params_not_allowed) if params_not_allowed.length > 0
414
+ end
415
+ end
416
+ end
417
+ end