module Qa # Provide pagination processing used to respond to requests for paginated results. # # Defaults for page_offset and page_limit: (see example responses under #build_response) # # format == :json # # * if neither page_offset nor page_limit is passed in, then... (backward compatible) # * returns results as an Array # * returns all results # * page_offset is "1" # * page_limit is total_num_found # # * if either page_offset or page_limit is passed in, then... # * returns results as an Array # * returns a page of results # * default for page_offset is "1" # * default for page_limit is DEFAULT_PAGE_LIMIT (i.e., 10) # # format == :jsonapi # # * response is always in the jsonapi format # * results are always paginated # * default for page_offset is "1" # * default for page_limit is DEFAULT_PAGE_LIMIT (i.e., 10) # # How page_offset is calculated for pagination links: # # expected page boundaries # # * Expected page boundaries are always calculated starting from page_offset=1 # and the current page_limit. The page boundaries will include the page_offsets # that cover all results. For example, page_limit=10 with 36 results will have # page boundaries 1, 11, 21, 31. # # self url # # * The self url always has the page_offset for the current page, which defaults # to 1 if not passed in. # # first page url # # * The first page url always has page_offset=1. # # last page url # # * The last page url always has page_offset equal to the last of the expected page # boundaries regardless of the passed in page_offset. For the example where # page_limit=10 with 36 results, the last page will always have page_offset=31. # # prev page url # # * Previous' page_offset is calculated from the passed in page_offset whether or # not it is on an expected page boundary. # # * For prev, page_offset = passed in page_offset - page_limit || nil if calculated as < 1 # * when current page_offset (e.g. 1) is less than page_limit (e.g. 10), then page_offset # for prev will be nil (e.g. 1 - 10 = -9 which is < 1) # * when current page_offset is an expected page boundary (e.g. 21), then # page_offset for prev will also be a page boundary (e.g. 21 - 10 = 11 # which is an expected page boundary) # * when current page_offset is not on an expected page boundary (e.g. 13), then # page_offset for prev will not be on an expected page boundary (e.g. 13 - 10 = 3 # which is not an expected page boundary) # # next page url # # * Next's page_offset is calculated from the passed in page_offset whether or # not it is on an expected page boundary. # # * For next, page_offset = passed in page_offset + page_limit || nil if calculated > total number of results found # * when current page_offset (e.g. 31) is greater than total number of results (e.g. 36), # then page_offset for next will be nil (e.g. 31 + 10 = 41 which is > 36) # * when current page_offset is an expected page boundary (e.g. 21), then # page_offset for next will also be a page boundary (e.g. 21 + 10 = 31 # which is an expected page boundary) # * when current page_offset is not on an expected page boundary (e.g. 13), then # page_offset for next will not be on an expected page boundary (e.g. 13 + 10 = 23 # which is not an expected page boundary) # class PaginationService # rubocop:disable Metrics/ClassLength # Default page_limit to use if not passed in with the request. DEFAULT_PAGE_LIMIT = 10 # Error code for page_limit and page_offset when the value is not an integer. ERROR_NOT_INTEGER = 901 # Error code for page_limit and page_offset when the value is below the acceptable range (e.g. < 1). ERROR_OUT_OF_RANGE_TOO_SMALL = 902 # Error code for page_offset when the value is above the acceptable range (e.g. > total_num_found). ERROR_OUT_OF_RANGE_TOO_LARGE = 903 # @param request [ActionDispatch::Request] The request from the controller. # To support pagination, it's params need to respond to: # * #page_offset [Integer] - the offset into the results for the start of the page (counts from 1; default: 1) # * #page_limit [Integer] - the max number of records to return in a page # * if format==:jsonapi, defaults to: DEFAULT_PAGE_LIMIT # * if page_offset is passed in, defaults to: DEFAULT_PAGE_LIMIT # * else when format==:json && page_offset.nil?, defaults to all results (backward compatible) # @param results [Array] results of a search query as processed by the authority module # @param format [String] - if present, supported values are [:json | :jsonapi] # * when :json, the response is an array of results (default) # * when :jsonapi, the response follows the JSON API specification # # @see https://jsonapi.org/format/#fetching-pagination Pagination section of JSON API specification # @see https://jsonapi.org/examples/#pagination JSON API example pagination def initialize(request:, results:, format: :json) @request = request @results = results @requested_format = format @page_offset_error = false @page_limit_error = false end # @return json results, optionally limited to requested page and optionally # formatted according to the JSON-API standard. The default is to return # just the results for backward compatibility. See examples. # # @example json without pagination (backward compatible) (used only if neither page_offset nor page_limit are passed in) # # request: q=term # # response: format=json, no pagination, all results # [ # { "id": "1", "label": "term 1" }, # { "id": "2", "label": "term 2" }, # ... # { "id": "28", "label": "term 28" } # ] # # @example json with pagination (used if either page_offset or page_limit are passed in) # # request: q=term, page_offset=3, page_limit=2 # # response: format=json, paginated, results 3..4 # [ # { "id": "3", "label": "term 3" }, # { "id": "4", "label": "term 4" } # ] # # @example json-api with pagination using default page_offset and page_limit # # request: q=term, format=json-api # # response: format=json-api, paginated, results 1..10 # { # "data": [ # { "id": "1", "label": "term 1" }, # { "id": "2", "label": "term 2" }, # ... # { "id": "10", "label": "term 10" } # ], # "meta": { # "page": { # "page_offset": "1", # "page_limit": "10", # "actual_page_size": "10", # "total_num_found": "28", # } # } # "links": { # "self_url": "http://example.com/qa/search/local/states?q=new&format=json-api&page_limit=10&page_offset=1", # "first_url": "http://example.com/qa/search/local/states?q=new&format=json-api&page_limit=10&page_offset=1", # "prev_url": nil, # "next_url": "http://example.com/qa/search/local/states?q=new&format=json-api&page_limit=10&page_offset=11", # "last_url": "http://example.com/qa/search/local/states?q=new&format=json-api&page_limit=10&page_offset=21" # } # } # # @example json-api with pagination for page_offset=7 and page_limit=2 # # request: q=term, format=json-api, page_offset=7, page_limit=2 # # response: format=json, paginated, results 7..8 # { # "data": [ # { "id": "7", "label": "term 7" }, # { "id": "8", "label": "term 8" } # ], # "meta": { # "page": { # "page_offset": "7", # "page_limit": "2", # "actual_page_size": "2", # "total_num_found": "28", # } # } # "links": { # "self_url": "http://example.com/qa/search/local/states?q=new&format=json-api&page_limit=2&page_offset=7", # "first_url": "http://example.com/qa/search/local/states?q=new&format=json-api&page_limit=2&page_offset=1", # "prev_url": "http://example.com/qa/search/local/states?q=new&format=json-api&page_limit=2&page_offset=5", # "next_url": "http://example.com/qa/search/local/states?q=new&format=json-api&page_limit=2&page_offset=9", # "last_url": "http://example.com/qa/search/local/states?q=new&format=json-api&page_limit=2&page_offset=27" # } # } # # @example json-api with page_offset and page_limit errors # # request: q=term, format=json-api, page_offset=0, page_limit=-1 # # response: format=json-api, paginated, no results, errors # { # "data": [], # "meta": { # "page": { # "page_offset": "0", # "page_limit": "-1", # "actual_page_size": nil, # "total_num_found": "28", # } # } # "links": { # "self_url": "http://example.com/qa/search/local/states?q=new&format=json-api&page_limit=-1&page_offset=0", # "first_url": "http://example.com/qa/search/local/states?q=new&format=json-api&page_limit=10&page_offset=1", # "prev_url": nil, # "next_url": nil, # "last_url": "http://example.com/qa/search/local/states?q=new&format=json-api&page_limit=10&page_offset=21" # } # "errors": [ # { # "status" => "200", # "source" => { "page_offset" => "0" }, # "title" => "Page Offset Out of Range", # "detail" => "Offset 0 < 1 (first result). Returning empty results." # }, # { # "status" => "200", # "source" => { "page_limit" => "-1" }, # "title" => "Page Limit Out of Range", # "detail" => "Page limit -1 < 1 (minimum limit). Returning empty results." # # } # ] # } # # @see DEFAULT_PAGE_LIMIT # @see ERROR_NOT_INTEGER # @see ERROR_OUT_OF_RANGE_TOO_SMALL # @see ERROR_OUT_OF_RANGE_TOO_LARGE def build_response json_api? ? build_json_api_response : build_json_response end private def errors? page_offset_error? || page_limit_error? end def page_offset_error? page_offset @page_offset_error end def page_limit_error? page_limit @page_limit_error end # @return just the data as a JSON array def build_json_response errors? ? [] : build_data end # @return pages of results following the JSON API standard def build_json_api_response errors? ? build_json_api_response_with_errors : build_json_api_response_without_errors end def build_json_api_response_without_errors { "data" => build_data, "meta" => build_meta, "links" => build_links } end def build_json_api_response_with_errors { "data" => [], "meta" => build_meta, "links" => build_links_when_errors, "errors" => build_errors } end def build_data @results[start_index..last_index] end def build_meta meta = {} meta['page_offset'] = page_offset_error? ? @requested_page_offset.to_s : page_offset.to_s meta['page_limit'] = page_limit_error? ? @requested_page_limit.to_s : page_limit.to_s meta['actual_page_size'] = errors? ? "0" : actual_page_size.to_s meta['total_num_found'] = total_num_found.to_s { "page" => meta } end def build_links links = {} links['self'] = self_link links['first'] = first_link links['prev'] = prev_link links['next'] = next_link links['last'] = last_link links end def build_links_when_errors links = {} links['self'] = "#{request_base_url}#{request_path}?#{request_query_string}" links['first'] = first_link links['prev'] = nil links['next'] = nil links['last'] = last_link links end def build_errors errors = [] errors << build_page_offset_error if page_offset_error? errors << build_page_limit_error if page_limit_error? errors end def build_page_offset_error case @page_offset_error when ERROR_NOT_INTEGER build_page_offset_not_integer when ERROR_OUT_OF_RANGE_TOO_LARGE build_page_offset_too_large when ERROR_OUT_OF_RANGE_TOO_SMALL build_page_offset_too_small end end def build_page_limit_error case @page_limit_error when ERROR_NOT_INTEGER build_page_limit_not_integer when ERROR_OUT_OF_RANGE_TOO_SMALL build_page_limit_too_small end end def build_page_offset_not_integer { "status" => "200", "source" => { "page_offset" => @requested_page_offset }, "title" => "Page Offset Invalid", "detail" => "Page offset #{@requested_page_offset} is not an Integer. Returning empty results." } end def build_page_offset_too_large { "status" => "200", "source" => { "page_offset" => @requested_page_offset }, "title" => "Page Offset Out of Range", "detail" => "Page offset #{@requested_page_offset} > #{total_num_found} (total number of results). Returning empty results." } end def build_page_offset_too_small { "status" => "200", "source" => { "page_offset" => page_offset.to_s }, "title" => "Page Offset Out of Range", "detail" => "Page offset #{@requested_page_offset} < 1 (first result). Returning empty results." } end def build_page_limit_not_integer { "status" => "200", "source" => { "page_limit" => @requested_page_limit }, "title" => "Page Limit Invalid", "detail" => "Page limit #{@requested_page_limit} is not an Integer. Returning empty results." } end def build_page_limit_too_small { "status" => "200", "source" => { "page_limit" => @requested_page_limit.to_s }, "title" => "Page Limit Out of Range", "detail" => "Page limit #{@requested_page_limit} < 1 (minimum limit). Returning empty results." } end def request_params @request_params ||= @request.params end def request_query_params @request_query_params ||= @request.query_parameters end def request_query_string @request_query_string ||= @request.query_string end def request_base_url @request_base_url ||= @request.base_url end def request_path @request_path ||= @request.path end # @return [Boolean] true if results should be formatted according to JSON API standard def json_api? format == :jsonapi end # @param [Symbol] The requested format of the response (default=:json) # @note Supported formats include [:json | :json-api]. For backward compatibility, # it defaults to :json. def format return @format if @format.present? return @format = @requested_format if [:json, :jsonapi].include? @requested_format Rails.logger.warn("Format '#{@requested_format}' is not a valid format for search. Supported formats are [:json, :jsonapi]. Defaulting to :json.") @format = :json end # @return [Integer] The first record to include in the response data. (default=1). # @note The first record may be out of range (i.e., < 1 or > total_num_of_results), # but it will always be numeric, defaulting to 1 if not specified or not an Integer. def page_offset return @page_offset if @page_offset.present? return @page_offset = 1 unless page_offset_specified? @page_offset = validated_request_page_offset || 1 end # @return [Boolean] true if the request specifies the page offset; otherwise, false def page_offset_specified? request_params.keys.include?("page_offset") || request_params.keys.include?("startRecord") end # @return [Integer] The page offset as specified in the request_params, nil if invalid. # @note The page offset can be specified with page_offset (preferred) or # startRecord (deprecated, supported for backward compatibility with # linked_data module pagination). def requested_page_offset return @requested_page_offset if @requested_page_offset.present? @requested_page_offset = (request_params['page_offset'] || request_params['startRecord']) end # @return [Integer] The first record to include in the response data as # requested as long as it is an Integer; otherwise, returns nil. def validated_request_page_offset @page_offset_error = false offset = Integer(requested_page_offset) return offset if offset == 1 @page_offset_error = ERROR_OUT_OF_RANGE_TOO_SMALL if offset < 1 @page_offset_error = ERROR_OUT_OF_RANGE_TOO_LARGE if offset > total_num_found offset rescue ArgumentError @page_offset_error = ERROR_NOT_INTEGER nil end # @return [Integer] The requested maximum number of results to return (default=DEFAULT_PAGE_LIMIT | ALL) # @see #default_page_limit # @see DEFAULT_PAGE_LIMIT def page_limit return @page_limit if @page_limit.present? return @page_limit = default_page_limit unless page_limit_specified? @page_limit = validated_request_page_limit || default_page_limit end # @return [Boolean] true if the request specifies the page limit; otherwise, false def page_limit_specified? request_params.keys.include?("page_limit") || request_params.keys.include?("maxRecords") end # @return [Integer] The max number of records for a page as specified in the request_params. # @note The page size limit can be specified with page_limit (preferred) or # maxRecords (deprecated, supported for backward compatibility with # linked_data module pagination). def requested_page_limit return @requested_page_limit if @requested_page_limit.present? @requested_page_limit ||= (request_params['page_limit'] || request_params['maxRecords']) end # @return [Integer] The max number of records to include in response data as # requested as long as it is a positive Integer; otherwise, returns nil. def validated_request_page_limit @page_limit_error = false limit = Integer(requested_page_limit) @page_limit_error = ERROR_OUT_OF_RANGE_TOO_SMALL if limit < 1 limit.positive? ? limit : nil rescue ArgumentError @page_limit_error = ERROR_NOT_INTEGER nil end # @return [Integer] The default to use when page_limit isn't specified. # @note To maintain backward compatibility, the limit will be all results # if format is json and neither page_limit nor page_offset were specified. def default_page_limit return total_num_found unless json_api? || page_offset_specified? || page_limit_specified? DEFAULT_PAGE_LIMIT end # @return [Integer] the index into the terms Array for the first record to # include in the page data # @note page_offset begins counting at 1 and the Array index begins at 0. def start_index @start_index ||= page_offset - 1 end # @return [Integer] the index into the terms Array for the last record to # include in the page data def last_index return @last_index if @last_index.present? return @last_index = start_index if start_index >= last_possible_index last_index = start_index + page_limit - 1 @last_index = last_index <= last_possible_index ? last_index : last_possible_index end # @return [Integer] the index for the last term in the Array def last_possible_index total_num_found - 1 end # @return [Integer] actual number of results in the returned page of results; # 0 if request is out of range def actual_page_size @actual_page_size ||= start_index <= last_possible_index ? last_index - start_index + 1 : 0 end # @return [Integer] total number of terms matching the search query def total_num_found @results.length end # @return the URL to current page of results def self_link url_with(page_offset: page_offset) end # @return the URL to the first page of results def first_link url_with(page_offset: 1) end # @return the URL to the last page of results def last_link last_start = (total_num_found / page_limit) * page_limit + 1 last_start -= page_limit if last_start > total_num_found last_start = 1 if last_start < 1 url_with(page_offset: last_start) end # @return the URL to the next page of results; nil if on last page def next_link next_start = page_offset + page_limit next_start <= total_num_found ? url_with(page_offset: next_start) : nil end # @return the URL to the previous page of results; nil if on first page def prev_link return if page_offset == 1 prev_start = page_offset - page_limit prev_start >= 1 ? url_with(page_offset: prev_start) : url_with(page_offset: 1) end # @return the original URL without the parameters def url_without_parameters URI.parse(request_base_url + request_path) end # Generate a URL based off the original URL and parameter values with page_offset # updated based on the passed in value. # @param page_offset [Integer] the value to use for page_offset # @return [String] a full URL with the updated page_offset def url_with(page_offset:) updated_params = update_parameters(page_offset) uri = url_without_parameters uri.query = URI.encode_www_form(updated_params) uri.to_s end # @param page_offset [Integer] the value to use for page_offset # @return [Hash] parameter key-value pairs formatted for the URL using # the preferred parameter name and updated page_offset value def update_parameters(page_offset) updated_params = request_query_params.except('page_offset', 'page_limit') updated_params['page_limit'] = page_limit updated_params['page_offset'] = page_offset updated_params end end end