model-api 0.8.3

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.
@@ -0,0 +1,33 @@
1
+ module ModelApi
2
+ class SimpleMetadata
3
+ class << self
4
+ def process_metadata(type, obj, args)
5
+ instance_var = :"@api_#{type}_metadata"
6
+ metadata = obj.instance_variable_get(instance_var) || {}
7
+ if args.present?
8
+ if args.size == 1 && args[0].is_a?(Hash)
9
+ metadata.merge!(args[0].symbolize_keys)
10
+ elsif args.size == 1 && args[0].is_a?(Array)
11
+ metadata.merge!(Hash[args[0].map { |key| [key.to_sym, {}] }])
12
+ else
13
+ metadata.merge!(Hash[args.map { |key| [key.to_sym, {}] }])
14
+ end
15
+ obj.instance_variable_set(instance_var, metadata)
16
+ end
17
+ metadata.dup
18
+ end
19
+
20
+ def merge_superclass_metadata(type, sc, metadata, opts = {})
21
+ metadata_def_method = :"api_#{type}"
22
+ if sc == ActiveRecord::Base || !sc.respond_to?(:"api_#{type}")
23
+ metadata
24
+ elsif (exclude_keys = opts[:exclude_keys]).is_a?(Array)
25
+ (sc.send(metadata_def_method) || {}).reject { |k, _v| exclude_keys.include?(k) }
26
+ .merge(metadata)
27
+ else
28
+ (sc.send(metadata_def_method) || {}).merge(metadata)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,38 @@
1
+ module ModelApi
2
+ class SuppressLoginRedirectMiddleware
3
+ def initialize(app)
4
+ @app = app
5
+ @api_root = nil
6
+ end
7
+
8
+ def call(env)
9
+ unless @api_roots.present?
10
+ options = Rails.application.config.class.class_variable_get(:@@options)
11
+ options ||= {}
12
+ @api_roots = options[:api_middleware_root_paths] || ['api']
13
+ @api_roots = [@api_roots] unless @api_roots.is_a?(Array)
14
+ @api_roots = @api_roots.map { |path| path.starts_with?('/') ? path : "/#{path}" }
15
+ end
16
+ response = @app.call(env)
17
+ if response[0].to_i == 302
18
+ @api_roots.each do |path|
19
+ next unless env['REQUEST_PATH'].to_s.starts_with?(path) &&
20
+ (loc = response[1].find { |a| a[0] == 'Location' }).present? &&
21
+ loc[1].to_s.ends_with?('/users/sign_in')
22
+
23
+ # Mimic headers returned from API endpoint 404's for security reasons.
24
+ response_headers = ModelApi::Utils.common_http_headers.merge(
25
+ 'Content-Type' => 'application/json',
26
+ 'X-Content-Type-Options' => 'nosniff',
27
+ 'X-Frame-Options' => 'SAMEORIGIN',
28
+ 'X-Request-Id' => SecureRandom.uuid,
29
+ 'X-UA-Compatible' => 'chrome=1',
30
+ 'X-XSS-Protection' => '1; mode=block'
31
+ )
32
+ return [404, response_headers, [ModelApi::Utils.not_found_response_body]]
33
+ end
34
+ end
35
+ response
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,4 @@
1
+ module ModelApi
2
+ class UnauthorizedException < Exception
3
+ end
4
+ end
@@ -0,0 +1,392 @@
1
+ module ModelApi
2
+ class Utils
3
+ API_OPERATIONS = [:index, :show, :create, :update, :patch, :destroy, :other, :filter, :sort]
4
+ CAMELCASE_CONVERSION = true
5
+
6
+ class << self
7
+ def route_name(request)
8
+ Rails.application.routes.router.recognize(request) do |route, _matches, _parameters|
9
+ return route.name
10
+ end
11
+ nil
12
+ end
13
+
14
+ def api_attrs(obj_or_class)
15
+ return nil if obj_or_class.nil?
16
+ klass = obj_or_class.is_a?(Class) ? obj_or_class : obj_or_class.class
17
+ return expand_metadata(klass.api_attributes) if klass.respond_to?(:api_attributes)
18
+ Hash[klass.column_names.map(&:to_sym).map { |attr| [attr, { attribute: attr }] }]
19
+ end
20
+
21
+ def filtered_attrs(obj_or_class, operation, opts = {})
22
+ return nil if obj_or_class.nil?
23
+ klass = obj_or_class.is_a?(Class) ? obj_or_class : obj_or_class.class
24
+ filtered_metadata(api_attrs(klass), klass, operation, opts)
25
+ end
26
+
27
+ def filtered_ext_attrs(metadata, operation = nil, opts = {})
28
+ if operation.is_a?(Hash) && opts.blank?
29
+ opts = operation
30
+ operation = opts[:operation] || :show
31
+ end
32
+ if metadata.is_a?(ActiveRecord::Base) || (metadata.is_a?(Class) &&
33
+ metadata < ActiveRecord::Base)
34
+ metadata = filtered_attrs(metadata, operation, opts)
35
+ end
36
+ return metadata unless metadata.is_a?(Hash) && metadata.present?
37
+ if [:filter, :sort].include?(operation)
38
+ return Hash[metadata.map { |a, m| [ext_query_attr(a, m), m] }]
39
+ end
40
+ Hash[metadata.map { |a, m| [ext_attr(a, m), m] }]
41
+ end
42
+
43
+ def parse_request_body(request)
44
+ request_body = request.body.read.to_s.strip
45
+ parsed_request_body = nil
46
+ if request.env['API_CONTENT_TYPE'] == :xml ||
47
+ request_body.start_with?('<')
48
+ parsed_request_body = Hash.from_xml(request_body) rescue nil
49
+ detected_format = :xml
50
+ end
51
+ unless parsed_request_body.present?
52
+ parsed_request_body = JSON.parse(request_body) rescue nil
53
+ detected_format = :json
54
+ end
55
+ [parsed_request_body, detected_format]
56
+ end
57
+
58
+ def ext_attr(attr, attr_metadata = {})
59
+ sym = attr.is_a?(Symbol)
60
+ ext_attr = attr_metadata[:alias] || attr
61
+ ext_attr = ext_attr.to_s.camelize(:lower) if CAMELCASE_CONVERSION
62
+ sym ? ext_attr.to_sym : ext_attr.to_s
63
+ end
64
+
65
+ def ext_query_attr(attr, attr_metadata = {})
66
+ sym = attr.is_a?(Symbol)
67
+ ext_attr = attr_metadata[:alias] || attr
68
+ ext_attr = ext_attr.to_s.underscore
69
+ sym ? ext_attr.to_sym : ext_attr.to_s
70
+ end
71
+
72
+ def ext_value(value, opts = {})
73
+ return value unless CAMELCASE_CONVERSION
74
+ if value.is_a?(Hash)
75
+ ext_hash(value, opts)
76
+ elsif value.respond_to?(:map)
77
+ value.map { |v| ext_value(v, opts) }
78
+ else
79
+ value
80
+ end
81
+ end
82
+
83
+ def internal_value(value, opts = {})
84
+ return value unless CAMELCASE_CONVERSION
85
+ if value.is_a?(Hash)
86
+ internal_hash(value, opts)
87
+ elsif value.respond_to?(:map)
88
+ value.map { |v| internal_value(v, opts) }
89
+ else
90
+ value
91
+ end
92
+ end
93
+
94
+ def api_links(obj_or_class)
95
+ klass = obj_or_class.is_a?(Class) ? obj_or_class : obj_or_class.class
96
+ return {} unless klass.respond_to?(:api_links)
97
+ klass.api_links.dup
98
+ end
99
+
100
+ def filtered_links(obj_or_class, operation, opts = {})
101
+ return {} if obj_or_class.nil? || eval_bool(obj_or_class, opts[:exclude_api_links], opts)
102
+ klass = obj_or_class.is_a?(Class) ? obj_or_class : obj_or_class.class
103
+ filtered_metadata(api_links(klass), klass, operation, opts)
104
+ end
105
+
106
+ def eval_can(criteria, context, action_type, controller)
107
+ return false unless criteria.present? && context.present? &&
108
+ action_type.present? && controller.respond_to?(:can?)
109
+ controller.can?(criteria, context)
110
+ end
111
+
112
+ def eval_bool(obj, expr, opts = {})
113
+ if expr.is_a?(Hash) && opts.blank?
114
+ obj = nil
115
+ opts = expr
116
+ end
117
+ if expr.respond_to?(:call)
118
+ return invoke_callback(expr, *([obj, opts].compact)) ? true : false
119
+ end
120
+ expr ? true : false
121
+ end
122
+
123
+ def transform_value(value, transform_method_or_proc, opts = {})
124
+ return value unless transform_method_or_proc.present?
125
+ if (transform_method_or_proc.is_a?(String) || transform_method_or_proc.is_a?(Symbol)) &&
126
+ value.respond_to?(transform_method_or_proc.to_sym)
127
+ return value.send(transform_method_or_proc.to_sym)
128
+ end
129
+ value = value.symbolize_keys if value.is_a?(Hash)
130
+ return value unless transform_method_or_proc.respond_to?(:call)
131
+ invoke_callback(transform_method_or_proc, value, opts.freeze)
132
+ end
133
+
134
+ def http_status_code(status)
135
+ unless status.is_a?(Symbol)
136
+ status_num = status.to_s.to_i
137
+ if status_num.to_s == status.to_s
138
+ unless Rack::Utils::HTTP_STATUS_CODES.include?(status_num)
139
+ fail "Invalid / unrecognized HTTP status code: #{status_num}"
140
+ end
141
+ return status_num
142
+ end
143
+ status = status.to_s.to_sym
144
+ end
145
+ status_code = Rack::Utils::SYMBOL_TO_STATUS_CODE[status]
146
+ fail "Invalid / unrecognized HTTP status: #{status}" unless status_code.present?
147
+ status_code
148
+ end
149
+
150
+ def http_status(status)
151
+ unless status.is_a?(Fixnum)
152
+ status_num = status.to_s.to_i
153
+ if status_num.to_s != status.to_s
154
+ status_sym = status.to_s.to_sym
155
+ unless Rack::Utils::SYMBOL_TO_STATUS_CODE.include?(status_sym)
156
+ fail "Invalid / unrecognized HTTP status: #{status_sym}"
157
+ end
158
+ return status_sym
159
+ end
160
+ status = status_num
161
+ end
162
+ status_string = Rack::Utils::HTTP_STATUS_CODES[status]
163
+ fail "Invalid / unrecognized HTTP status code: #{status}" unless status_string.present?
164
+ status_string.downcase.gsub(/\s|-/, '_').to_sym
165
+ end
166
+
167
+ def response_successful?(response_status)
168
+ http_status_code(response_status) < 400
169
+ end
170
+
171
+ def assoc_opts(assoc, attr_metadata, opts)
172
+ contextual_metadata_opts(attr_metadata, opts.merge(association: assoc))
173
+ end
174
+
175
+ # Build options to generate metadata for a special context, e.g. for an object nested inside
176
+ # of a parent object.
177
+ def contextual_metadata_opts(attr_metadata, opts = {})
178
+ context_opts = opts
179
+ if (obj_metadata = attr_metadata[:attributes]).present?
180
+ context_opts = context_opts.merge(metadata: obj_metadata)
181
+ end
182
+ except_attrs = attr_metadata[:except_attrs] || []
183
+ if (assoc = opts[:association]).present? &&
184
+ ![:belongs_to, :has_and_belongs_to_many].include?(assoc.macro) &&
185
+ !assoc.through_reflection.present?
186
+ except_attrs << assoc.foreign_key.to_sym
187
+ end
188
+ if except_attrs.present?
189
+ context_opts = context_opts.merge(except: (context_opts[:except] || []) +
190
+ except_attrs.compact.map(&:to_sym).uniq)
191
+ end
192
+ context_opts
193
+ end
194
+
195
+ def set_open_api_type_and_format(properties, type_name)
196
+ open_api_type, open_api_format = OpenApi::Utils.open_api_type_and_format(type_name)
197
+ if open_api_type.nil?
198
+ open_api_type = :string
199
+ open_api_format = type_name
200
+ end
201
+ if open_api_type.present?
202
+ properties[:type] = open_api_type
203
+ properties[:format] = open_api_format if open_api_format.present?
204
+ end
205
+ properties
206
+ end
207
+
208
+ def model_metadata(klass)
209
+ return klass.api_model if klass.respond_to?(:api_model)
210
+ {}
211
+ end
212
+
213
+ def model_name(klass)
214
+ model_alias = (model_metadata(klass) || {})[:alias]
215
+ ActiveModel::Name.new(klass, nil, model_alias.present? ? model_alias.to_s : nil)
216
+ end
217
+
218
+ def format_value(value, attr_metadata, opts)
219
+ ModelApi::Utils.transform_value(value, attr_metadata[:render], opts)
220
+ rescue Exception => e
221
+ Rails.logger.warn 'Error encountered formatting API output ' \
222
+ "(\"#{e.message}\") for value: \"#{value}\"" \
223
+ ' ... rendering unformatted value instead.'
224
+ value
225
+ end
226
+
227
+ def not_found_response_body(opts = {})
228
+ response =
229
+ {
230
+ successful: false,
231
+ status: :not_found,
232
+ status_code: http_status_code(:not_found),
233
+ errors: [{
234
+ error: opts[:error] || 'No resource found',
235
+ message: opts[:message] || 'No resource found at the path ' \
236
+ 'provided or matching the criteria specified'
237
+ }]
238
+ }
239
+ response.to_json(opts)
240
+ end
241
+
242
+ def invoke_callback(callback, *params)
243
+ return nil unless callback.respond_to?(:call)
244
+ callback.send(*(([:call] + params)[0..callback.parameters.size]))
245
+ end
246
+
247
+ def common_http_headers
248
+ {
249
+ 'Cache-Control' => 'no-cache, no-store, max-age=0, must-revalidate',
250
+ 'Pragma' => 'no-cache',
251
+ 'Expires' => 'Fri, 01 Jan 1990 00:00:00 GMT'
252
+ }
253
+ end
254
+
255
+ # Transforms request and response to match conventions (i.e. using camelcase attrs if
256
+ # configured, and the standard response envelope)
257
+ def translate_external_api_filter(controller, opts = {}, &block)
258
+ request = controller.request
259
+ json, _format = parse_request_body(request)
260
+ json = internal_value(json)
261
+ json.each { |k, v| request.parameters[k] = request.POST[k] = v } if json.is_a?(Hash)
262
+
263
+ block.call
264
+
265
+ obj = controller.response_body.first if controller.response_body.is_a?(Array)
266
+ obj = (JSON.parse(obj) rescue nil) if obj.present?
267
+ opts = opts.merge(generate_body_only: true)
268
+ controller.response_body = [ModelApi::Renderer.render(controller,
269
+ ModelApi::Utils.ext_value(obj), opts)]
270
+ end
271
+
272
+ private
273
+
274
+ def action_filter(klass, filter_value, test_value, opts = {})
275
+ filter_type = opts[:filter_type] || :operation
276
+ if test_value.is_a?(Array)
277
+ return test_value.map(&:to_sym).include?(filter_value)
278
+ elsif test_value.is_a?(Hash)
279
+ test_value = test_value[filter_value]
280
+ end
281
+ if test_value.respond_to?(:call)
282
+ return ModelApi::Utils.invoke_callback(test_value, klass,
283
+ opts.merge(filter_type => filter_value).freeze)
284
+ end
285
+ filter_value == test_value
286
+ end
287
+
288
+ def filtered_metadata(metadata, klass, operation, opts = {})
289
+ if (only = opts[:only]).present?
290
+ metadata.select! { |k, _v| only.include?(k) }
291
+ end
292
+ if (except = opts[:except]).present?
293
+ metadata.reject! { |k, _v| except.include?(k) }
294
+ end
295
+ if (metadata_overrides = opts[:metadata]).present?
296
+ metadata = merge_metadata_overrides(metadata, metadata_overrides)
297
+ end
298
+ metadata.select! do |_attr, attr_metadata|
299
+ include_item?(attr_metadata, klass, operation, opts)
300
+ end
301
+ metadata
302
+ end
303
+
304
+ def include_item?(metadata, obj, operation, opts = {})
305
+ return false unless metadata.is_a?(Hash)
306
+ return false unless include_item_meets_admin_criteria?(metadata, obj, opts)
307
+ # Stop here re: filter/sort params, as following checks involve payloads/responses only
308
+ return eval_bool(obj, metadata[:filter], opts) if operation == :filter
309
+ return eval_bool(obj, metadata[:sort], opts) if operation == :sort
310
+ return false unless include_item_meets_read_write_criteria?(metadata, obj, operation, opts)
311
+ return false unless include_item_meets_incl_excl_criteria?(metadata, obj, operation, opts)
312
+ true
313
+ end
314
+
315
+ def merge_metadata_overrides(metadata, metadata_overrides)
316
+ if metadata_overrides.is_a?(Hash)
317
+ Hash[
318
+ expand_metadata(metadata_overrides).map do |key, item_metadata|
319
+ [key, item_metadata.reverse_merge(metadata[key] || {}).reverse_merge(key: key)]
320
+ end
321
+ ]
322
+ elsif metadata_overrides.is_a?(Array)
323
+ metadata.select { |key, _m| metadata_overrides.include?(key) }
324
+ else
325
+ metadata
326
+ end
327
+ end
328
+
329
+ def expand_metadata(metadata)
330
+ Hash[metadata.map { |k, v| [k, v.is_a?(Hash) ? v : { value: v, read_only: true }] }]
331
+ end
332
+
333
+ def ext_hash(hash, opts = {})
334
+ return hash unless CAMELCASE_CONVERSION
335
+ Hash[hash.map do |key, value|
336
+ sym = key.is_a?(Symbol)
337
+ key = key.to_s.camelize(:lower)
338
+ value = ext_value(value, opts)
339
+ [sym ? key.to_sym : key, value]
340
+ end]
341
+ end
342
+
343
+ def internal_hash(hash, opts = {})
344
+ return hash unless CAMELCASE_CONVERSION
345
+ Hash[hash.map do |key, value|
346
+ sym = key.is_a?(Symbol)
347
+ key = key.to_s.underscore
348
+ value = internal_value(value, opts)
349
+ [sym ? key.to_sym : key, value]
350
+ end]
351
+ end
352
+
353
+ def include_item_meets_admin_criteria?(metadata, obj, opts = {})
354
+ if eval_bool(obj, metadata[:admin_only], opts)
355
+ if opts.include?(:admin)
356
+ return false unless opts[:admin]
357
+ else
358
+ return false unless opts[:user].try(:admin_api_user?)
359
+ end
360
+ end
361
+ return false if eval_bool(obj, metadata[:admin_content], opts) && !opts[:admin_content]
362
+ true
363
+ end
364
+
365
+ def include_item_meets_read_write_criteria?(metadata, obj, operation, opts = {})
366
+ if [:create, :update, :patch].include?(operation)
367
+ return false if eval_bool(obj, metadata[:read_only], opts)
368
+ else
369
+ return false if eval_bool(obj, metadata[:write_only], opts)
370
+ end
371
+ true
372
+ end
373
+
374
+ def include_item_meets_incl_excl_criteria?(metadata, obj, operation, opts = {})
375
+ if (only = metadata[:only]).present?
376
+ return false unless action_filter(obj, operation, only, opts)
377
+ end
378
+ if (except = metadata[:except]).present?
379
+ return false if action_filter(obj, operation, except, opts)
380
+ end
381
+ action = opts[:action]
382
+ if (only = metadata[:only_actions]).present?
383
+ return false unless action_filter(obj, action, only, opts.merge(filter_type: :action))
384
+ end
385
+ if (except = metadata[:except_actions]).present?
386
+ return false if action_filter(obj, action, except, opts.merge(filter_type: :action))
387
+ end
388
+ true
389
+ end
390
+ end
391
+ end
392
+ end