berkmancenter/lumendatabase

View on GitHub
app/controllers/search_controller.rb

Summary

Maintainability
A
1 hr
Test Coverage
# This class abstracts common functionality of Notices::SearchController and
# Entities::SearchController. It is not meant to be used on its own. It should
# be subclassed, and subclasses must define the following:
# - EACH_SERIALIZER
# - URL_ROOT
# - SEARCHED_MODEL
# - item_searcher
# - set_model_specific_variables
# They may also define html_responder.
class SearchController < ApplicationController
  before_action :set_model_specific_variables
  before_action :prevent_impossible_pagination
  before_action :restrict_deep_pagination

  layout 'search'

  EACH_SERIALIZER = nil
  URL_ROOT = nil

  def index
    if request.format.html? && current_user.nil? && !Rails.env.test?
      permitted = false

      if session[:captcha_permission]
        time_permission = session[:captcha_permission]
        permitted = true if time_permission > Time.now
      end

      unless permitted
        redirect_to(captcha_gateway_index_path(destination: CGI.escape(request.original_url))) and return
      end
    end

    @searcher = item_searcher
    @searchdata = @searcher.search
    @wrapped_instances = wrap_instances

    respond_to do |format|
      format.html { html_responder }
      format.json { json_renderer }
    end
  end

  private

  def html_responder; end

  def item_searcher; end

  def json_renderer
    # The self.class incantation is necessary to make instances look up their
    # subclass overrides to these constants, rather than pulling in the
    # original definition.
    render(
      json: {
        self.class::URL_ROOT => @wrapped_instances.map { |instance| self.class::EACH_SERIALIZER.new(instance) },
        meta: meta_hash_for(@searchdata)
      }
    )
  end

  # Enrich the activerecord object with search-related metadata for display.
  # Return the enriched instance (or nil, if none was found).
  def augment_instance(instance)
    return unless instance.present?

    result = @searchdata.select { |datum| datum[:_id] == instance.id.to_s }.first

    class << instance
      attr_accessor :_score, :highlight
    end

    instance._score = result[:_score]

    highlights = result[:highlight].presence || []
    instance.highlight = highlights.map { |h| h[1] }.flatten

    instance
  end

  def sort_by(sort_by_param)
    ResultOrdering.define(sort_by_param, @model_class).sort_by
  end

  def wrap_instances
    # #records fetches the database instances while maintaining the search
    # response ordering.
    # Note that the search definition above is lazy; this is the first line
    # where anything with Elasticsearch actually gets executed.
    instances = @searchdata.records
    instances.map { |r| augment_instance(r) }
  end

  # Elasticsearch cannot return more than 20_000 results in production (2000
  # pages at 10 results per page).
  def prevent_impossible_pagination
    return if num_results < 20_001

    render 'shared/_error',
           status: :not_found,
           locals: {
             message: 'Lumen cannot display beyond the 20,000th result. ' \
                      'Try a more specific query.'
           }
  end

  # Deep pagination is expensive for the CPU, so don't let anonymous users
  # do it.
  def restrict_deep_pagination
    return if pagination_allowed?

    render 'shared/_error',
           status: :unauthorized,
           locals: {
             message: 'You must be logged in to see past the first 100 ' \
                      'results. ' \
                      '<a href="https://lumendatabase.org/pages/researchers#key">Request ' \
                      'a research account key</a>.'.html_safe
           }
  end

  def pagination_allowed?
    [user_signed_in?,
     num_results < 101,
     request.format.json? && num_results < 20_001].any?
  end

  def num_results
    params[:page].to_i * (params[:per_page] || 10 ).to_i
  end

  def meta_hash_for(results)
    %i[
      current_page next_page offset per_page
      previous_page total_entries total_pages
    ].each_with_object(query_meta(results)) do |attribute, memo|
      begin
        memo[attribute] = results.send(attribute)
      rescue
        memo[attribute] = nil
      end
    end
  end

  def query_meta(results)
    {
      query: {
        term: params[:term]
      }.merge(facet_query_meta(results) || {}),
      facets: results.response.aggregations
    }
  end

  def facet_query_meta(results)
    results.response.aggregations && results.response.aggregations.keys.each_with_object({}) do |facet, memo|
      memo[facet.to_sym] = params[facet.to_sym] if params[facet.to_sym].present?
    end
  end

  def set_model_specific_variables; end
end