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