22232: Explain why we don't use pg_column_size to limit database reads
[arvados.git] / services / api / app / controllers / application_controller.rb
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: AGPL-3.0
4
5 require 'safe_json'
6 require 'request_error'
7
8 module ApiTemplateOverride
9   def allowed_to_render?(fieldset, field, model, options)
10     return false if !super
11     if options[:select]
12       options[:select].include? field.to_s
13     else
14       true
15     end
16   end
17 end
18
19 class ActsAsApi::ApiTemplate
20   prepend ApiTemplateOverride
21 end
22
23 require 'load_param'
24
25 class ApplicationController < ActionController::Base
26   include CurrentApiClient
27   include LoadParam
28   include DbCurrentTime
29
30   respond_to :json
31
32   # Although CSRF protection is already enabled by default, this is
33   # still needed to reposition CSRF checks later in callback order.
34   protect_from_forgery
35
36   ERROR_ACTIONS = [:render_error, :render_not_found]
37
38   around_action :set_current_request_id
39   before_action :disable_api_methods
40   before_action :set_cors_headers
41   before_action :respond_with_json_by_default
42   before_action :remote_ip
43   before_action :load_read_auths
44   before_action :require_auth_scope, except: ERROR_ACTIONS
45
46   before_action :catch_redirect_hint
47   before_action :load_required_parameters
48   before_action :load_limit_offset_order_params, only: [:index, :contents]
49   before_action :load_select_param
50   before_action(:find_object_by_uuid,
51                 except: [:index, :create, :update] + ERROR_ACTIONS)
52   before_action :find_object_for_update, only: [:update]
53   before_action :load_where_param, only: [:index, :contents]
54   before_action :load_filters_param, only: [:index, :contents]
55   before_action :find_objects_for_index, :only => :index
56   before_action(:set_nullable_attrs_to_null, only: [:update, :create])
57   before_action :reload_object_before_update, :only => :update
58   before_action(:render_404_if_no_object,
59                 except: [:index, :create] + ERROR_ACTIONS)
60   before_action :only_admin_can_bypass_federation
61
62   attr_writer :resource_attrs
63
64   begin
65     rescue_from(Exception,
66                 ArvadosModel::PermissionDeniedError,
67                 :with => :render_error)
68     rescue_from(ActiveRecord::RecordNotFound,
69                 ActionController::RoutingError,
70                 AbstractController::ActionNotFound,
71                 :with => :render_not_found)
72   end
73
74   def initialize *args
75     super
76     @object = nil
77     @objects = nil
78     @offset = nil
79     @limit = nil
80     @select = nil
81     @distinct = nil
82     @response_resource_name = nil
83     @attrs = nil
84     @extra_included = nil
85   end
86
87   def default_url_options
88     options = {}
89     if Rails.configuration.Services.Controller.ExternalURL != URI("")
90       exturl = Rails.configuration.Services.Controller.ExternalURL
91       options[:host] = exturl.host
92       options[:port] = exturl.port
93       options[:protocol] = exturl.scheme
94     end
95     options
96   end
97
98   def index
99     if params[:eager] and params[:eager] != '0' and params[:eager] != 0 and params[:eager] != ''
100       @objects.each(&:eager_load_associations)
101     end
102     render_list
103   end
104
105   def show
106     send_json @object.as_api_response(nil, select: select_for_klass(@select, model_class))
107   end
108
109   def create
110     @object = model_class.new resource_attrs
111
112     if @object.respond_to?(:name) && params[:ensure_unique_name]
113       @object.save_with_unique_name!
114     else
115       @object.save!
116     end
117
118     show
119   end
120
121   def update
122     attrs_to_update = resource_attrs.reject { |k,v|
123       [:kind, :etag, :href].index k
124     }
125     @object.update! attrs_to_update
126     show
127   end
128
129   def destroy
130     @object.destroy
131     show
132   end
133
134   def catch_redirect_hint
135     if !current_user
136       if params.has_key?('redirect_to') then
137         session[:redirect_to] = params[:redirect_to]
138       end
139     end
140   end
141
142   def render_404_if_no_object
143     render_not_found "Object not found" if !@object
144   end
145
146   def only_admin_can_bypass_federation
147     unless !params[:bypass_federation] || current_user.andand.is_admin
148       send_error("The bypass_federation parameter is only permitted when current user is admin", status: 403)
149     end
150   end
151
152   def render_error(e)
153     logger.error e.inspect
154     if e.respond_to? :backtrace and e.backtrace
155       # This will be cleared by lograge after adding it to the log.
156       # Usually lograge would get the exceptions, but in our case we're catching
157       # all of them with exception handlers that cannot re-raise them because they
158       # don't get propagated.
159       Thread.current[:exception] = e.inspect
160       Thread.current[:backtrace] = e.backtrace.collect { |x| x + "\n" }.join('')
161     end
162     if (@object.respond_to? :errors and
163         @object.errors.andand.full_messages.andand.any?)
164       errors = @object.errors.full_messages
165       logger.error errors.inspect
166     else
167       errors = [e.inspect]
168     end
169     status = e.respond_to?(:http_status) ? e.http_status : 422
170     send_error(*errors, status: status)
171   end
172
173   def render_not_found(e=ActionController::RoutingError.new("Path not found"))
174     logger.error e.inspect
175     send_error("Path not found", status: 404)
176   end
177
178   def render_accepted
179     send_json ({accepted: true}), status: 202
180   end
181
182   protected
183
184   def bool_param(pname)
185     if params.include?(pname)
186       if params[pname].is_a?(Boolean)
187         return params[pname]
188       else
189         logger.warn "Warning: received non-boolean value #{params[pname].inspect} for boolean parameter #{pname} on #{self.class.inspect}, treating as false."
190       end
191     end
192     false
193   end
194
195   def send_error(*args)
196     if args.last.is_a? Hash
197       err = args.pop
198     else
199       err = {}
200     end
201     err[:errors] ||= args
202     err[:errors].map! do |err|
203       err += " (#{request.request_id})"
204     end
205     err[:error_token] = [Time.now.utc.to_i, "%08x" % rand(16 ** 8)].join("+")
206     status = err.delete(:status) || 422
207     logger.error "Error #{err[:error_token]}: #{status}"
208     send_json err, status: status
209   end
210
211   def send_json response, opts={}
212     # The obvious render(json: ...) forces a slow JSON encoder. See
213     # #3021 and commit logs. Might be fixed in Rails 4.1.
214     render({
215              plain: SafeJSON.dump(response).html_safe,
216              content_type: 'application/json'
217            }.merge opts)
218   end
219
220   def find_objects_for_index
221     @objects ||= model_class.readable_by(*@read_users, {
222       :include_trash => ((self.class._index_requires_parameters[:include_trash] && bool_param(:include_trash)) || 'untrash' == action_name),
223       :include_old_versions => self.class._index_requires_parameters[:include_old_versions] && bool_param(:include_old_versions)
224     })
225     apply_where_limit_order_params
226   end
227
228   def apply_filters model_class=nil
229     model_class ||= self.model_class
230     @objects = model_class.apply_filters(@objects, @filters)
231   end
232
233   def select_for_klass sel, model_class, raise_unknown=true
234     return nil if sel.nil?
235     # Filter the select fields to only the ones that apply to the
236     # given class.
237     sel.map do |column|
238       sp = column.split(".")
239       if sp.length == 2 && sp[0] == model_class.table_name && model_class.selectable_attributes.include?(sp[1])
240         sp[1]
241       elsif model_class.selectable_attributes.include? column
242         column
243       elsif raise_unknown
244         raise ArgumentError.new("Invalid attribute '#{column}' of #{model_class.name} in select parameter")
245       else
246         nil
247       end
248     end.compact
249   end
250
251   def apply_where_limit_order_params model_class=nil
252     model_class ||= self.model_class
253     apply_filters model_class
254
255     ar_table_name = @objects.table_name
256     if @where.is_a? Hash and @where.any?
257       conditions = ['1=1']
258       @where.each do |attr,value|
259         if attr.to_s == 'any'
260           if value.is_a?(Array) and
261               value.length == 2 and
262               value[0] == 'contains' then
263             ilikes = []
264             model_class.searchable_columns('ilike').each do |column|
265               # Including owner_uuid in an "any column" search will
266               # probably just return a lot of false positives.
267               next if column == 'owner_uuid'
268               ilikes << "#{ar_table_name}.#{column} ilike ?"
269               conditions << "%#{value[1]}%"
270             end
271             if ilikes.any?
272               conditions[0] << ' and (' + ilikes.join(' or ') + ')'
273             end
274           end
275         elsif attr.to_s.match(/^[a-z][_a-z0-9]+$/) and
276             model_class.columns.collect(&:name).index(attr.to_s)
277           if value.nil?
278             conditions[0] << " and #{ar_table_name}.#{attr} is ?"
279             conditions << nil
280           elsif value.is_a? Array
281             if value[0] == 'contains' and value.length == 2
282               conditions[0] << " and #{ar_table_name}.#{attr} like ?"
283               conditions << "%#{value[1]}%"
284             else
285               conditions[0] << " and #{ar_table_name}.#{attr} in (?)"
286               conditions << value
287             end
288           elsif value.is_a? String or value.is_a? Integer or value == true or value == false
289             conditions[0] << " and #{ar_table_name}.#{attr}=?"
290             conditions << value
291           elsif value.is_a? Hash
292             # Not quite the same thing as "equal?" but better than nothing?
293             value.each do |k,v|
294               if v.is_a? String
295                 conditions[0] << " and #{ar_table_name}.#{attr} ilike ?"
296                 conditions << "%#{k}%#{v}%"
297               end
298             end
299           end
300         end
301       end
302       if conditions.length > 1
303         conditions[0].sub!(/^1=1 and /, '')
304         @objects = @objects.
305           where(*conditions)
306       end
307     end
308
309     if @select
310       unless action_name.in? %w(create update destroy)
311         # Map attribute names in @select to real column names, resolve
312         # those to fully-qualified SQL column names, and pass the
313         # resulting string to the select method.
314         columns_list = model_class.columns_for_attributes(select_for_klass @select, model_class).
315           map { |s| "#{ar_table_name}.#{ActiveRecord::Base.connection.quote_column_name s}" }
316         @objects = @objects.select(columns_list.join(", "))
317       end
318
319       # This information helps clients understand what they're seeing
320       # (Workbench always expects it), but they can't select it explicitly
321       # because it's not an SQL column.  Always add it.
322       # (This is harmless, given that clients can deduce what they're
323       # looking at by the returned UUID anyway.)
324       @select |= ["kind"]
325     end
326     @objects = @objects.order(@orders.join ", ") if @orders.any?
327     @objects = @objects.limit(@limit)
328     @objects = @objects.offset(@offset)
329     @objects = @objects.distinct() if @distinct
330   end
331
332   # limit_database_read ensures @objects (which must be an
333   # ActiveRelation) does not return too many results to fit in memory,
334   # by previewing the results and calling @objects.limit() if
335   # necessary.
336   def limit_database_read(model_class:)
337     return if @limit == 0 || @limit == 1
338     model_class ||= self.model_class
339     limit_columns = model_class.limit_index_columns_read
340     limit_columns &= model_class.columns_for_attributes(select_for_klass @select, model_class) if @select
341     return if limit_columns.empty?
342     model_class.transaction do
343       # This query does not use `pg_column_size()` because the returned value
344       # can be smaller than the apparent length thanks to compression.
345       # `octet_length(::text)` better reflects how expensive it will be for
346       # Rails to process the data.
347       limit_query = @objects.
348         except(:select, :distinct).
349         select("(%s) as read_length" %
350                limit_columns.map { |s| "octet_length(#{model_class.table_name}.#{s}::text)" }.join(" + "))
351       new_limit = 0
352       read_total = 0
353       limit_query.each do |record|
354         new_limit += 1
355         read_total += record.read_length.to_i
356         if read_total >= Rails.configuration.API.MaxIndexDatabaseRead
357           new_limit -= 1 if new_limit > 1
358           @limit = new_limit
359           break
360         elsif new_limit >= @limit
361           break
362         end
363       end
364       @objects = @objects.limit(@limit)
365       # Force @objects to run its query inside this transaction.
366       @objects.each { |_| break }
367     end
368   end
369
370   def resource_attrs
371     return @attrs if @attrs
372     @attrs = params[resource_name]
373     if @attrs.nil?
374       @attrs = {}
375     elsif @attrs.is_a? String
376       @attrs = Oj.strict_load @attrs, symbol_keys: true
377     end
378     unless [Hash, ActionController::Parameters].include? @attrs.class
379       message = "No #{resource_name}"
380       if resource_name.index('_')
381         message << " (or #{resource_name.camelcase(:lower)})"
382       end
383       message << " hash provided with request"
384       raise ArgumentError.new(message)
385     end
386     %w(created_at modified_by_client_uuid modified_by_user_uuid modified_at).each do |x|
387       @attrs.delete x.to_sym
388     end
389     @attrs = @attrs.symbolize_keys if @attrs.is_a? ActiveSupport::HashWithIndifferentAccess
390     @attrs
391   end
392
393   # Authentication
394   def load_read_auths
395     @read_auths = []
396     if current_api_client_authorization
397       @read_auths << current_api_client_authorization
398     end
399     # Load reader tokens if this is a read request.
400     # If there are too many reader tokens, assume the request is malicious
401     # and ignore it.
402     if request.get? and params[:reader_tokens] and
403       params[:reader_tokens].size < 100
404       secrets = params[:reader_tokens].map { |t|
405         if t.is_a? String and t.starts_with? "v2/"
406           t.split("/")[2]
407         else
408           t
409         end
410       }
411       @read_auths += ApiClientAuthorization
412         .includes(:user)
413         .where('api_token IN (?) AND
414                 (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)',
415                secrets)
416         .to_a
417     end
418     @read_auths.select! { |auth| auth.scopes_allow_request? request }
419     @read_users = @read_auths.map(&:user).uniq
420   end
421
422   def require_login
423     if not current_user
424       respond_to do |format|
425         format.json { send_error("Not logged in", status: 401) }
426         format.html { redirect_to '/login' }
427       end
428       false
429     end
430   end
431
432   def admin_required
433     unless current_user and current_user.is_admin
434       send_error("Forbidden", status: 403)
435     end
436   end
437
438   def require_auth_scope
439     unless current_user && @read_auths.any? { |auth| auth.user.andand.uuid == current_user.uuid }
440       if require_login != false
441         send_error("Forbidden", status: 403)
442       end
443       false
444     end
445   end
446
447   def set_current_request_id
448     Rails.logger.tagged(request.request_id) do
449       yield
450     end
451   end
452
453   def append_info_to_payload(payload)
454     super
455     payload[:request_id] = request.request_id
456     payload[:client_ipaddr] = @remote_ip
457     payload[:client_auth] = current_api_client_authorization.andand.uuid || nil
458   end
459
460   def disable_api_methods
461     if Rails.configuration.API.DisabledAPIs[controller_name + "." + action_name]
462       send_error("Disabled", status: 404)
463     end
464   end
465
466   def set_cors_headers
467     response.headers['Access-Control-Allow-Origin'] = '*'
468     response.headers['Access-Control-Allow-Methods'] = 'GET, HEAD, PUT, POST, DELETE'
469     response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type'
470     response.headers['Access-Control-Max-Age'] = '86486400'
471   end
472
473   def respond_with_json_by_default
474     html_index = request.accepts.index(Mime[:html])
475     if html_index.nil? or request.accepts[0...html_index].include?(Mime[:json])
476       request.format = :json
477     end
478   end
479
480   def model_class
481     controller_name.classify.constantize
482   end
483
484   def resource_name             # params[] key used by client
485     controller_name.singularize
486   end
487
488   def table_name
489     controller_name
490   end
491
492   def find_object_for_update
493     find_object_by_uuid(with_lock: true)
494   end
495
496   def find_object_by_uuid(with_lock: false)
497     if params[:id] and params[:id].match(/\D/)
498       params[:uuid] = params.delete :id
499     end
500     @where = { uuid: params[:uuid] }
501     @offset = 0
502     @limit = 1
503     @orders = []
504     @filters = []
505     @objects = nil
506
507     # This is a little hacky but sometimes the fields the user wants
508     # to selecting on are unrelated to the object being loaded here,
509     # for example groups#contents, so filter the fields that will be
510     # used in find_objects_for_index and then reset afterwards.  In
511     # some cases, code that modifies the @select list needs to set
512     # @preserve_select.
513     @preserve_select = @select
514     @select = select_for_klass(@select, self.model_class, false)
515
516     find_objects_for_index
517     if with_lock && Rails.configuration.API.LockBeforeUpdate
518       @object = @objects.lock.first
519     else
520       @object = @objects.first
521     end
522     @select = @preserve_select
523   end
524
525   def nullable_attributes
526     []
527   end
528
529   # Go code may send empty values (ie: empty string instead of NULL) that
530   # should be translated to NULL on the database.
531   def set_nullable_attrs_to_null
532     nullify_attrs(resource_attrs.to_hash).each do |k, v|
533       resource_attrs[k] = v
534     end
535   end
536
537   def nullify_attrs(a = {})
538     new_attrs = a.to_hash.symbolize_keys
539     (new_attrs.keys & nullable_attributes).each do |attr|
540       val = new_attrs[attr]
541       if (val.class == Integer && val == 0) || (val.class == String && val == "")
542         new_attrs[attr] = nil
543       end
544     end
545     return new_attrs
546   end
547
548   def reload_object_before_update
549     # This is necessary to prevent an ActiveRecord::ReadOnlyRecord
550     # error when updating an object which was retrieved using a join.
551     if @object.andand.readonly?
552       @object = model_class.find_by_uuid(@objects.first.uuid)
553     end
554   end
555
556   def load_json_value(hash, key, must_be_class=nil)
557     return if hash[key].nil?
558
559     val = hash[key]
560     if val.is_a? ActionController::Parameters
561       val = val.to_unsafe_hash
562     elsif val.is_a? String
563       val = SafeJSON.load(val)
564       hash[key] = val
565     end
566     # When assigning a Hash to an ActionController::Parameters and then
567     # retrieve it, we get another ActionController::Parameters instead of
568     # a Hash. This doesn't happen with other types. This is why 'val' is
569     # being used to do type checking below.
570     if must_be_class and !val.is_a? must_be_class
571       raise TypeError.new("parameter #{key.to_s} must be a #{must_be_class.to_s}")
572     end
573   end
574
575   def self.accept_attribute_as_json(attr, must_be_class=nil)
576     before_action lambda { accept_attribute_as_json attr, must_be_class }
577   end
578   accept_attribute_as_json :properties, Hash
579   accept_attribute_as_json :info, Hash
580   def accept_attribute_as_json(attr, must_be_class)
581     if params[resource_name] and [Hash, ActionController::Parameters].include?(resource_attrs.class)
582       if resource_attrs[attr].is_a? Hash
583         # Convert symbol keys to strings (in hashes provided by
584         # resource_attrs)
585         resource_attrs[attr] = resource_attrs[attr].
586           with_indifferent_access.to_hash
587       else
588         load_json_value(resource_attrs, attr, must_be_class)
589       end
590     end
591   end
592
593   def self.accept_param_as_json(key, must_be_class=nil)
594     prepend_before_action lambda { load_json_value(params, key, must_be_class) }
595   end
596   accept_param_as_json :reader_tokens, Array
597
598   def object_list(model_class:)
599     if @objects.respond_to?(:except)
600       limit_database_read(model_class: model_class)
601     end
602     list = {
603       :kind  => "arvados##{(@response_resource_name || resource_name).camelize(:lower)}List",
604       :etag => "",
605       :self_link => "",
606       :offset => @offset,
607       :limit => @limit,
608       :items => @objects.as_api_response(nil, {select: @select})
609     }
610     if @extra_included
611       list[:included] = @extra_included.as_api_response(nil, {select: @select})
612     end
613     case params[:count]
614     when nil, '', 'exact'
615       if @objects.respond_to? :except
616         list[:items_available] = @objects.
617           except(:limit).except(:offset).
618           count(@distinct ? :id : '*')
619       end
620     when 'none'
621     else
622       raise ArgumentError.new("count parameter must be 'exact' or 'none'")
623     end
624     list
625   end
626
627   def render_list
628     send_json object_list(model_class: self.model_class)
629   end
630
631   def remote_ip
632     # Caveat: this is highly dependent on the proxy setup. YMMV.
633     if request.headers.key?('HTTP_X_REAL_IP') then
634       # We're behind a reverse proxy
635       @remote_ip = request.headers['HTTP_X_REAL_IP']
636     else
637       # Hopefully, we are not!
638       @remote_ip = request.env['REMOTE_ADDR']
639     end
640   end
641
642   def load_required_parameters
643     (self.class.send "_#{params[:action]}_requires_parameters" rescue {}).
644       each do |key, info|
645       if info[:required] and not params.include?(key)
646         raise ArgumentError.new("#{key} parameter is required")
647       elsif info[:type] == 'boolean'
648         # Make sure params[key] is either true or false -- not a
649         # string, not nil, etc.
650         if not params.include?(key)
651           params[key] = info[:default] || false
652         elsif [false, 'false', '0', 0].include? params[key]
653           params[key] = false
654         elsif [true, 'true', '1', 1].include? params[key]
655           params[key] = true
656         else
657           raise TypeError.new("#{key} parameter must be a boolean, true or false")
658         end
659       end
660     end
661     true
662   end
663
664   def self._create_requires_parameters
665     {
666       select: {
667         type: 'array',
668         description: "An array of names of attributes to return in the response.",
669         required: false,
670       },
671       ensure_unique_name: {
672         type: "boolean",
673         description: "If the given name is already used by this owner, adjust the name to ensure uniqueness instead of returning an error.",
674         location: "query",
675         required: false,
676         default: false
677       },
678       cluster_id: {
679         type: 'string',
680         description: "Cluster ID of a federated cluster where this object should be created.",
681         location: "query",
682         required: false,
683       },
684     }
685   end
686
687   def self._update_requires_parameters
688     {
689       select: {
690         type: 'array',
691         description: "An array of names of attributes to return in the response.",
692         required: false,
693       },
694     }
695   end
696
697   def self._show_requires_parameters
698     {
699       select: {
700         type: 'array',
701         description: "An array of names of attributes to return in the response.",
702         required: false,
703       },
704     }
705   end
706
707   def self._index_requires_parameters
708     {
709       filters: {
710         type: 'array',
711         required: false,
712         description: "Filters to limit which objects are returned by their attributes.
713 Refer to the [filters reference][] for more information about how to write filters.
714
715 [filters reference]: https://doc.arvados.org/api/methods.html#filters
716 ",
717       },
718       where: {
719         type: 'object',
720         required: false,
721         description: "An object to limit which objects are returned by their attributes.
722 The keys of this object are attribute names.
723 Each value is either a single matching value or an array of matching values for that attribute.
724 The `filters` parameter is more flexible and preferred.
725 ",
726       },
727       order: {
728         type: 'array',
729         required: false,
730         description: "An array of strings to set the order in which matching objects are returned.
731 Each string has the format `<ATTRIBUTE> <DIRECTION>`.
732 `DIRECTION` can be `asc` or omitted for ascending, or `desc` for descending.
733 ",
734       },
735       select: {
736         type: 'array',
737         description: "An array of names of attributes to return from each matching object.",
738         required: false,
739       },
740       distinct: {
741         type: 'boolean',
742         required: false,
743         default: false,
744         description: "If this is true, and multiple objects have the same values
745 for the attributes that you specify in the `select` parameter, then each unique
746 set of values will only be returned once in the result set.
747 ",
748       },
749       limit: {
750         type: 'integer',
751         required: false,
752         default: DEFAULT_LIMIT,
753         description: "The maximum number of objects to return in the result.
754 Note that the API may return fewer results than this if your request hits other
755 limits set by the administrator.
756 ",
757       },
758       offset: {
759         type: 'integer',
760         required: false,
761         default: 0,
762         description: "Return matching objects starting from this index.
763 Note that result indexes may change if objects are modified in between a series
764 of list calls.
765 ",
766       },
767       count: {
768         type: 'string',
769         required: false,
770         default: 'exact',
771         description: "A string to determine result counting behavior. Supported values are:
772
773   * `\"exact\"`: The response will include an `items_available` field that
774     counts the number of objects that matched this search criteria,
775     including ones not included in `items`.
776
777   * `\"none\"`: The response will not include an `items_avaliable`
778     field. This improves performance by returning a result as soon as enough
779     `items` have been loaded for this result.
780
781 ",
782       },
783       cluster_id: {
784         type: 'string',
785         description: "Cluster ID of a federated cluster to return objects from",
786         location: "query",
787         required: false,
788       },
789       bypass_federation: {
790         type: 'boolean',
791         required: false,
792         default: false,
793         description: "If true, do not return results from other clusters in the
794 federation, only the cluster that received the request.
795 You must be an administrator to use this flag.
796 ",
797       }
798     }
799   end
800
801   def render *opts
802     if opts.first
803       response = opts.first[:json]
804       if response.is_a?(Hash) &&
805           params[:_profile] &&
806           Thread.current[:request_starttime]
807         response[:_profile] = {
808           request_time: Time.now - Thread.current[:request_starttime]
809         }
810       end
811     end
812     super(*opts)
813   end
814 end