introspective_grape 0.5.7 → 0.6.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.
- checksums.yaml +4 -4
- data/Gemfile +22 -18
- data/README.md +2 -0
- data/introspective_grape.gemspec +5 -4
- data/lib/introspective_grape/api.rb +461 -459
- data/lib/introspective_grape/create_helpers.rb +25 -25
- data/lib/introspective_grape/traversal.rb +1 -1
- data/lib/introspective_grape/validators.rb +36 -36
- data/lib/introspective_grape/version.rb +5 -5
- data/spec/dummy/Gemfile +23 -22
- data/spec/dummy/app/api/api_helpers.rb +38 -37
- data/spec/dummy/app/api/dummy_api.rb +61 -61
- data/spec/dummy/app/api/permissions_helper.rb +7 -7
- data/spec/dummy/app/helpers/current.rb +3 -0
- data/spec/dummy/app/models/chat_message.rb +34 -34
- data/spec/dummy/app/models/chat_user.rb +16 -16
- data/spec/dummy/app/models/location.rb +26 -26
- data/spec/dummy/app/models/role.rb +30 -30
- data/spec/dummy/app/models/team.rb +14 -9
- data/spec/dummy/app/models/user/chatter.rb +79 -79
- data/spec/dummy/bin/bundle +3 -3
- data/spec/dummy/bin/rails +4 -4
- data/spec/dummy/bin/setup +36 -29
- data/spec/dummy/bin/update +31 -0
- data/spec/dummy/bin/yarn +11 -0
- data/spec/dummy/config/application.rb +30 -37
- data/spec/dummy/config/boot.rb +3 -6
- data/spec/dummy/config/environment.rb +5 -11
- data/spec/dummy/config/environments/development.rb +54 -42
- data/spec/dummy/config/environments/production.rb +81 -79
- data/spec/dummy/config/environments/test.rb +47 -44
- data/spec/dummy/config/initializers/application_controller_renderer.rb +8 -0
- data/spec/dummy/config/initializers/assets.rb +14 -11
- data/spec/dummy/config/initializers/content_security_policy.rb +25 -0
- data/spec/dummy/config/initializers/cookies_serializer.rb +5 -3
- data/spec/dummy/config/initializers/inflections.rb +16 -16
- data/spec/dummy/config/initializers/new_framework_defaults_5_2.rb +38 -0
- data/spec/dummy/config/initializers/wrap_parameters.rb +14 -14
- data/spec/dummy/config/locales/en.yml +33 -23
- data/spec/dummy/config/storage.yml +34 -0
- data/spec/models/image_spec.rb +10 -14
- data/spec/requests/project_api_spec.rb +185 -182
- data/spec/requests/user_api_spec.rb +221 -221
- data/spec/support/request_helpers.rb +22 -21
- metadata +20 -157
@@ -1,459 +1,461 @@
|
|
1
|
-
require 'action_controller'
|
2
|
-
require 'kaminari'
|
3
|
-
#require 'byebug'
|
4
|
-
require 'grape-kaminari'
|
5
|
-
require 'introspective_grape/validators'
|
6
|
-
|
7
|
-
class IntrospectiveGrapeError < StandardError
|
8
|
-
end
|
9
|
-
|
10
|
-
module IntrospectiveGrape
|
11
|
-
# rubocop:disable Metrics/ClassLength
|
12
|
-
class API < Grape::API::Instance
|
13
|
-
# rubocop:enable Metrics/ClassLength
|
14
|
-
extend IntrospectiveGrape::Helpers
|
15
|
-
extend IntrospectiveGrape::CreateHelpers
|
16
|
-
extend IntrospectiveGrape::Filters
|
17
|
-
extend IntrospectiveGrape::Traversal
|
18
|
-
extend IntrospectiveGrape::Doc
|
19
|
-
extend IntrospectiveGrape::SnakeParams
|
20
|
-
include Grape::Kaminari
|
21
|
-
|
22
|
-
# Allow files to be uploaded through ActionController:
|
23
|
-
ActionController::Parameters::PERMITTED_SCALAR_TYPES.push Rack::Multipart::UploadedFile, ActionController::Parameters
|
24
|
-
|
25
|
-
# Generate uniform RESTful APIs for an ActiveRecord Model:
|
26
|
-
#
|
27
|
-
# class <Some API Controller> < IntrospectiveGrape::API
|
28
|
-
# exclude_actions Model, :index,:show,:create,:update,:destroy
|
29
|
-
# default_includes Model, <associations for eager loading>
|
30
|
-
# restful <Model Class>, [<strong, param, fields>]
|
31
|
-
#
|
32
|
-
# class <Model>Entity < Grape::Entity
|
33
|
-
# expose :id, :attribute
|
34
|
-
# expose :association, using: <Association>Entity>
|
35
|
-
# end
|
36
|
-
# end
|
37
|
-
#
|
38
|
-
# To define a Grape param type for a virtual attribute or override the defaut param
|
39
|
-
# type from model introspection, define a class method in the model with the param
|
40
|
-
# types for the attributes specified in a hash:
|
41
|
-
#
|
42
|
-
# def self.grape_param_types
|
43
|
-
# { "<attribute name>" => Grape::API::Boolean,
|
44
|
-
# "<attribute name>" => Integer,
|
45
|
-
# "<attribute name>" => String }
|
46
|
-
# end
|
47
|
-
#
|
48
|
-
# For nested models declared in Rails' strong params both the Grape params for the
|
49
|
-
# nested params as well as nested routes will be declared, allowing for
|
50
|
-
# a good deal of flexibility for API consumers out of the box, nested params for
|
51
|
-
# bulk updates and nested routes for interacting with single records.
|
52
|
-
#
|
53
|
-
|
54
|
-
class << self
|
55
|
-
PLURAL_REFLECTIONS = [ActiveRecord::Reflection::HasManyReflection, ActiveRecord::Reflection::HasManyReflection].freeze
|
56
|
-
# mapping of activerecord/postgres 'type's to ruby data classes, where they differ
|
57
|
-
PG2RUBY = { datetime: DateTime }.freeze
|
58
|
-
|
59
|
-
def inherited(child)
|
60
|
-
super(child)
|
61
|
-
child.before do
|
62
|
-
# Ensure that a user is logged in.
|
63
|
-
send(IntrospectiveGrape::API.authentication_method(self))
|
64
|
-
end
|
65
|
-
|
66
|
-
child.snake_params_before_validation if IntrospectiveGrape.config.camelize_parameters
|
67
|
-
end
|
68
|
-
|
69
|
-
# We will probably need before and after hooks eventually, but haven't yet...
|
70
|
-
# api_actions.each do |a|
|
71
|
-
# define_method "before_#{a}_hook" do |model, params| ; end
|
72
|
-
# define_method "after_#{a}_hook" do |model, params| ; end
|
73
|
-
# end
|
74
|
-
|
75
|
-
# rubocop:disable Metrics/AbcSize
|
76
|
-
def restful(model, strong_params=[], routes=[])
|
77
|
-
raise IntrospectiveGrapeError.new("#{model.name}'s attribute_param_types class method needs to be changed to grape_param_types") if model.respond_to?(:attribute_param_types)
|
78
|
-
|
79
|
-
# Recursively define endpoints for the model and any nested models.
|
80
|
-
#
|
81
|
-
# model: the model class for the API
|
82
|
-
# whitelist: a list of fields in Rail's strong params structure, also used to
|
83
|
-
# generate grape's permitted params.
|
84
|
-
# routes: An array of OpenStruct representations of a nested route's ancestors
|
85
|
-
#
|
86
|
-
|
87
|
-
# Defining the api will break pending migrations during db:migrate, so bail:
|
88
|
-
begin ActiveRecord::Migration.check_pending! rescue return end
|
89
|
-
|
90
|
-
# normalize the whitelist to symbols
|
91
|
-
strong_params.map! {|f| f.is_a?(String) ? f.to_sym : f }
|
92
|
-
# default to a flat representation of the model's attributes if left unspecified
|
93
|
-
strong_params = strong_params.blank? ? model.attribute_names.map(&:to_sym) - %i(id updated_at created_at) : strong_params
|
94
|
-
|
95
|
-
# The strong params will be the same for all routes, differing from the Grape params
|
96
|
-
# when routes are nested
|
97
|
-
whitelist = whitelist( strong_params )
|
98
|
-
|
99
|
-
# As routes are nested keep track of the routes, we are preventing siblings from
|
100
|
-
# appending to the routes array here:
|
101
|
-
routes = build_routes(routes, model)
|
102
|
-
|
103
|
-
# Top level declaration of the Grape::API namespace for the resource:
|
104
|
-
resource routes.first.name.pluralize do
|
105
|
-
# yield to prepend user-defined routes under the root namespace first,
|
106
|
-
yield if block_given?
|
107
|
-
end
|
108
|
-
|
109
|
-
# Then define IntrospectiveGrape's routes:
|
110
|
-
define_routes(routes, whitelist)
|
111
|
-
end
|
112
|
-
|
113
|
-
def define_routes(routes, api_params)
|
114
|
-
define_endpoints(routes, api_params)
|
115
|
-
# recursively define endpoints
|
116
|
-
model = routes.last.model || return
|
117
|
-
|
118
|
-
api_params.select {|a| a.is_a?(Hash) }.each do |nested|
|
119
|
-
# Recursively add RESTful nested routes for every nested model:
|
120
|
-
nested.each do |r, fields|
|
121
|
-
# Look at model.reflections to find the association's class name:
|
122
|
-
reflection_name = r.to_s.sub(/_attributes$/, '')
|
123
|
-
begin
|
124
|
-
relation = model.reflections[reflection_name].class_name.constantize
|
125
|
-
rescue StandardError
|
126
|
-
Rails.logger.fatal "Can't find associated model for #{r} on #{model}"
|
127
|
-
end
|
128
|
-
|
129
|
-
next_routes = build_routes(routes, relation, reflection_name)
|
130
|
-
define_routes(next_routes, fields)
|
131
|
-
end
|
132
|
-
end
|
133
|
-
end
|
134
|
-
|
135
|
-
def define_index(dsl, routes, model, api_params)
|
136
|
-
root = routes.first
|
137
|
-
klass = routes.first.klass
|
138
|
-
name = routes.last.name.pluralize
|
139
|
-
simple_filters(klass, model, api_params)
|
140
|
-
|
141
|
-
dsl.desc "list #{name}" do
|
142
|
-
detail klass.index_documentation(name)
|
143
|
-
end
|
144
|
-
dsl.params do
|
145
|
-
klass.declare_filter_params(self, klass, model, api_params)
|
146
|
-
use :pagination, per_page: klass.pagination[:per_page]||25, max_per_page: klass.pagination[:max_per_page], offset: klass.pagination[:offset]||0 if klass.pagination
|
147
|
-
end
|
148
|
-
dsl.get '/' do
|
149
|
-
# Invoke the policy for the action, defined in the policy classes for the model:
|
150
|
-
authorize root.model.new, :index?
|
151
|
-
|
152
|
-
# Nested route indexes need to be scoped by the API's top level policy class:
|
153
|
-
records = policy_scope( root.model.includes(klass.default_includes(root.model)) )
|
154
|
-
records = klass.apply_filter_params(klass, model, api_params, params, records)
|
155
|
-
records = records.map {|r| klass.find_leaves( routes, r, params ) }.flatten.compact.uniq
|
156
|
-
|
157
|
-
# paginate the records using Kaminari
|
158
|
-
records = paginate(Kaminari.paginate_array(records)) if klass.pagination
|
159
|
-
present records, with: "#{klass}::#{model}Entity".constantize
|
160
|
-
end
|
161
|
-
end
|
162
|
-
|
163
|
-
def define_show(dsl, routes, model, _api_params)
|
164
|
-
name = routes.last.name.singularize
|
165
|
-
klass = routes.first.klass
|
166
|
-
dsl.desc "retrieve a #{name}" do
|
167
|
-
detail klass.show_documentation(name)
|
168
|
-
end
|
169
|
-
dsl.get ":#{routes.last.swagger_key}" do
|
170
|
-
authorize @model, :show?
|
171
|
-
present klass.find_leaf(routes, @model, params), with: "#{klass}::#{model}Entity".constantize
|
172
|
-
end
|
173
|
-
end
|
174
|
-
|
175
|
-
def define_create(dsl, routes, model, api_params)
|
176
|
-
name = routes.last.name.singularize
|
177
|
-
klass = routes.first.klass
|
178
|
-
dsl.desc "create a #{name}" do
|
179
|
-
detail klass.create_documentation(name)
|
180
|
-
end
|
181
|
-
dsl.params do
|
182
|
-
klass.generate_params(self, :create, model, api_params, true)
|
183
|
-
end
|
184
|
-
dsl.post do
|
185
|
-
representation = @model ? klass.add_new_records_to_root_record(self, routes, params, @model) : klass.create_new_record(self, routes, params)
|
186
|
-
present representation, with: "#{klass}::#{model}Entity".constantize
|
187
|
-
end
|
188
|
-
end
|
189
|
-
|
190
|
-
def define_update(dsl, routes, model, api_params)
|
191
|
-
klass = routes.first.klass
|
192
|
-
name = routes.last.name.singularize
|
193
|
-
dsl.desc "update a #{name}" do
|
194
|
-
detail klass.update_documentation(name)
|
195
|
-
end
|
196
|
-
dsl.params do
|
197
|
-
klass.generate_params(self, :update, model, api_params, true)
|
198
|
-
end
|
199
|
-
dsl.put ":#{routes.last.swagger_key}" do
|
200
|
-
authorize @model, :update?
|
201
|
-
|
202
|
-
@model.
|
203
|
-
|
204
|
-
if IntrospectiveGrape.config.skip_object_reload
|
205
|
-
present klass.find_leaf(routes, @model, params), with: "#{klass}::#{model}Entity".constantize
|
206
|
-
else
|
207
|
-
default_includes = routes.first.klass.default_includes(routes.first.model)
|
208
|
-
present klass.find_leaf(routes, @model.class.includes(default_includes).find(@model.id), params), with: "#{klass}::#{model}Entity".constantize
|
209
|
-
end
|
210
|
-
end
|
211
|
-
end
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
name
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
def convert_nested_params_hash(dsl, routes)
|
230
|
-
root = routes.first
|
231
|
-
klass = self
|
232
|
-
dsl.after_validation do
|
233
|
-
next unless params[root.key] # there was no one, nothing to see
|
234
|
-
|
235
|
-
# After Grape validates its parameters:
|
236
|
-
# 1) Find the root model instance for the API if its passed (implicitly either
|
237
|
-
# an update/destroy on the root node or it's a nested route
|
238
|
-
# 2) For nested endpoints convert the params hash into Rails-compliant nested
|
239
|
-
# attributes, to be passed to the root instance for update. This keeps
|
240
|
-
# behavior consistent between bulk and single record updates.
|
241
|
-
@model = root.model.includes( root.klass.default_includes(root.model) ).find(params[root.key])
|
242
|
-
@params.merge!( klass.merge_nested_params(routes, params) )
|
243
|
-
end
|
244
|
-
end
|
245
|
-
|
246
|
-
def merge_nested_params(routes, params)
|
247
|
-
attr = params.reject {|k| [routes.first.key, :api_key].include?(k) }
|
248
|
-
build_nested_attributes(routes[1
|
249
|
-
end
|
250
|
-
|
251
|
-
def define_restful_api(dsl, routes, model, api_params)
|
252
|
-
# declare index, show, update, create, and destroy methods:
|
253
|
-
API_ACTIONS.each do |action|
|
254
|
-
send("define_#{action}", dsl, routes, model, api_params) unless exclude_actions(model).include?(action)
|
255
|
-
end
|
256
|
-
end
|
257
|
-
|
258
|
-
def define_endpoints(routes, api_params)
|
259
|
-
# De-reference these as local variables from their class scope, or when we make
|
260
|
-
# calls to the API they will be whatever they were last set to by the recursive
|
261
|
-
# calls to "nest_routes".
|
262
|
-
routes = routes.clone
|
263
|
-
api_params = api_params.clone
|
264
|
-
model = routes.last.model || return
|
265
|
-
|
266
|
-
# We define the param keys for ID fields in camelcase for swagger's URL substitution,
|
267
|
-
# they'll come back in snake case in the params hash, the API as a whole is agnostic:
|
268
|
-
namespace = routes[0..-2].map {|p| "#{p.name.pluralize}/:#{p.swagger_key}/" }.join + routes.last.name.pluralize
|
269
|
-
|
270
|
-
klass = self # the 'resource' block changes the context to the Grape::API::Instance...
|
271
|
-
resource namespace do
|
272
|
-
klass.convert_nested_params_hash(self, routes)
|
273
|
-
klass.define_restful_api(self, routes, model, api_params)
|
274
|
-
end
|
275
|
-
end
|
276
|
-
|
277
|
-
def build_routes(routes, model, reflection_name=nil)
|
278
|
-
routes = routes.clone
|
279
|
-
# routes: the existing routes array passed from the parent
|
280
|
-
# model: the model being manipulated in this leaf
|
281
|
-
# reflection_name: the association name from the original strong_params declaration
|
282
|
-
#
|
283
|
-
# Constructs an array representation of the route's models and their associations,
|
284
|
-
# a /root/:rootId/branch/:branchId/leaf/:leafId path would have flat array like
|
285
|
-
# [root, branch, leaf] representing the path structure and its models, used to
|
286
|
-
# manipulate ActiveRecord relationships and params hashes and so on.
|
287
|
-
parent_model = routes.last&.model
|
288
|
-
return routes if model == parent_model
|
289
|
-
|
290
|
-
name = reflection_name || model.name.underscore
|
291
|
-
reflection = parent_model&.reflections&.fetch(reflection_name)
|
292
|
-
swagger_key = IntrospectiveGrape.config.camelize_parameters ? "#{name.singularize.camelize(:lower)}Id" : "#{name.singularize}_id"
|
293
|
-
|
294
|
-
routes.push OpenStruct.new(
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
#
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
attributes
|
317
|
-
|
318
|
-
if
|
319
|
-
|
320
|
-
|
321
|
-
{ route.param => attributes.merge( build_nested_attributes(routes, hash) ) }
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
#
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
#
|
334
|
-
#
|
335
|
-
#
|
336
|
-
|
337
|
-
#
|
338
|
-
|
339
|
-
|
340
|
-
fields
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
#
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
#
|
381
|
-
#
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
model
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
end
|
1
|
+
require 'action_controller'
|
2
|
+
require 'kaminari'
|
3
|
+
# require 'byebug'
|
4
|
+
require 'grape-kaminari'
|
5
|
+
require 'introspective_grape/validators'
|
6
|
+
|
7
|
+
class IntrospectiveGrapeError < StandardError
|
8
|
+
end
|
9
|
+
|
10
|
+
module IntrospectiveGrape
|
11
|
+
# rubocop:disable Metrics/ClassLength
|
12
|
+
class API < Grape::API::Instance
|
13
|
+
# rubocop:enable Metrics/ClassLength
|
14
|
+
extend IntrospectiveGrape::Helpers
|
15
|
+
extend IntrospectiveGrape::CreateHelpers
|
16
|
+
extend IntrospectiveGrape::Filters
|
17
|
+
extend IntrospectiveGrape::Traversal
|
18
|
+
extend IntrospectiveGrape::Doc
|
19
|
+
extend IntrospectiveGrape::SnakeParams
|
20
|
+
include Grape::Kaminari
|
21
|
+
|
22
|
+
# Allow files to be uploaded through ActionController:
|
23
|
+
ActionController::Parameters::PERMITTED_SCALAR_TYPES.push Rack::Multipart::UploadedFile, ActionController::Parameters
|
24
|
+
|
25
|
+
# Generate uniform RESTful APIs for an ActiveRecord Model:
|
26
|
+
#
|
27
|
+
# class <Some API Controller> < IntrospectiveGrape::API
|
28
|
+
# exclude_actions Model, :index,:show,:create,:update,:destroy
|
29
|
+
# default_includes Model, <associations for eager loading>
|
30
|
+
# restful <Model Class>, [<strong, param, fields>]
|
31
|
+
#
|
32
|
+
# class <Model>Entity < Grape::Entity
|
33
|
+
# expose :id, :attribute
|
34
|
+
# expose :association, using: <Association>Entity>
|
35
|
+
# end
|
36
|
+
# end
|
37
|
+
#
|
38
|
+
# To define a Grape param type for a virtual attribute or override the defaut param
|
39
|
+
# type from model introspection, define a class method in the model with the param
|
40
|
+
# types for the attributes specified in a hash:
|
41
|
+
#
|
42
|
+
# def self.grape_param_types
|
43
|
+
# { "<attribute name>" => Grape::API::Boolean,
|
44
|
+
# "<attribute name>" => Integer,
|
45
|
+
# "<attribute name>" => String }
|
46
|
+
# end
|
47
|
+
#
|
48
|
+
# For nested models declared in Rails' strong params both the Grape params for the
|
49
|
+
# nested params as well as nested routes will be declared, allowing for
|
50
|
+
# a good deal of flexibility for API consumers out of the box, nested params for
|
51
|
+
# bulk updates and nested routes for interacting with single records.
|
52
|
+
#
|
53
|
+
|
54
|
+
class << self
|
55
|
+
PLURAL_REFLECTIONS = [ActiveRecord::Reflection::HasManyReflection, ActiveRecord::Reflection::HasManyReflection].freeze
|
56
|
+
# mapping of activerecord/postgres 'type's to ruby data classes, where they differ
|
57
|
+
PG2RUBY = { datetime: DateTime }.freeze
|
58
|
+
|
59
|
+
def inherited(child)
|
60
|
+
super(child)
|
61
|
+
child.before do
|
62
|
+
# Ensure that a user is logged in.
|
63
|
+
send(IntrospectiveGrape::API.authentication_method(self))
|
64
|
+
end
|
65
|
+
|
66
|
+
child.snake_params_before_validation if IntrospectiveGrape.config.camelize_parameters
|
67
|
+
end
|
68
|
+
|
69
|
+
# We will probably need before and after hooks eventually, but haven't yet...
|
70
|
+
# api_actions.each do |a|
|
71
|
+
# define_method "before_#{a}_hook" do |model, params| ; end
|
72
|
+
# define_method "after_#{a}_hook" do |model, params| ; end
|
73
|
+
# end
|
74
|
+
|
75
|
+
# rubocop:disable Metrics/AbcSize
|
76
|
+
def restful(model, strong_params=[], routes=[])
|
77
|
+
raise IntrospectiveGrapeError.new("#{model.name}'s attribute_param_types class method needs to be changed to grape_param_types") if model.respond_to?(:attribute_param_types)
|
78
|
+
|
79
|
+
# Recursively define endpoints for the model and any nested models.
|
80
|
+
#
|
81
|
+
# model: the model class for the API
|
82
|
+
# whitelist: a list of fields in Rail's strong params structure, also used to
|
83
|
+
# generate grape's permitted params.
|
84
|
+
# routes: An array of OpenStruct representations of a nested route's ancestors
|
85
|
+
#
|
86
|
+
|
87
|
+
# Defining the api will break pending migrations during db:migrate, so bail:
|
88
|
+
begin ActiveRecord::Migration.check_pending! rescue return end
|
89
|
+
|
90
|
+
# normalize the whitelist to symbols
|
91
|
+
strong_params.map! {|f| f.is_a?(String) ? f.to_sym : f }
|
92
|
+
# default to a flat representation of the model's attributes if left unspecified
|
93
|
+
strong_params = strong_params.blank? ? model.attribute_names.map(&:to_sym) - %i(id updated_at created_at) : strong_params
|
94
|
+
|
95
|
+
# The strong params will be the same for all routes, differing from the Grape params
|
96
|
+
# when routes are nested
|
97
|
+
whitelist = whitelist( strong_params )
|
98
|
+
|
99
|
+
# As routes are nested keep track of the routes, we are preventing siblings from
|
100
|
+
# appending to the routes array here:
|
101
|
+
routes = build_routes(routes, model)
|
102
|
+
|
103
|
+
# Top level declaration of the Grape::API namespace for the resource:
|
104
|
+
resource routes.first.name.pluralize do
|
105
|
+
# yield to prepend user-defined routes under the root namespace first,
|
106
|
+
yield if block_given?
|
107
|
+
end
|
108
|
+
|
109
|
+
# Then define IntrospectiveGrape's routes:
|
110
|
+
define_routes(routes, whitelist)
|
111
|
+
end
|
112
|
+
|
113
|
+
def define_routes(routes, api_params)
|
114
|
+
define_endpoints(routes, api_params)
|
115
|
+
# recursively define endpoints
|
116
|
+
model = routes.last.model || return
|
117
|
+
|
118
|
+
api_params.select {|a| a.is_a?(Hash) }.each do |nested|
|
119
|
+
# Recursively add RESTful nested routes for every nested model:
|
120
|
+
nested.each do |r, fields|
|
121
|
+
# Look at model.reflections to find the association's class name:
|
122
|
+
reflection_name = r.to_s.sub(/_attributes$/, '')
|
123
|
+
begin
|
124
|
+
relation = model.reflections[reflection_name].class_name.constantize
|
125
|
+
rescue StandardError
|
126
|
+
Rails.logger.fatal "Can't find associated model for #{r} on #{model}"
|
127
|
+
end
|
128
|
+
|
129
|
+
next_routes = build_routes(routes, relation, reflection_name)
|
130
|
+
define_routes(next_routes, fields)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def define_index(dsl, routes, model, api_params)
|
136
|
+
root = routes.first
|
137
|
+
klass = routes.first.klass
|
138
|
+
name = routes.last.name.pluralize
|
139
|
+
simple_filters(klass, model, api_params)
|
140
|
+
|
141
|
+
dsl.desc "list #{name}" do
|
142
|
+
detail klass.index_documentation(name)
|
143
|
+
end
|
144
|
+
dsl.params do
|
145
|
+
klass.declare_filter_params(self, klass, model, api_params)
|
146
|
+
use :pagination, per_page: klass.pagination[:per_page]||25, max_per_page: klass.pagination[:max_per_page], offset: klass.pagination[:offset]||0 if klass.pagination
|
147
|
+
end
|
148
|
+
dsl.get '/' do
|
149
|
+
# Invoke the policy for the action, defined in the policy classes for the model:
|
150
|
+
authorize root.model.new, :index?
|
151
|
+
|
152
|
+
# Nested route indexes need to be scoped by the API's top level policy class:
|
153
|
+
records = policy_scope( root.model.includes(klass.default_includes(root.model)) )
|
154
|
+
records = klass.apply_filter_params(klass, model, api_params, params, records)
|
155
|
+
records = records.map {|r| klass.find_leaves( routes, r, params ) }.flatten.compact.uniq
|
156
|
+
|
157
|
+
# paginate the records using Kaminari
|
158
|
+
records = paginate(Kaminari.paginate_array(records)) if klass.pagination
|
159
|
+
present records, with: "#{klass}::#{model}Entity".constantize
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def define_show(dsl, routes, model, _api_params)
|
164
|
+
name = routes.last.name.singularize
|
165
|
+
klass = routes.first.klass
|
166
|
+
dsl.desc "retrieve a #{name}" do
|
167
|
+
detail klass.show_documentation(name)
|
168
|
+
end
|
169
|
+
dsl.get ":#{routes.last.swagger_key}" do
|
170
|
+
authorize @model, :show?
|
171
|
+
present klass.find_leaf(routes, @model, params), with: "#{klass}::#{model}Entity".constantize
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def define_create(dsl, routes, model, api_params)
|
176
|
+
name = routes.last.name.singularize
|
177
|
+
klass = routes.first.klass
|
178
|
+
dsl.desc "create a #{name}" do
|
179
|
+
detail klass.create_documentation(name)
|
180
|
+
end
|
181
|
+
dsl.params do
|
182
|
+
klass.generate_params(self, :create, model, api_params, true)
|
183
|
+
end
|
184
|
+
dsl.post do
|
185
|
+
representation = @model ? klass.add_new_records_to_root_record(self, routes, params, @model) : klass.create_new_record(self, routes, params)
|
186
|
+
present representation, with: "#{klass}::#{model}Entity".constantize
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def define_update(dsl, routes, model, api_params)
|
191
|
+
klass = routes.first.klass
|
192
|
+
name = routes.last.name.singularize
|
193
|
+
dsl.desc "update a #{name}" do
|
194
|
+
detail klass.update_documentation(name)
|
195
|
+
end
|
196
|
+
dsl.params do
|
197
|
+
klass.generate_params(self, :update, model, api_params, true)
|
198
|
+
end
|
199
|
+
dsl.put ":#{routes.last.swagger_key}" do
|
200
|
+
authorize @model, :update?
|
201
|
+
|
202
|
+
@model.update!( safe_params(params).permit(klass.whitelist) )
|
203
|
+
|
204
|
+
if IntrospectiveGrape.config.skip_object_reload
|
205
|
+
present klass.find_leaf(routes, @model, params), with: "#{klass}::#{model}Entity".constantize
|
206
|
+
else
|
207
|
+
default_includes = routes.first.klass.default_includes(routes.first.model)
|
208
|
+
present klass.find_leaf(routes, @model.class.includes(default_includes).find(@model.id), params), with: "#{klass}::#{model}Entity".constantize
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
def define_destroy(dsl, routes, model, _api_params)
|
214
|
+
klass = routes.first.klass
|
215
|
+
name = routes.last.name.singularize
|
216
|
+
dsl.desc "destroy a #{name}" do
|
217
|
+
detail klass.destroy_documentation(name)
|
218
|
+
end
|
219
|
+
dsl.params do
|
220
|
+
requires routes.last.swagger_key, type: klass.param_type(model, model.primary_key)
|
221
|
+
end
|
222
|
+
dsl.delete ":#{routes.last.swagger_key}" do
|
223
|
+
authorize @model, :destroy?
|
224
|
+
present status: (klass.find_leaf(routes, @model, params).destroy ? true : false)
|
225
|
+
end
|
226
|
+
end
|
227
|
+
# rubocop:enable Metrics/AbcSize
|
228
|
+
|
229
|
+
def convert_nested_params_hash(dsl, routes)
|
230
|
+
root = routes.first
|
231
|
+
klass = self
|
232
|
+
dsl.after_validation do
|
233
|
+
next unless params[root.key] # there was no one, nothing to see
|
234
|
+
|
235
|
+
# After Grape validates its parameters:
|
236
|
+
# 1) Find the root model instance for the API if its passed (implicitly either
|
237
|
+
# an update/destroy on the root node or it's a nested route
|
238
|
+
# 2) For nested endpoints convert the params hash into Rails-compliant nested
|
239
|
+
# attributes, to be passed to the root instance for update. This keeps
|
240
|
+
# behavior consistent between bulk and single record updates.
|
241
|
+
@model = root.model.includes( root.klass.default_includes(root.model) ).find(params[root.key])
|
242
|
+
@params.merge!( klass.merge_nested_params(routes, params) )
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
def merge_nested_params(routes, params)
|
247
|
+
attr = params.reject {|k| [routes.first.key, :api_key].include?(k) }
|
248
|
+
build_nested_attributes(routes[1..], attr)
|
249
|
+
end
|
250
|
+
|
251
|
+
def define_restful_api(dsl, routes, model, api_params)
|
252
|
+
# declare index, show, update, create, and destroy methods:
|
253
|
+
API_ACTIONS.each do |action|
|
254
|
+
send("define_#{action}", dsl, routes, model, api_params) unless exclude_actions(model).include?(action)
|
255
|
+
end
|
256
|
+
end
|
257
|
+
|
258
|
+
def define_endpoints(routes, api_params)
|
259
|
+
# De-reference these as local variables from their class scope, or when we make
|
260
|
+
# calls to the API they will be whatever they were last set to by the recursive
|
261
|
+
# calls to "nest_routes".
|
262
|
+
routes = routes.clone
|
263
|
+
api_params = api_params.clone
|
264
|
+
model = routes.last.model || return
|
265
|
+
|
266
|
+
# We define the param keys for ID fields in camelcase for swagger's URL substitution,
|
267
|
+
# they'll come back in snake case in the params hash, the API as a whole is agnostic:
|
268
|
+
namespace = routes[0..-2].map {|p| "#{p.name.pluralize}/:#{p.swagger_key}/" }.join + routes.last.name.pluralize
|
269
|
+
|
270
|
+
klass = self # the 'resource' block changes the context to the Grape::API::Instance...
|
271
|
+
resource namespace do
|
272
|
+
klass.convert_nested_params_hash(self, routes)
|
273
|
+
klass.define_restful_api(self, routes, model, api_params)
|
274
|
+
end
|
275
|
+
end
|
276
|
+
|
277
|
+
def build_routes(routes, model, reflection_name=nil)
|
278
|
+
routes = routes.clone
|
279
|
+
# routes: the existing routes array passed from the parent
|
280
|
+
# model: the model being manipulated in this leaf
|
281
|
+
# reflection_name: the association name from the original strong_params declaration
|
282
|
+
#
|
283
|
+
# Constructs an array representation of the route's models and their associations,
|
284
|
+
# a /root/:rootId/branch/:branchId/leaf/:leafId path would have flat array like
|
285
|
+
# [root, branch, leaf] representing the path structure and its models, used to
|
286
|
+
# manipulate ActiveRecord relationships and params hashes and so on.
|
287
|
+
parent_model = routes.last&.model
|
288
|
+
return routes if model == parent_model
|
289
|
+
|
290
|
+
name = reflection_name || model.name.underscore
|
291
|
+
reflection = parent_model&.reflections&.fetch(reflection_name)
|
292
|
+
swagger_key = IntrospectiveGrape.config.camelize_parameters ? "#{name.singularize.camelize(:lower)}Id" : "#{name.singularize}_id"
|
293
|
+
|
294
|
+
routes.push OpenStruct.new( # rubocop:disable Style/OpenStructUse
|
295
|
+
klass: self, name: name, param: "#{name}_attributes", model: model,
|
296
|
+
many?: plural?(parent_model, reflection),
|
297
|
+
key: "#{name.singularize}_id".to_sym,
|
298
|
+
swagger_key: swagger_key, reflection: reflection
|
299
|
+
)
|
300
|
+
end
|
301
|
+
|
302
|
+
def plural?(model, reflection)
|
303
|
+
(model && PLURAL_REFLECTIONS.include?(reflection.class))
|
304
|
+
end
|
305
|
+
|
306
|
+
def build_nested_attributes(routes, hash)
|
307
|
+
# Recursively re-express the flat attributes hash from nested routes as nested
|
308
|
+
# attributes that can be used to perform an update on the root model.
|
309
|
+
|
310
|
+
# do nothing if the params are already nested.
|
311
|
+
return {} if routes.blank? || hash[routes.first.param]
|
312
|
+
|
313
|
+
route = routes.shift
|
314
|
+
# change 'x_id' to 'x_attributes': { id: id, y_attributes: {} }
|
315
|
+
id = hash.delete route.key
|
316
|
+
attributes = id ? { id: id } : {}
|
317
|
+
|
318
|
+
attributes.merge!( hash ) if routes.blank? # assign param values to the last reference
|
319
|
+
|
320
|
+
if route.many? # nest it in an array if it is a has_many association
|
321
|
+
{ route.param => [attributes.merge( build_nested_attributes(routes, hash) )] }
|
322
|
+
else
|
323
|
+
{ route.param => attributes.merge( build_nested_attributes(routes, hash) ) }
|
324
|
+
end
|
325
|
+
end
|
326
|
+
|
327
|
+
def generate_params(dsl, action, model, fields, is_root_endpoint=false)
|
328
|
+
# We'll be doing a recursive walk (to handle nested attributes) down the
|
329
|
+
# whitelisted params, generating the Grape param definitions by introspecting
|
330
|
+
# on the model and its associations.
|
331
|
+
raise "Invalid action: #{action}" unless %i(update create).include?(action)
|
332
|
+
|
333
|
+
# dsl : The Grape::Validations::ParamsScope object
|
334
|
+
# action: create or update
|
335
|
+
# model : The ActiveRecord model class
|
336
|
+
# fields: The whitelisted data structure for Rails' strong params, from which we
|
337
|
+
# infer Grape's parameters
|
338
|
+
|
339
|
+
# skip the ID param at the root level endpoint, so we don't duplicate the URL parameter (api/v#/model/modelId)
|
340
|
+
fields -= [:id] if is_root_endpoint
|
341
|
+
|
342
|
+
fields.each do |field|
|
343
|
+
if field.is_a?(Hash)
|
344
|
+
generate_nested_params(dsl, action, model, field)
|
345
|
+
elsif action == :create && param_required?(model, field)
|
346
|
+
# All params are optional on an update, only require them during creation.
|
347
|
+
# Updating a record with new child models will have to rely on ActiveRecord
|
348
|
+
# validations:
|
349
|
+
dsl.requires field, { type: param_type(model, field) }.merge( validations(model, field) )
|
350
|
+
else
|
351
|
+
# dsl.optional field, *options
|
352
|
+
dsl.optional field, { type: param_type(model, field) }.merge( validations(model, field) )
|
353
|
+
end
|
354
|
+
end
|
355
|
+
end
|
356
|
+
|
357
|
+
def validations(model, field)
|
358
|
+
(model.try(:grape_validations) || {}).with_indifferent_access[field] || {}
|
359
|
+
end
|
360
|
+
|
361
|
+
def generate_nested_params(dsl, action, model, fields)
|
362
|
+
klass = self
|
363
|
+
fields.each do |r, v|
|
364
|
+
# Look at model.reflections to find the association's class name:
|
365
|
+
reflection = r.to_s.sub(/_attributes$/, '') # the reflection name
|
366
|
+
relation = find_relation(model, reflection)
|
367
|
+
|
368
|
+
if file_attachment?(model, r)
|
369
|
+
# Handle Carrierwave file upload fields
|
370
|
+
s = %i(filename type name tempfile head) - v
|
371
|
+
Rails.logger.warn "Missing required file upload parameters #{s} for uploader field #{r}" if s.present?
|
372
|
+
elsif plural_reflection?(model, reflection)
|
373
|
+
# In case you need a refresher on how these work:
|
374
|
+
# http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html
|
375
|
+
dsl.optional r, type: Array do |dl|
|
376
|
+
klass.generate_params(dl, action, relation, v)
|
377
|
+
klass.add_destroy_param(dl, model, reflection, action)
|
378
|
+
end
|
379
|
+
else
|
380
|
+
# TODO: handle any remaining correctly. Presently defaults to a Hash
|
381
|
+
# http://www.rubydoc.info/github/rails/rails/ActiveRecord/Reflection
|
382
|
+
# ThroughReflection, HasOneReflection,
|
383
|
+
# HasAndBelongsToManyReflection, BelongsToReflection
|
384
|
+
dsl.optional r, type: Hash do |dl|
|
385
|
+
klass.generate_params(dl, action, relation, v)
|
386
|
+
klass.add_destroy_param(dl, model, reflection, action)
|
387
|
+
end
|
388
|
+
end
|
389
|
+
end
|
390
|
+
end
|
391
|
+
|
392
|
+
def plural_reflection?(model, reflection)
|
393
|
+
PLURAL_REFLECTIONS.include?( model.reflections[reflection].class )
|
394
|
+
end
|
395
|
+
|
396
|
+
def find_relation(model, reflection)
|
397
|
+
begin
|
398
|
+
model.reflections[reflection].class_name.constantize
|
399
|
+
rescue StandardError
|
400
|
+
model
|
401
|
+
end
|
402
|
+
end
|
403
|
+
|
404
|
+
def file_attachment?(model, field)
|
405
|
+
(model.respond_to?(:uploaders) && model.uploaders[field.to_sym]) || # carrierwave
|
406
|
+
(model.respond_to?(:attachment_definitions) && model.attachment_definitions[field.to_sym]) ||
|
407
|
+
(defined?(Paperclip::Attachment) && model.send(:new).try(field).is_a?(Paperclip::Attachment))
|
408
|
+
end
|
409
|
+
|
410
|
+
def param_type(model, field)
|
411
|
+
# Translate from the AR type to the GrapeParam types
|
412
|
+
field = field.to_s
|
413
|
+
db_type = (model&.try(:columns_hash) || {})[field]&.type
|
414
|
+
|
415
|
+
# Check if it's a file attachment, look for an override class from the model,
|
416
|
+
# check PG2RUBY, use the database type, or fail over to a String:
|
417
|
+
uploaded_file?(model, field) ||
|
418
|
+
check_model_for_type(model, field) ||
|
419
|
+
PG2RUBY[db_type] ||
|
420
|
+
db_type_constant(db_type) ||
|
421
|
+
String # default to String if nothing else works
|
422
|
+
end
|
423
|
+
|
424
|
+
def uploaded_file?(model, field)
|
425
|
+
file_attachment?(model, field) && Rack::Multipart::UploadedFile
|
426
|
+
end
|
427
|
+
|
428
|
+
def check_model_for_type(model, field)
|
429
|
+
(model.try(:grape_param_types) || {}).with_indifferent_access[field]
|
430
|
+
end
|
431
|
+
|
432
|
+
def db_type_constant(db_type)
|
433
|
+
begin
|
434
|
+
db_type.to_s.camelize.constantize
|
435
|
+
rescue StandardError
|
436
|
+
nil
|
437
|
+
end
|
438
|
+
end
|
439
|
+
|
440
|
+
def param_required?(model, field)
|
441
|
+
# Detect if the field is a required field for the create action
|
442
|
+
return false if skip_presence_validations.include?(field)
|
443
|
+
|
444
|
+
validated_field = field.match?(/_id/) ? field.to_s.sub(/_id\z/, '').to_sym : field.to_sym
|
445
|
+
|
446
|
+
model.validators_on(validated_field).any? {|v| v.is_a? ActiveRecord::Validations::PresenceValidator }
|
447
|
+
end
|
448
|
+
|
449
|
+
def add_destroy_param(dsl, model, reflection, action)
|
450
|
+
return if action == :create
|
451
|
+
|
452
|
+
raise "#{model} does not accept nested attributes for #{reflection}" unless model.nested_attributes_options[reflection.to_sym]
|
453
|
+
|
454
|
+
return unless model.nested_attributes_options[reflection.to_sym][:allow_destroy]
|
455
|
+
|
456
|
+
# If destruction is allowed append the _destroy field
|
457
|
+
dsl.optional '_destroy', type: Integer
|
458
|
+
end
|
459
|
+
end
|
460
|
+
end
|
461
|
+
end
|