caprese 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.coveralls.yml +1 -0
  3. data/.gitignore +12 -0
  4. data/.travis.yml +18 -0
  5. data/Gemfile +3 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +41 -0
  8. data/Rakefile +8 -0
  9. data/bin/console +14 -0
  10. data/bin/setup +8 -0
  11. data/caprese.gemspec +38 -0
  12. data/lib/caprese/adapter/json_api/error.rb +123 -0
  13. data/lib/caprese/adapter/json_api/json_pointer.rb +52 -0
  14. data/lib/caprese/adapter/json_api/jsonapi.rb +49 -0
  15. data/lib/caprese/adapter/json_api/link.rb +88 -0
  16. data/lib/caprese/adapter/json_api/meta.rb +37 -0
  17. data/lib/caprese/adapter/json_api/pagination_links.rb +69 -0
  18. data/lib/caprese/adapter/json_api/relationship.rb +65 -0
  19. data/lib/caprese/adapter/json_api/resource_identifier.rb +49 -0
  20. data/lib/caprese/adapter/json_api.rb +509 -0
  21. data/lib/caprese/concerns/versioning.rb +69 -0
  22. data/lib/caprese/controller/concerns/callbacks.rb +60 -0
  23. data/lib/caprese/controller/concerns/errors.rb +42 -0
  24. data/lib/caprese/controller/concerns/persistence.rb +327 -0
  25. data/lib/caprese/controller/concerns/query.rb +209 -0
  26. data/lib/caprese/controller/concerns/relationships.rb +250 -0
  27. data/lib/caprese/controller/concerns/rendering.rb +43 -0
  28. data/lib/caprese/controller/concerns/typing.rb +39 -0
  29. data/lib/caprese/controller.rb +26 -0
  30. data/lib/caprese/error.rb +121 -0
  31. data/lib/caprese/errors.rb +69 -0
  32. data/lib/caprese/record/errors.rb +82 -0
  33. data/lib/caprese/record.rb +19 -0
  34. data/lib/caprese/routing/caprese_resources.rb +27 -0
  35. data/lib/caprese/serializer/concerns/links.rb +96 -0
  36. data/lib/caprese/serializer/concerns/lookup.rb +37 -0
  37. data/lib/caprese/serializer/error_serializer.rb +13 -0
  38. data/lib/caprese/serializer.rb +13 -0
  39. data/lib/caprese/version.rb +3 -0
  40. data/lib/caprese.rb +35 -0
  41. metadata +273 -0
@@ -0,0 +1,42 @@
1
+ require 'active_support/concern'
2
+ require 'caprese/error'
3
+ require 'caprese/serializer/error_serializer'
4
+
5
+ module Caprese
6
+ module Errors
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ around_action :enable_caprese_style_errors
11
+
12
+ rescue_from Error do |e|
13
+ output = { json: e }
14
+ render output.merge(e.header)
15
+ end
16
+ end
17
+
18
+ # Fail with a controller action error
19
+ #
20
+ # @param [Symbol] field the field (a controller param) that caused the error
21
+ # @param [Symbol] code the code for the error
22
+ # @param [Hash] t the interpolation params to be passed into I18n.t
23
+ def error(field: nil, code: :invalid, t: {})
24
+ fail Error.new(
25
+ controller: unversion(params[:controller]),
26
+ action: params[:action],
27
+ field: field,
28
+ code: code,
29
+ t: t
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ # Temporarily render model errors as Caprese::Record::Errors instead of ActiveModel::Errors
36
+ def enable_caprese_style_errors
37
+ Caprese::Record.caprese_style_errors = true
38
+ yield
39
+ Caprese::Record.caprese_style_errors = false
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,327 @@
1
+ require 'active_record/associations'
2
+ require 'active_record/validations'
3
+ require 'active_support/concern'
4
+
5
+ module Caprese
6
+ module Persistence
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ # Rescue from instances where required parameters are missing
11
+ #
12
+ # @note The only instance this may be called, at least in JSON API settings, is a
13
+ # missing params['data'] param
14
+ rescue_from ActionController::ParameterMissing do |e|
15
+ rescue_with_handler Error.new(
16
+ field: e.param,
17
+ code: :blank
18
+ )
19
+ end
20
+
21
+ rescue_from ActiveRecord::RecordInvalid do |e|
22
+ rescue_with_handler RecordInvalidError.new(e.record)
23
+ end
24
+
25
+ rescue_from ActiveRecord::RecordNotDestroyed do |e|
26
+ rescue_with_handler ActionForbiddenError.new
27
+ end
28
+
29
+ rescue_from ActiveRecord::DeleteRestrictionError do |e|
30
+ rescue_with_handler DeleteRestrictedError.new(e.message)
31
+ end
32
+ end
33
+
34
+ # Creates a new record of whatever type a given controller manages
35
+ #
36
+ # @note For this action to succeed, the given controller must define `create_params`
37
+ # @see #create_params
38
+ #
39
+ # 1. Check that type of record to be created matches type that the given controller manages
40
+ # 2. Build the appropriate attributes/associations for the create action
41
+ # 3. Build a record with the attributes
42
+ # 4. Execute after_initialize callbacks
43
+ # 5. Execute before_create callbacks
44
+ # 6. Execute before_save callbacks
45
+ # 7. Create the record by saving it (or fail with RecordInvalid and render errors)
46
+ # 8. Execute after_create callbacks
47
+ # 9. Execute after_save callbacks
48
+ # 10. Return the created resource with 204 Created
49
+ # @see #rescue_from ActiveRecord::RecordInvalid
50
+ def create
51
+ fail_on_type_mismatch(data_params[:type])
52
+
53
+ record = queried_record_scope.build
54
+ assign_record_attributes(record, permitted_params_for(:create), data_params)
55
+ execute_after_initialize_callbacks(record)
56
+
57
+ execute_before_create_callbacks(record)
58
+ execute_before_save_callbacks(record)
59
+
60
+ record.save!
61
+
62
+ execute_after_create_callbacks(record)
63
+ execute_after_save_callbacks(record)
64
+
65
+ render(
66
+ json: record,
67
+ status: :created,
68
+ fields: query_params[:fields],
69
+ include: query_params[:include]
70
+ )
71
+ end
72
+
73
+ # Updates a record of whatever type a given controller manages
74
+ #
75
+ # @note For this action to succeed, the given controller must define `update_params`
76
+ # @see #update_params
77
+ #
78
+ # 1. Check that type of record to be updated matches type that the given controller manages
79
+ # 2. Execute before_update callbacks
80
+ # 3. Execute before_save callbacks
81
+ # 4. Update the record (or fail with RecordInvalid and render errors)
82
+ # 5. Execute after_update callbacks
83
+ # 6. Execute after_save callbacks
84
+ # 7. Return the updated resource
85
+ # @see #rescue_from ActiveRecord::RecordInvalid
86
+ def update
87
+ fail_on_type_mismatch(data_params[:type])
88
+
89
+ execute_before_update_callbacks(queried_record)
90
+ execute_before_save_callbacks(queried_record)
91
+
92
+ assign_record_attributes(queried_record, permitted_params_for(:update), data_params)
93
+ queried_record.save!
94
+
95
+ execute_after_update_callbacks(queried_record)
96
+ execute_after_save_callbacks(queried_record)
97
+
98
+ render(
99
+ json: queried_record,
100
+ fields: query_params[:fields],
101
+ include: query_params[:include]
102
+ )
103
+ end
104
+
105
+ # Destroys a record of whatever type a given controller manages
106
+ #
107
+ # 1. Execute any before_destroy callbacks, with the record to be destroyed passed in
108
+ # 2. Destroy the record, ensuring that it checks the model for dependencies before doing so
109
+ # 3. Execute any after_destroy callbacks, with the destroyed resource passed in
110
+ # 4. Return 204 No Content if the record was successfully deleted
111
+ def destroy
112
+ execute_before_destroy_callbacks(queried_record)
113
+ queried_record.destroy!
114
+ execute_after_destroy_callbacks(queried_record)
115
+
116
+ head :no_content
117
+ end
118
+
119
+ private
120
+
121
+ # Requires the data param standard to JSON API
122
+ #
123
+ # @return [StrongParameters] the strong params in the `data` object param
124
+ def data_params
125
+ params.require('data')
126
+ end
127
+
128
+ # An array of symbols stating params that are permitted for a #create action
129
+ # for a record
130
+ #
131
+ # @note Abstract function, must be overridden by every controller
132
+ #
133
+ # @return [Array] a list of params permitted to create a record of whatever type
134
+ # a given controller manages
135
+ def permitted_create_params
136
+ fail NotImplementedError
137
+ end
138
+
139
+ # An array of symbols stating params that are permitted for a #update action
140
+ # for a record
141
+ #
142
+ # @note Abstract function, must be overridden by every controller
143
+ #
144
+ # @return [Array] a list of params permitted to update a record of whatever type
145
+ # a given controller manages
146
+ def permitted_update_params
147
+ fail NotImplementedError
148
+ end
149
+
150
+ # Gets the permitted params for a given action (create, update)
151
+ #
152
+ # @param [Symbol] action the action to get permitted params for
153
+ # @return [Array] the permitted params for a given action
154
+ def permitted_params_for(action)
155
+ send("permitted_#{action}_params")
156
+ end
157
+
158
+ # Gets a set of nested params in an action_params definition
159
+ #
160
+ # @example
161
+ # create_params => [:body, user: [:name, :email]]
162
+ # nested_params_for(user, create_params)
163
+ # => [:name, :email]
164
+ #
165
+ # @param [Symbol] key the key of the nested params
166
+ # @param [Array] params the params to search for the key in
167
+ # @return [Array,Nil] the nested params for a given key
168
+ def nested_params_for(key, params)
169
+ params.detect { |p| p.is_a?(Hash) }.try(:[], key.to_sym)
170
+ end
171
+
172
+ # Flattens an array of the top level keys for a given set of params
173
+ #
174
+ # @example
175
+ # create_params => [:body, user: [:name], post: [:title]]
176
+ # flattened_keys_for(create_params) => [:body, :user, :post]
177
+ #
178
+ # @param [Array] params the params to flatten keys for
179
+ # @return [Array] the flattened array of keys for the action params
180
+ def flattened_keys_for(params)
181
+ params.map do |p|
182
+ if p.is_a?(Hash)
183
+ p.keys
184
+ else
185
+ p
186
+ end
187
+ end.flatten
188
+ end
189
+
190
+ # Builds permitted attributes and relationships into the queried record
191
+ #
192
+ # @example
193
+ # params = {
194
+ # type: 'orders',
195
+ # attributes: { price: '...', other: '...' },
196
+ # relationships: {
197
+ # product: {
198
+ # data: { token: 'asj38k', type: 'products' }
199
+ # }
200
+ # }
201
+ # }
202
+ # create_params => [:price]
203
+ #
204
+ # assign_record_attributes(record, create_params, params)
205
+ # => { price: '...', product: Product<@token='asj38k'> }
206
+ #
207
+ # @example
208
+ # params = {
209
+ # type: 'orders',
210
+ # attributes: { price: '...', other: '...' },
211
+ # relationships: {
212
+ # order_items: {
213
+ # data: [{
214
+ # type: 'order_items',
215
+ # attributes: {
216
+ # title: 'An order item',
217
+ # amount: 5.0,
218
+ # tax: 0.0
219
+ # }
220
+ # }]
221
+ # }
222
+ # }
223
+ # }
224
+ #
225
+ # create_params => [:price, order_items: [:title, :amount, :tax]]
226
+ #
227
+ # assign_record_attributes(record, create_params, params) # => {
228
+ # price: '...',
229
+ # order_items: [OrderItem<@token=nil,@title='An order item',@amount=5.0,@tax=0.0>]
230
+ # }
231
+ #
232
+ # @param [ActiveRecord::Base] record the record to build attribute into
233
+ # @param [Array] permitted_params the permitted params for the action
234
+ # @param [Parameters] data the data sent to the server to construct and assign to the record
235
+ def assign_record_attributes(record, permitted_params, data)
236
+ attributes = data[:attributes].try(:permit, *permitted_params) || {}
237
+
238
+ data[:relationships]
239
+ .try(:slice, *flattened_keys_for(permitted_params))
240
+ .try(:each) do |relationship_name, relationship_data|
241
+ attributes[relationship_name] = records_for_relationship(
242
+ record,
243
+ nested_params_for(relationship_name, permitted_params),
244
+ relationship_name,
245
+ relationship_data
246
+ )
247
+ end
248
+
249
+ record.assign_attributes(attributes)
250
+ end
251
+
252
+ # Gets all the records for a relationship given a relationship data definition
253
+ #
254
+ # @param [ActiveRecord::Base] owner the owner of the relationship
255
+ # @param [Array] permitted_params the permitted params for the
256
+ # @param [String] relationship_name the name of the relationship to get records for
257
+ # @param [Hash,Array<Hash>] relationship_data the resource identifier data to use to find/build records
258
+ # @return [ActiveRecord::Base,Array<ActiveRecord::Base>] the record(s) for the relationship
259
+ def records_for_relationship(owner, permitted_params, relationship_name, relationship_data)
260
+ if relationship_data.is_a?(Array)
261
+ relationship_data.map do |relationship_data_item|
262
+ ref = record_for_relationship(owner, relationship_name, relationship_data_item[:data])
263
+
264
+ if ref && contains_constructable_data?(relationship_data_item[:data])
265
+ assign_record_attributes(ref, permitted_params, relationship_data_item[:data])
266
+ end
267
+
268
+ ref
269
+ end
270
+ else
271
+ ref = record_for_relationship(owner, relationship_name, relationship_data[:data])
272
+
273
+ if ref && contains_constructable_data?(relationship_data[:data])
274
+ assign_record_attributes(ref, permitted_params, relationship_data[:data])
275
+ end
276
+
277
+ ref
278
+ end
279
+ end
280
+
281
+ # Given a resource identifier, finds or builds a resource for a relationship
282
+ #
283
+ # @param [ActiveRecord::Base] owner the owner of the relationship record
284
+ # @param [String] relationship_name the name of the relationship
285
+ # @param [Hash] resource_identifier the resource identifier for the resource
286
+ # @return [ActiveRecord::Base] the found or built resource for the relationship
287
+ def record_for_relationship(owner, relationship_name, resource_identifier)
288
+ record =
289
+ if (id = resource_identifier[Caprese.config.resource_primary_key])
290
+ get_record!(
291
+ resource_identifier[:type],
292
+ Caprese.config.resource_primary_key,
293
+ id
294
+ )
295
+ elsif contains_constructable_data?(resource_identifier)
296
+ record_scope(resource_identifier[:type]).build
297
+ else
298
+ owner.errors.add(relationship_name)
299
+ nil
300
+ end
301
+
302
+ record
303
+ end
304
+
305
+ # Assigns permitted attributes for a record in a relationship, for a given action
306
+ # like create/update
307
+ #
308
+ # @param [ActiveRecord::Base] record the relationship record
309
+ # @param [String] relationship_name the name of the relationship
310
+ # @param [Symbol] action the action that is calling this method (create, update)
311
+ # @param [Hash] resource_identifier the resource identifier
312
+ def assign_relationship_record_attributes(record, relationship_name, action, attributes)
313
+ record.assign_attributes(
314
+ attributes.permit(nested_permitted_params_for(action, relationship_name))
315
+ )
316
+ end
317
+
318
+ # Indicates whether or not :attributes or :relationships keys are in a resource identifier,
319
+ # thus allowing us to construct this data into the final record
320
+ #
321
+ # @param [Hash] resource_identifier the resource identifier to check for constructable data in
322
+ # @return [Boolean] whether or not the resource identifier contains constructable data
323
+ def contains_constructable_data?(resource_identifier)
324
+ [:attributes, :relationships].any? { |k| resource_identifier.key?(k) }
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,209 @@
1
+ require 'active_support/concern'
2
+ require 'caprese/errors'
3
+ require 'kaminari'
4
+
5
+ module Caprese
6
+ module Query
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ before_action :execute_before_query_callbacks
11
+ after_action :execute_after_query_callbacks
12
+ end
13
+
14
+ # Standardizes the index action since it always does the same thing
15
+ def index
16
+ render(
17
+ json: queried_collection,
18
+ fields: query_params[:fields],
19
+ include: query_params[:include]
20
+ )
21
+ end
22
+
23
+ # Standardizes the show action since it always does the same thing
24
+ def show
25
+ render(
26
+ json: queried_record,
27
+ fields: query_params[:fields],
28
+ include: query_params[:include]
29
+ )
30
+ end
31
+
32
+ # The params that affect the query and subsequent response
33
+ #
34
+ # @example INCLUDE ASSOCIATED RESOURCES
35
+ # `GET /api/v1/products?include=merchant`
36
+ #
37
+ # @example INCLUDE NESTED ASSOCIATED RESOURCES
38
+ # `GET /api/v1/products?include=merchant.currency`
39
+ #
40
+ # @example FIELDSETS
41
+ # `GET /api/v1/products?include=merchant&fields[product]=title,description&fields[merchant]=id,name`
42
+ #
43
+ # @example SORT
44
+ # `GET /api/v1/products?sort=updated_at`
45
+ #
46
+ # @example SORT DESCENDING
47
+ # `GET /api/v1/products?sort=-updated_at`
48
+ #
49
+ # @example PAGINATION
50
+ # `GET /api/v1/products?page[number]=1&page[size]=5`
51
+ #
52
+ # @example LIMIT AND OFFSET
53
+ # `GET /api/v1/products?limit=1&offset=1`
54
+ #
55
+ # @example LIMIT AND OFFSET (GET LAST)
56
+ # `GET /api/v1/products?limit=1&offset=-1`
57
+ #
58
+ # @example FILTERING
59
+ # `GET /api/v1/products?filter[venue_id]=10`
60
+ #
61
+ # @return [Hash] the params that modify our query
62
+ def query_params
63
+ if @query_params.blank?
64
+ @query_params = params.except(:action, :controller)
65
+
66
+ # Sort query by column, ascending or descending
67
+ @query_params[:sort] = @query_params[:sort].split(',') if @query_params[:sort]
68
+
69
+ # Convert fields params into arrays for each resource
70
+ @query_params[:fields].each do |resource, fields|
71
+ @query_params[:fields][resource] = fields.split(',')
72
+ end if @query_params[:fields]
73
+ end
74
+
75
+ @query_params
76
+ end
77
+
78
+ # Gets a collection of type `type`, providing the collection as
79
+ # a `record scope` by which to query records
80
+ #
81
+ # @note We use the term scope, because the collection may be all records of that type,
82
+ # or the records may be scoped further by overriding this method
83
+ #
84
+ # @note If no `type` is provided, the type is assumed to be the controller_record_class
85
+ #
86
+ # @param [Symbol] type the type to get a record scope for
87
+ # @return [Relation] the scope of records of type `type`
88
+ def record_scope(type = nil)
89
+ (type && record_class(type) || controller_record_class).all
90
+ end
91
+
92
+ # Gets a record in a scope using a column/value to search by
93
+ #
94
+ # @example
95
+ # get_record(:orders, :id, '1e0da61f-0229-4035-99a5-3e5c37a221fb')
96
+ # # => Order.find_by(id: '1e0da61f-0229-4035-99a5-3e5c37a221fb')
97
+ #
98
+ # @param [Symbol,Relation] scope the scope to find the record in
99
+ # if Symbol, call record_scope for that type and use it
100
+ # if Relation, use it as a scope
101
+ # @param [Symbol] column the name of the column to find the record by
102
+ # @param [Value] value the value to match to a column/row value
103
+ # @return [APIRecord] the record that was found
104
+ def get_record(scope, column, value)
105
+ scope = record_scope(scope) unless scope.respond_to?(:find_by)
106
+
107
+ scope.find_by(column => value)
108
+ end
109
+
110
+ # Gets a record in a scope using a column/value to search by
111
+ # @note Fails with error 404 Not Found if the record was not found
112
+ #
113
+ # @see get_record
114
+ def get_record!(scope, column, value)
115
+ scope = record_scope(scope) unless scope.respond_to?(:find_by!)
116
+
117
+ begin
118
+ scope.find_by!(column => value)
119
+ rescue ActiveRecord::RecordNotFound => e
120
+ fail RecordNotFoundError.new(
121
+ field: column,
122
+ model: scope.name.underscore,
123
+ value: value
124
+ )
125
+ end
126
+ end
127
+
128
+ # Applies query_params[:sort] and query_params[:page] to a given scope
129
+ #
130
+ # @param [Relation] scope the scope to apply sorting and pagination to
131
+ # @return [Relation] the sorted and paginated scope
132
+ def apply_sorting_pagination_to_scope(scope)
133
+ if query_params[:sort].try(:any?)
134
+ ordering = {}
135
+ query_params[:sort].each do |sort_field|
136
+ ordering = ordering.merge(
137
+ if sort_field[0] == '-' # EX: -created_at, sort by created_at descending
138
+ { sort_field[1..-1] => :desc }
139
+ else
140
+ { sort_field => :asc }
141
+ end
142
+ )
143
+ end
144
+ scope = scope.reorder(ordering)
145
+ end
146
+
147
+ if query_params[:offset] || query_params[:limit]
148
+ offset = query_params[:offset].to_i || 0
149
+
150
+ if offset < 0
151
+ offset = scope.count + offset
152
+ end
153
+
154
+ limit = query_params[:limit] && query_params[:limit].to_i || self.config.default_page_size
155
+ limit = [limit, self.config.max_page_size].min
156
+
157
+ scope.offset(offset).limit(limit)
158
+ else
159
+ page_number = query_params[:page].try(:[], :number)
160
+ page_size = query_params[:page].try(:[], :size).try(:to_i) || self.config.default_page_size
161
+ page_size = [page_size, self.config.max_page_size].min
162
+
163
+ scope.page(page_number).per(page_size)
164
+ end
165
+ end
166
+
167
+ # Gets the scope by which to query controller records, taking into account custom scoping and
168
+ # the filters provided in the query
169
+ #
170
+ # @return [Relation] the record scope of the queried controller
171
+ def queried_record_scope
172
+ unless @queried_record_scope
173
+ scope = record_scope
174
+
175
+ if scope.any? && query_params[:filter].try(:any?)
176
+ if (valid_filters = query_params[:filter].select { |k, _| scope.column_names.include? k }).present?
177
+ valid_filters.each do |k, v|
178
+ scope = scope.where(k => v)
179
+ end
180
+ end
181
+ end
182
+
183
+ @queried_record_scope = scope
184
+ end
185
+
186
+ @queried_record_scope
187
+ end
188
+
189
+ # Gets the record that was queried, i.e. the record corresponding to the primary key in the
190
+ # route param (/:id), in the queried_record_scope
191
+ #
192
+ # @return [ActiveRecord::Base] the queried record
193
+ def queried_record
194
+ @queried_record ||=
195
+ get_record!(
196
+ queried_record_scope,
197
+ column = self.config.resource_primary_key,
198
+ params[column]
199
+ )
200
+ end
201
+
202
+ # Gets the collection that was queried, i.e. the sorted & paginated queried_record_scope
203
+ #
204
+ # @return [Relation] the queried collection
205
+ def queried_collection
206
+ @queried_collection ||= apply_sorting_pagination_to_scope(queried_record_scope)
207
+ end
208
+ end
209
+ end