caprese 0.2.0

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.
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