jsonapi-resources 0.0.1

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