Commit 56a473da authored by Robert May's avatar Robert May

Add PageLimiter controller concern

This adds a new controller concern which can be used to limit the
maximum number of pages that can be browsed through pagination.
parent d4c405ab
# frozen_string_literal: true
# Include this in your controller and call `limit_pages` in order
# to configure the limiter.
#
# Examples:
# class MyController < ApplicationController
# include PageLimiter
# limit_pages 500
#
# # Optionally provide a block to customize the response:
# limit_pages 500 do
# head :ok
# end
#
# # Or override the default response method
# limit_pages 500
#
# def page_out_of_bounds
# head :ok
# end
#
module PageLimiter
extend ActiveSupport::Concern
PageLimitNotANumberError = Class.new(StandardError)
included do
around_action :check_page_number, if: :max_page_defined?
end
class_methods do
def limit_pages(number, &block)
set_max_page(number)
@page_limiter_block = block
end
def max_page
@max_page
end
def page_limiter_block
@page_limiter_block
end
private
def set_max_page(value)
raise PageLimitNotANumberError unless value.is_a?(Integer)
@max_page = value
end
end
# Override this method in your controller to customize the response
def page_out_of_bounds
default_page_out_of_bounds_response
end
private
# Used to see whether the around_action should run or not
def max_page_defined?
self.class.max_page.present? && self.class.max_page > 0
end
# If the page exceeds the defined maximum, either call the provided
# block (if provided) or call the #page_out_of_bounds method to
# provide a response.
#
# If the page doesn't exceed the limit, it yields the controller action.
def check_page_number
if params[:page].present? && params[:page].to_i > self.class.max_page
record_interception
if self.class.page_limiter_block.present?
instance_eval(&self.class.page_limiter_block)
else
page_out_of_bounds
end
else
yield
end
end
# By default just return a HTTP status code and an empty response
def default_page_out_of_bounds_response
head :bad_request
end
# Record the page limit being hit in Prometheus
def record_interception
Gitlab::Metrics.counter(:gitlab_page_out_of_bounds,
controller: params[:controller],
action: params[:action],
agent: request.user_agent
)
end
end
# frozen_string_literal: true
require 'spec_helper'
class PageLimiterSpecController < ApplicationController
include PageLimiter
limit_pages 2 do
raise "block response"
end
def page_out_of_bounds
raise "method response"
end
end
describe PageLimiter do
let(:controller_class) do
PageLimiterSpecController
end
let(:instance) do
controller_class.new
end
before do
allow(instance).to receive(:params) do
{
controller: "explore/projects",
action: "index"
}
end
allow(instance).to receive(:request) do
double(:request, user_agent: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)")
end
end
describe ".max_page" do
subject { controller_class.max_page }
it { is_expected.to eq(2) }
end
describe ".page_limiter_block" do
subject { controller_class.page_limiter_block }
it "is an executable block" do
expect { subject.call }.to raise_error("block response")
end
end
describe ".set_max_page" do
subject { controller_class.send(:set_max_page, page) }
context "page is a number" do
let(:page) { 2 }
it { is_expected.to eq(page) }
end
context "page is a string" do
let(:page) { "2" }
it "raises an error" do
expect { subject }.to raise_error(PageLimiter::PageLimitNotANumberError)
end
end
context "page is nil" do
let(:page) { nil }
it "raises an error" do
expect { subject }.to raise_error(PageLimiter::PageLimitNotANumberError)
end
end
end
describe "#page_out_of_bounds" do
subject { instance.page_out_of_bounds }
it "returns a bad_request header" do
expect { subject }.to raise_error("method response")
end
end
describe "#max_page_defined?" do
using RSpec::Parameterized::TableSyntax
subject { instance.send(:max_page_defined?) }
where(:max_page, :result) do
2 | true
nil | false
0 | false
end
with_them do
before do
controller_class.instance_variable_set(:@max_page, max_page)
end
# Reset this afterwards to prevent polluting other specs
after do
controller_class.instance_variable_set(:@max_page, 2)
end
it { is_expected.to be(result) }
end
end
describe "#check_page_number" do
let(:max_page) { 2 }
subject { instance.send(:check_page_number) { "test" } }
before do
allow(instance).to receive(:params) { { page: page.to_s } }
end
context "page is over the limit" do
let(:page) { max_page + 1 }
it "records the interception" do
expect(instance).to receive(:record_interception)
# Need this second expectation to cancel out the exception
expect { subject }.to raise_error("block response")
end
context "block is given" do
it "calls the block" do
expect { subject }.to raise_error("block response")
end
end
context "block is not given" do
before do
allow(controller_class).to receive(:page_limiter_block) { nil }
end
it "calls the #page_out_of_bounds method" do
expect { subject }.to raise_error("method response")
end
end
end
context "page is not over the limit" do
let(:page) { max_page }
it "yields" do
expect(subject).to eq("test")
end
end
end
describe "#default_page_out_of_bounds_response" do
subject { instance.send(:default_page_out_of_bounds_response) }
it "returns a bad_request header" do
expect(instance).to receive(:head).with(:bad_request)
subject
end
end
describe "#record_interception" do
subject { instance.send(:record_interception) }
it "records a metric counter" do
expect(Gitlab::Metrics).to receive(:counter).with(
:gitlab_page_out_of_bounds,
controller: "explore/projects",
action: "index",
agent: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
)
subject
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment