├── .rspec ├── spec ├── support │ ├── active_record │ │ ├── foo.rb │ │ └── schema.rb │ ├── shared_examples │ │ ├── existing_headers.rb │ │ ├── middle_page.rb │ │ ├── first_page.rb │ │ └── last_page.rb │ ├── numbers_api.rb │ └── numbers_controller.rb ├── sequel_spec.rb ├── spec_helper.rb ├── active_record_spec.rb ├── api-pagination_spec.rb ├── grape_spec.rb └── rails_spec.rb ├── lib ├── api-pagination │ ├── version.rb │ ├── railtie.rb │ ├── hooks.rb │ └── configuration.rb ├── grape │ └── pagination.rb ├── rails │ └── pagination.rb └── api-pagination.rb ├── .gitignore ├── CONTRIBUTING.md ├── .github └── workflows │ ├── linting.yml │ └── tests.yml ├── Gemfile ├── LICENSE.txt ├── api-pagination.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /spec/support/active_record/foo.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | 3 | class Foo < ActiveRecord::Base; end 4 | -------------------------------------------------------------------------------- /spec/support/active_record/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define(version: 0) do 2 | create_table "foos", force: true do |t| 3 | t.string "foo" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/api-pagination/version.rb: -------------------------------------------------------------------------------- 1 | module ApiPagination 2 | class Version 3 | MAJOR = 7 4 | MINOR = 0 5 | PATCH = 0 6 | 7 | def self.to_s 8 | [MAJOR, MINOR, PATCH].join(".") 9 | end 10 | end 11 | 12 | VERSION = Version.to_s 13 | end 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | .ruby-version 7 | .ruby-gemset 8 | Gemfile.lock 9 | InstalledFiles 10 | _yardoc 11 | coverage 12 | doc/ 13 | lib/bundler/man 14 | pkg 15 | rdoc 16 | spec/reports 17 | test/tmp 18 | test/version_tmp 19 | tmp 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | 1. Fork it 4 | 2. Create your feature branch (`git checkout -b my-new-feature`) 5 | 3. Commit your changes and tests (`git commit -am 'Add some feature'`) 6 | 4. Run the tests (`PAGINATOR=pagy bundle exec rspec; PAGINATOR=kaminari bundle exec rspec; PAGINATOR=will_paginate bundle exec rspec`) 7 | 5. Push to the branch (`git push origin my-new-feature`) 8 | 6. Create a new Pull Request 9 | -------------------------------------------------------------------------------- /lib/api-pagination/railtie.rb: -------------------------------------------------------------------------------- 1 | require "rails/railtie" 2 | 3 | module ApiPagination 4 | class Railtie < ::Rails::Railtie 5 | initializer :api_pagination do 6 | ActiveSupport.on_load(:action_controller) do 7 | require "rails/pagination" 8 | 9 | klass = if Rails::VERSION::MAJOR >= 5 || defined?(ActionController::API) 10 | ActionController::API 11 | else 12 | ActionController::Base 13 | end 14 | 15 | klass.send(:include, Rails::Pagination) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Ruby and install gems 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: 3.4 21 | bundler-cache: true 22 | 23 | - name: Lint Ruby code 24 | run: | 25 | bundle exec standardrb 26 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in api_pagination.gemspec 4 | gemspec 5 | 6 | # Explicitly specify version constraints to match gemspec requirements 7 | # Without these, Bundler may override gemspec constraints and install incompatible versions 8 | gem "kaminari", "~> 1.2", ">= 1.2.1", require: false 9 | gem "will_paginate", "~> 3.3", ">= 3.3.1", require: false 10 | gem "pagy", "~> 43.0", require: false 11 | 12 | gem "sqlite3", require: false 13 | gem "sequel", "~> 5.49", require: false 14 | 15 | gem "standardrb" 16 | -------------------------------------------------------------------------------- /spec/support/shared_examples/existing_headers.rb: -------------------------------------------------------------------------------- 1 | shared_examples "an endpoint with existing Link headers" do 2 | it "should keep existing Links" do 3 | expect(links).to include('; rel="without"') 4 | end 5 | 6 | it "should contain pagination Links" do 7 | expect(links).to include('; rel="next"') 8 | expect(links).to include('; rel="last"') 9 | end 10 | 11 | it "should give a Total header" do 12 | expect(total).to eq(30) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | # Test against pagination libraries 16 | paginator: ["pagy", "kaminari", "will_paginate"] 17 | env: 18 | PAGINATOR: ${{ matrix.paginator }} 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: 3.4 27 | bundler-cache: true 28 | 29 | - name: Run tests 30 | run: bundle exec rspec 31 | -------------------------------------------------------------------------------- /spec/sequel_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | if ApiPagination.config.paginator == :will_paginate 4 | require "sqlite3" 5 | require "sequel" 6 | require "will_paginate/sequel" 7 | 8 | DB = Sequel.sqlite 9 | DB.extension :pagination 10 | DB.create_table :people do 11 | primary_key :id 12 | String :name 13 | end 14 | 15 | describe "Using will_paginate with Sequel" do 16 | let(:people) do 17 | DB[:people] 18 | end 19 | 20 | before(:each) do 21 | people.insert(name: "John") 22 | people.insert(name: "Mary") 23 | end 24 | 25 | it "returns a Sequel::Dataset" do 26 | collection = ApiPagination.paginate(people).first 27 | expect(collection.is_a?(Sequel::Dataset)).to be_truthy 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/support/shared_examples/middle_page.rb: -------------------------------------------------------------------------------- 1 | shared_examples "an endpoint with a middle page" do 2 | it "should give all pagination links" do 3 | expect(links).to include('; rel="first"') 4 | expect(links).to include('; rel="last"') 5 | expect(links).to include('; rel="next"') 6 | expect(links).to include('; rel="prev"') 7 | end 8 | 9 | it "should give a Total header" do 10 | expect(total).to eq(100) 11 | end 12 | 13 | it "should list a middle page of numbers in the response body" do 14 | body = "[11,12,13,14,15,16,17,18,19,20]" 15 | 16 | if defined?(response) 17 | expect(response.body).to eq(body) 18 | else 19 | expect(last_response.body).to eq(body) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/support/shared_examples/first_page.rb: -------------------------------------------------------------------------------- 1 | shared_examples "an endpoint with a first page" do 2 | it 'should not give a link with rel "first"' do 3 | expect(link).not_to include('rel="first"') 4 | end 5 | 6 | it 'should not give a link with rel "prev"' do 7 | expect(link).not_to include('rel="prev"') 8 | end 9 | 10 | it 'should give a link with rel "last"' do 11 | expect(links).to include('; rel="last"') 12 | end 13 | 14 | it 'should give a link with rel "next"' do 15 | expect(links).to include('; rel="next"') 16 | end 17 | 18 | it "should give a Total header" do 19 | expect(total).to eq(100) 20 | end 21 | 22 | it "should list the first page of numbers in the response body" do 23 | body = "[1,2,3,4,5,6,7,8,9,10]" 24 | if defined?(response) 25 | expect(response.body).to eq(body) 26 | else 27 | expect(last_response.body).to eq(body) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/support/shared_examples/last_page.rb: -------------------------------------------------------------------------------- 1 | shared_examples "an endpoint with a last page" do 2 | it 'should not give a link with rel "last"' do 3 | expect(link).not_to include('rel="last"') 4 | end 5 | 6 | it 'should not give a link with rel "next"' do 7 | expect(link).not_to include('rel="next"') 8 | end 9 | 10 | it 'should give a link with rel "first"' do 11 | expect(links).to include('; rel="first"') 12 | end 13 | 14 | it 'should give a link with rel "prev"' do 15 | expect(links).to include('; rel="prev"') 16 | end 17 | 18 | it "should give a Total header" do 19 | expect(total).to eq(100) 20 | end 21 | 22 | it "should list the last page of numbers in the response body" do 23 | body = "[91,92,93,94,95,96,97,98,99,100]" 24 | 25 | if defined?(response) 26 | expect(response.body).to eq(body) 27 | else 28 | expect(last_response.body).to eq(body) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright © 2014 David Celis 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /api-pagination.gemspec: -------------------------------------------------------------------------------- 1 | $:.unshift(File.expand_path("../lib", __FILE__)) 2 | require "api-pagination/version" 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "api-pagination" 6 | s.version = ApiPagination::VERSION 7 | s.authors = ["David Celis"] 8 | s.email = ["me@davidcel.is"] 9 | s.description = "Link header pagination for Rails and Grape APIs" 10 | s.summary = "Link header pagination for Rails and Grape APIs. Don't use the request body." 11 | s.homepage = "https://github.com/davidcelis/api-pagination" 12 | s.license = "MIT" 13 | 14 | s.files = Dir["lib/**/*"] 15 | s.require_paths = ["lib"] 16 | 17 | s.required_ruby_version = "> 2.7" 18 | 19 | s.add_development_dependency "kaminari", "~> 1.2", ">= 1.2.1" 20 | s.add_development_dependency "pagy", "~> 43.0" 21 | s.add_development_dependency "will_paginate", "~> 3.3", ">= 3.3.1" 22 | 23 | s.add_development_dependency "rspec", "~> 3.10" 24 | s.add_development_dependency "grape", "~> 1.6" 25 | s.add_development_dependency "railties", "~> 7.0" 26 | s.add_development_dependency "actionpack", "~> 7.0" 27 | s.add_development_dependency "sequel", "~> 5.49" 28 | s.add_development_dependency "activerecord-nulldb-adapter", "~> 0.9.0" 29 | end 30 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "support/numbers_controller" 2 | require "support/numbers_api" 3 | require "api-pagination" 4 | 5 | if ENV["PAGINATOR"].nil? 6 | warn <<~WARNING 7 | No PAGINATOR set. Defaulting to pagy. 8 | 9 | To test against kaminari, run `PAGINATOR=kaminari bundle exec rspec` 10 | To test against will_paginate, run `PAGINATOR=will_paginate bundle exec rspec` 11 | WARNING 12 | 13 | ENV["PAGINATOR"] = "pagy" 14 | end 15 | 16 | require ENV["PAGINATOR"] 17 | ApiPagination.config.paginator = ENV["PAGINATOR"].to_sym 18 | 19 | require "will_paginate/array" if ENV["PAGINATOR"].to_sym == :will_paginate 20 | 21 | RSpec.configure do |config| 22 | config.include Rack::Test::Methods 23 | config.include ControllerExampleGroup, type: :controller 24 | 25 | # Disable the 'should' syntax. 26 | config.expect_with :rspec do |c| 27 | c.syntax = :expect 28 | end 29 | 30 | # Run specs in random order to surface order dependencies. If you find an 31 | # order dependency and want to debug it, you can fix the order by providing 32 | # the seed, which is printed after each run. 33 | # --seed 1234 34 | config.order = "random" 35 | 36 | def app 37 | NumbersAPI 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/support/numbers_api.rb: -------------------------------------------------------------------------------- 1 | require "grape" 2 | require "api-pagination" 3 | 4 | class NumbersAPI < Grape::API 5 | format :json 6 | 7 | desc "Return some paginated set of numbers" 8 | paginate per_page: 10 9 | params do 10 | requires :count, type: Integer 11 | optional :with_headers, default: false, type: Boolean 12 | end 13 | get :numbers do 14 | if params[:with_headers] 15 | url = request.url.sub(/\?.*/, "") 16 | query = Rack::Utils.parse_query(request.query_string) 17 | query.delete("with_headers") 18 | header "Link", %(<#{url}?#{query.to_query}>; rel="without") 19 | end 20 | 21 | paginate (1..params[:count]).to_a 22 | end 23 | 24 | desc "Return some paginated set of numbers with max_per_page" 25 | paginate per_page: 10, max_per_page: 25 26 | params do 27 | requires :count, type: Integer 28 | end 29 | get :numbers_with_max_per_page do 30 | paginate (1..params[:count]).to_a 31 | end 32 | 33 | desc "Return some paginated set of numbers with max_per_page enforced" 34 | paginate per_page: 10, max_per_page: 25, enforce_max_per_page: true 35 | params do 36 | requires :count, type: Integer 37 | end 38 | get :numbers_with_enforced_max_per_page do 39 | paginate (1..params[:count]).to_a 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/api-pagination/hooks.rb: -------------------------------------------------------------------------------- 1 | begin; require "grape"; rescue LoadError; end 2 | if defined?(Grape::API) 3 | require "grape/pagination" 4 | 5 | klass = if Grape::VERSION >= "1.2.0" || defined?(Grape::API::Instance) 6 | Grape::API::Instance 7 | else 8 | Grape::API 9 | end 10 | 11 | klass.send(:include, Grape::Pagination) 12 | end 13 | 14 | begin; require "pagy"; rescue LoadError; end 15 | begin; require "kaminari"; rescue LoadError; end 16 | begin; require "will_paginate"; rescue LoadError; end 17 | 18 | unless defined?(Pagy) || defined?(Kaminari) || defined?(WillPaginate::CollectionMethods) 19 | Kernel.warn <<-WARNING.gsub(/^\s{4}/, "") 20 | Warning: api-pagination relies on either Pagy, Kaminari, or WillPaginate. 21 | Please install a paginator by adding one of the following to your Gemfile: 22 | 23 | gem 'pagy' 24 | gem 'kaminari' 25 | gem 'will_paginate' 26 | WARNING 27 | end 28 | 29 | if defined?(Rails) 30 | module ApiPagination 31 | module Hooks 32 | def self.rails_parent_controller 33 | if Rails::VERSION::MAJOR >= 5 || defined?(ActionController::API) 34 | ActionController::API 35 | else 36 | ActionController::Base 37 | end 38 | end 39 | end 40 | end 41 | 42 | require "api-pagination/railtie" 43 | end 44 | -------------------------------------------------------------------------------- /spec/active_record_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "support/active_record/foo" 3 | require "nulldb_rspec" 4 | 5 | ActiveRecord::Base.establish_connection( 6 | adapter: :nulldb, 7 | schema: "spec/support/active_record/schema.rb" 8 | ) 9 | 10 | NullDB.configure { |ndb| 11 | def ndb.project_root 12 | Dir.pwd 13 | end 14 | } 15 | 16 | shared_examples "produces_correct_sql" do 17 | it "produces correct sql for first page" do 18 | allow(collection).to receive(:count).and_return(collection_size) 19 | paginated_sql, _ = ApiPagination.paginate(collection, per_page: per_page) 20 | expect(paginated_sql.to_sql).to eql(Foo.limit(per_page).offset(0).to_sql) 21 | end 22 | end 23 | 24 | describe "ActiveRecord Support" do 25 | let(:collection) { Foo.all } 26 | let(:collection_size) { 50 } 27 | let(:per_page) { 5 } 28 | 29 | if ApiPagination.config.paginator == :will_paginate 30 | require "will_paginate/active_record" 31 | end 32 | 33 | context "pagination with #{ApiPagination.config.paginator}" do 34 | include_examples "produces_correct_sql" 35 | end 36 | 37 | if ApiPagination.config.paginator != :pagy 38 | context "reflections" do 39 | it "invokes the correct methods to determine type" do 40 | expect(collection).to receive(:klass).at_least(:once) 41 | .and_call_original 42 | ApiPagination.paginate(collection) 43 | end 44 | 45 | it "does not fail if table name is not snake cased class name" do 46 | allow(collection).to receive(:table_name).and_return(SecureRandom.uuid) 47 | expect { ApiPagination.paginate(collection) }.to_not raise_error 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/grape/pagination.rb: -------------------------------------------------------------------------------- 1 | module Grape 2 | module Pagination 3 | def self.included(base) 4 | Grape::Endpoint.class_eval do 5 | def paginate(collection) 6 | per_page = ApiPagination.config.per_page_param(params) || route_setting(:per_page) 7 | 8 | options = { 9 | page: ApiPagination.config.page_param(params), 10 | per_page: [per_page, route_setting(:max_per_page)].compact.min 11 | } 12 | collection, pagy = ApiPagination.paginate(collection, options) 13 | 14 | links = (header["Link"] || "").split(",").map(&:strip) 15 | url = request.url.sub(/\?.*$/, "") 16 | pages = ApiPagination.pages_from(pagy || collection, options) 17 | 18 | pages.each do |k, v| 19 | old_params = Rack::Utils.parse_nested_query(request.query_string) 20 | new_params = old_params.merge("page" => v) 21 | links << %(<#{url}?#{new_params.to_param}>; rel="#{k}") 22 | end 23 | 24 | total_header = ApiPagination.config.total_header 25 | per_page_header = ApiPagination.config.per_page_header 26 | page_header = ApiPagination.config.page_header 27 | include_total = ApiPagination.config.include_total 28 | 29 | header "Link", links.join(", ") unless links.empty? 30 | header total_header, ApiPagination.total_from(pagy || collection).to_s if include_total 31 | header per_page_header, options[:per_page].to_s 32 | header page_header, options[:page].to_s unless page_header.nil? 33 | 34 | collection 35 | end 36 | end 37 | 38 | base.class_eval do 39 | def self.paginate(options = {}) 40 | route_setting :per_page, options[:per_page] 41 | route_setting :max_per_page, options[:max_per_page] 42 | 43 | enforce_max_per_page = options[:max_per_page] && options[:enforce_max_per_page] 44 | per_page_values = enforce_max_per_page ? 0..options[:max_per_page] : nil 45 | 46 | params do 47 | optional :page, type: Integer, default: 1, 48 | desc: "Page of results to fetch." 49 | optional :per_page, type: Integer, default: options[:per_page], 50 | desc: "Number of results to return per page.", 51 | values: per_page_values 52 | end 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/rails/pagination.rb: -------------------------------------------------------------------------------- 1 | module Rails 2 | module Pagination 3 | protected 4 | 5 | def paginate(*options_or_collection) 6 | options = options_or_collection.extract_options! 7 | collection = options_or_collection.first 8 | 9 | return _paginate_collection(collection, options) if collection 10 | 11 | response_format = _discover_format(options) 12 | 13 | collection = options[response_format] 14 | collection = _paginate_collection(collection, options) 15 | 16 | options[response_format] = collection if options[response_format] 17 | 18 | render options 19 | end 20 | 21 | def paginate_with(collection) 22 | respond_with _paginate_collection(collection) 23 | end 24 | 25 | private 26 | 27 | def _discover_format(options) 28 | ApiPagination.config.response_formats.find { |format| options.key?(format) } 29 | end 30 | 31 | def _paginate_collection(collection, options = {}) 32 | options[:page] = ApiPagination.config.page_param(params) 33 | options[:per_page] ||= ApiPagination.config.per_page_param(params) 34 | 35 | collection, pagy = ApiPagination.paginate(collection, options) 36 | 37 | links = (headers["Link"] || "").split(",").map(&:strip) 38 | url = base_url + request.path_info 39 | pages = ApiPagination.pages_from(pagy || collection, options) 40 | 41 | pages.each do |k, v| 42 | new_params = request.query_parameters.merge(page: v) 43 | links << %(<#{url}?#{new_params.to_param}>; rel="#{k}") 44 | end 45 | 46 | total_header = ApiPagination.config.total_header 47 | per_page_header = ApiPagination.config.per_page_header 48 | page_header = ApiPagination.config.page_header 49 | include_total = ApiPagination.config.include_total 50 | 51 | headers["Link"] = links.join(", ") unless links.empty? 52 | headers[per_page_header] = options[:per_page].to_s 53 | headers[page_header] = options[:page].to_s unless page_header.nil? 54 | headers[total_header] = total_count(pagy || collection, options).to_s if include_total 55 | 56 | collection 57 | end 58 | 59 | def total_count(collection, options) 60 | total_count = if ApiPagination.config.paginator == :kaminari 61 | paginate_array_options = options[:paginate_array_options] 62 | paginate_array_options[:total_count] if paginate_array_options 63 | end 64 | total_count || ApiPagination.total_from(collection) 65 | end 66 | 67 | def base_url 68 | ApiPagination.config.base_url || request.base_url 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/api-pagination_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe ApiPagination do 4 | let(:collection) { (1..100).to_a } 5 | let(:active_record_relation) { double("ActiveRecord_Relation").as_null_object } 6 | let(:paginate_array_options) { {total_count: 1000} } 7 | 8 | describe "#paginate" do 9 | if ENV["PAGINATOR"].to_sym == :kaminari 10 | context "Using kaminari" do 11 | describe ".paginate" do 12 | it "should accept paginate_array_options option" do 13 | expect(Kaminari).to receive(:paginate_array) 14 | .with(collection, **paginate_array_options) 15 | .and_call_original 16 | 17 | ApiPagination.paginate( 18 | collection, 19 | { 20 | per_page: 30, 21 | paginate_array_options: paginate_array_options 22 | } 23 | ) 24 | end 25 | 26 | context "configured not to include the total" do 27 | before { ApiPagination.config.include_total = false } 28 | 29 | context "and paginating an array" do 30 | it "should not call without_count on the collection" do 31 | expect(collection).to_not receive :without_count 32 | ApiPagination.paginate(collection) 33 | end 34 | end 35 | context "and paginating an active record relation" do 36 | it "should call without_count on the relation" do 37 | expect(active_record_relation).to receive :without_count 38 | ApiPagination.paginate(active_record_relation) 39 | end 40 | end 41 | 42 | after { ApiPagination.config.include_total = true } 43 | end 44 | end 45 | 46 | describe ".pages_from" do 47 | subject { described_class.pages_from(collection) } 48 | 49 | context "on empty collection" do 50 | let(:collection) { ApiPagination.paginate([], page: 1).first } 51 | 52 | it { is_expected.to be_empty } 53 | end 54 | end 55 | end 56 | end 57 | 58 | if ENV["PAGINATOR"].to_sym == :will_paginate 59 | context "Using will_paginate" do 60 | context "passing in total_entries in options" do 61 | it "should set total_entries using the passed in value" do 62 | paginated_collection = ApiPagination.paginate(collection, total_entries: 3000).first 63 | expect(paginated_collection.total_entries).to eq(3000) 64 | end 65 | end 66 | 67 | context "passing in collection only" do 68 | it "should set total_entries using the size of the collection " do 69 | paginated_collection = ApiPagination.paginate(collection).first 70 | expect(paginated_collection.total_entries).to eq(100) 71 | end 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/support/numbers_controller.rb: -------------------------------------------------------------------------------- 1 | require "action_controller/railtie" 2 | require "api-pagination/hooks" 3 | require "ostruct" 4 | 5 | module Rails 6 | def self.application 7 | @application ||= begin 8 | routes = ActionDispatch::Routing::RouteSet.new 9 | OpenStruct.new(routes: routes, env_config: {}) 10 | end 11 | end 12 | end 13 | 14 | module ControllerExampleGroup 15 | def self.included(base) 16 | base.extend ClassMethods 17 | base.send(:include, ActionController::TestCase::Behavior) 18 | 19 | base.prepend_before do 20 | @routes = Rails.application.routes 21 | @controller = described_class.new 22 | end 23 | end 24 | 25 | module ClassMethods 26 | def setup(*methods) 27 | methods.each do |method| 28 | if /^setup_(fixtures|controller_request_and_response)$/.match?(method.to_s) 29 | prepend_before { send method } 30 | else 31 | before { send method } 32 | end 33 | end 34 | end 35 | 36 | def teardown(*methods) 37 | methods.each { |method| after { send method } } 38 | end 39 | end 40 | end 41 | 42 | Rails.application.routes.draw do 43 | resources :numbers, only: [:index] do 44 | collection do 45 | get :index_with_custom_render 46 | get :index_with_no_per_page 47 | get :index_with_paginate_array_options 48 | end 49 | end 50 | end 51 | 52 | class NumbersSerializer 53 | def initialize(numbers) 54 | @numbers = numbers 55 | end 56 | 57 | def to_json(options = {}) 58 | {numbers: @numbers.map { |n| {number: n} }}.to_json 59 | end 60 | end 61 | 62 | class NumbersController < ActionController::API 63 | include Rails.application.routes.url_helpers 64 | 65 | def index 66 | total = params.fetch(:count).to_i 67 | 68 | if params[:with_headers] 69 | query = request.query_parameters.dup 70 | query.delete(:with_headers) 71 | headers["Link"] = %(<#{numbers_url}?#{query.to_param}>; rel="without") 72 | end 73 | 74 | paginate json: (1..total).to_a, per_page: 10 75 | end 76 | 77 | def index_with_custom_render 78 | total = params.fetch(:count).to_i 79 | numbers = (1..total).to_a 80 | numbers = paginate numbers, per_page: 10 81 | 82 | render json: NumbersSerializer.new(numbers) 83 | end 84 | 85 | def index_with_no_per_page 86 | total = params.fetch(:count).to_i 87 | numbers = (1..total).to_a 88 | numbers = paginate numbers 89 | 90 | render json: NumbersSerializer.new(numbers) 91 | end 92 | 93 | def index_with_paginate_array_options 94 | count = params.fetch(:count).to_i 95 | total_count = params.fetch(:paginate_array_total_count).to_i 96 | numbers = (1..count).to_a 97 | numbers = paginate numbers, paginate_array_options: {total_count: total_count} 98 | 99 | render json: NumbersSerializer.new(numbers) 100 | end 101 | end 102 | 103 | ApiPagination::Railtie.initializers.each(&:run) 104 | -------------------------------------------------------------------------------- /lib/api-pagination/configuration.rb: -------------------------------------------------------------------------------- 1 | module ApiPagination 2 | class Configuration 3 | attr_accessor :total_header 4 | 5 | attr_accessor :per_page_header 6 | 7 | attr_accessor :page_header 8 | 9 | attr_accessor :include_total 10 | 11 | attr_accessor :base_url 12 | 13 | attr_accessor :response_formats 14 | 15 | def configure(&block) 16 | yield self 17 | end 18 | 19 | def initialize 20 | @total_header = "Total" 21 | @per_page_header = "Per-Page" 22 | @page_header = nil 23 | @include_total = true 24 | @base_url = nil 25 | @response_formats = [:json, :xml] 26 | end 27 | 28 | ["page", "per_page"].each do |param_name| 29 | method_name = "#{param_name}_param" 30 | instance_variable_name = "@#{method_name}" 31 | 32 | define_method method_name do |params = nil, &block| 33 | if block.is_a?(Proc) 34 | instance_variable_set(instance_variable_name, block) 35 | return 36 | end 37 | 38 | if instance_variable_get(instance_variable_name).nil? 39 | # use :page & :per_page by default 40 | instance_variable_set(instance_variable_name, lambda { |p| p[param_name.to_sym] }) 41 | end 42 | 43 | instance_variable_get(instance_variable_name).call(params) 44 | end 45 | 46 | define_method "#{method_name}=" do |param| 47 | if param.is_a?(Symbol) || param.is_a?(String) 48 | instance_variable_set(instance_variable_name, lambda { |params| params[param] }) 49 | else 50 | raise ArgumentError, "Cannot set page_param option" 51 | end 52 | end 53 | end 54 | 55 | def paginator 56 | if instance_variable_defined? :@paginator 57 | @paginator 58 | else 59 | set_paginator 60 | end 61 | end 62 | 63 | def paginator=(paginator) 64 | case paginator.to_sym 65 | when :pagy 66 | use_pagy 67 | when :kaminari 68 | use_kaminari 69 | when :will_paginate 70 | use_will_paginate 71 | else 72 | raise StandardError, "Unknown paginator: #{paginator}" 73 | end 74 | end 75 | 76 | private 77 | 78 | def set_paginator 79 | conditions = [defined?(Pagy), defined?(Kaminari), defined?(WillPaginate::CollectionMethods)] 80 | if conditions.compact.size > 1 81 | Kernel.warn <<~WARNING 82 | Warning: api-pagination relies on Pagy, Kaminari, or WillPaginate, but more than 83 | one are currently active. If possible, you should remove one or the other. If 84 | you can't, you _must_ configure api-pagination on your own. For example: 85 | 86 | ApiPagination.configure do |config| 87 | config.paginator = :kaminari 88 | end 89 | 90 | You should also configure Kaminari to use a different `per_page` method name as 91 | using these gems together causes a conflict; some information can be found at 92 | https://github.com/activeadmin/activeadmin/wiki/How-to-work-with-will_paginate 93 | 94 | Kaminari.configure do |config| 95 | config.page_method_name = :per_page_kaminari 96 | end 97 | 98 | WARNING 99 | elsif defined?(Pagy) 100 | use_pagy 101 | elsif defined?(Kaminari) 102 | use_kaminari 103 | elsif defined?(WillPaginate::CollectionMethods) 104 | use_will_paginate 105 | end 106 | end 107 | 108 | def use_pagy 109 | @paginator = :pagy 110 | end 111 | 112 | def use_kaminari 113 | require "kaminari/models/array_extension" 114 | @paginator = :kaminari 115 | end 116 | 117 | def use_will_paginate 118 | WillPaginate::CollectionMethods.module_eval do 119 | def first_page? 120 | !previous_page 121 | end 122 | 123 | def last_page? 124 | !next_page 125 | end 126 | end 127 | 128 | @paginator = :will_paginate 129 | end 130 | end 131 | 132 | class << self 133 | def configure 134 | yield config 135 | end 136 | 137 | def config 138 | @config ||= Configuration.new 139 | end 140 | alias_method :configuration, :config 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /lib/api-pagination.rb: -------------------------------------------------------------------------------- 1 | require "api-pagination/configuration" 2 | require "api-pagination/version" 3 | 4 | module ApiPagination 5 | class << self 6 | def paginate(collection, options = {}) 7 | options[:page] = options[:page].to_i 8 | options[:page] = 1 if options[:page] <= 0 9 | options[:per_page] = options[:per_page].to_i 10 | 11 | case ApiPagination.config.paginator 12 | when :pagy 13 | paginate_with_pagy(collection, options) 14 | when :kaminari 15 | paginate_with_kaminari(collection, options, options[:paginate_array_options] || {}) 16 | when :will_paginate 17 | paginate_with_will_paginate(collection, options) 18 | else 19 | raise StandardError, "Unknown paginator: #{ApiPagination.config.paginator}" 20 | end 21 | end 22 | 23 | def pages_from(collection, options = {}) 24 | return pagy_pages_from(collection) if ApiPagination.config.paginator == :pagy && collection.is_a?(Pagy) 25 | 26 | {}.tap do |pages| 27 | unless collection.first_page? 28 | pages[:first] = 1 29 | pages[:prev] = collection.current_page - 1 30 | end 31 | 32 | if !(collection.last_page? || (ApiPagination.config.paginator == :kaminari && collection.out_of_range?)) 33 | pages[:last] = collection.total_pages if ApiPagination.config.include_total 34 | pages[:next] = collection.current_page + 1 35 | end 36 | end 37 | end 38 | 39 | def total_from(collection) 40 | case ApiPagination.config.paginator 41 | when :pagy then collection.count.to_s 42 | when :kaminari then collection.total_count.to_s 43 | when :will_paginate then collection.total_entries.to_s 44 | end 45 | end 46 | 47 | private 48 | 49 | def paginate_with_pagy(collection, options) 50 | if Pagy.options[:max_per_page] && options[:per_page] > Pagy.options[:max_per_page] 51 | options[:per_page] = Pagy.options[:max_per_page] 52 | elsif options[:per_page] <= 0 53 | options[:per_page] = Pagy.options[:limit] 54 | end 55 | 56 | pagy = pagy_from(collection, options) 57 | collection = if collection.respond_to?(:offset) && collection.respond_to?(:limit) 58 | collection.offset(pagy.offset).limit(pagy.limit) 59 | else 60 | collection[pagy.offset, pagy.limit] 61 | end 62 | 63 | [collection, pagy] 64 | end 65 | 66 | def pagy_from(collection, options) 67 | count = if options[:count] 68 | options[:count] 69 | else 70 | collection.is_a?(Array) ? collection.count : collection.count(:all) 71 | end 72 | 73 | # Pagy 9.x requires keyword arguments 74 | # Use explicit keyword argument syntax to avoid Ruby version quirks 75 | pagy_options = {count: count, limit: options[:per_page], page: options[:page]} 76 | Pagy::Offset.new(**pagy_options) 77 | end 78 | 79 | def pagy_pages_from(pagy) 80 | {}.tap do |pages| 81 | unless pagy.page == 1 82 | pages[:first] = 1 83 | pages[:prev] = pagy.previous 84 | end 85 | 86 | unless pagy.page == pagy.pages 87 | pages[:last] = pagy.pages if ApiPagination.config.include_total 88 | pages[:next] = pagy.next 89 | end 90 | end 91 | end 92 | 93 | def paginate_with_kaminari(collection, options, paginate_array_options = {}) 94 | if Kaminari.config.max_per_page && options[:per_page] > Kaminari.config.max_per_page 95 | options[:per_page] = Kaminari.config.max_per_page 96 | elsif options[:per_page] <= 0 97 | options[:per_page] = get_default_per_page_for_kaminari(collection) 98 | end 99 | 100 | collection = Kaminari.paginate_array(collection, **paginate_array_options) if collection.is_a?(Array) 101 | collection = collection.page(options[:page]).per(options[:per_page]) 102 | collection.without_count if !collection.is_a?(Array) && !ApiPagination.config.include_total 103 | [collection, nil] 104 | end 105 | 106 | def paginate_with_will_paginate(collection, options) 107 | if options[:per_page] <= 0 108 | options[:per_page] = default_per_page_for_will_paginate(collection) 109 | end 110 | 111 | collection = if defined?(Sequel::Dataset) && collection.is_a?(Sequel::Dataset) 112 | collection.paginate(options[:page], options[:per_page]) 113 | else 114 | supported_options = [:page, :per_page, :total_entries] 115 | options = options.dup.keep_if { |k, v| supported_options.include?(k.to_sym) } 116 | collection.paginate(options) 117 | end 118 | 119 | [collection, nil] 120 | end 121 | 122 | def get_default_per_page_for_kaminari(collection) 123 | default = Kaminari.config.default_per_page 124 | extract_per_page_from_model(collection, :default_per_page) || default 125 | end 126 | 127 | def default_per_page_for_will_paginate(collection) 128 | default = WillPaginate.per_page 129 | extract_per_page_from_model(collection, :per_page) || default 130 | end 131 | 132 | def extract_per_page_from_model(collection, accessor) 133 | klass = if collection.respond_to?(:klass) 134 | collection.klass 135 | else 136 | collection.first.class 137 | end 138 | 139 | return unless klass.respond_to?(accessor) 140 | klass.send(accessor) 141 | end 142 | end 143 | end 144 | 145 | require "api-pagination/hooks" 146 | -------------------------------------------------------------------------------- /spec/grape_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "support/shared_examples/existing_headers" 3 | require "support/shared_examples/first_page" 4 | require "support/shared_examples/middle_page" 5 | require "support/shared_examples/last_page" 6 | 7 | describe NumbersAPI do 8 | it { is_expected.to be_kind_of(Grape::Pagination) } 9 | 10 | describe "GET #index" do 11 | let(:link) { last_response.headers["Link"] } 12 | let(:links) { link.split(", ") } 13 | let(:total) { last_response.headers["Total"].to_i } 14 | let(:per_page) { last_response.headers["Per-Page"].to_i } 15 | 16 | context "without enough items to give more than one page" do 17 | before { get "/numbers", count: 10 } 18 | 19 | it "should not paginate" do 20 | expect(last_response.headers.keys).not_to include("Link") 21 | end 22 | 23 | it "should give a Total header" do 24 | expect(total).to eq(10) 25 | end 26 | 27 | it "should give a Per-Page header" do 28 | expect(per_page).to eq(10) 29 | end 30 | 31 | it "should list all numbers in the response body" do 32 | body = "[1,2,3,4,5,6,7,8,9,10]" 33 | expect(last_response.body).to eq(body) 34 | end 35 | end 36 | 37 | context "with existing Link headers" do 38 | before { get "/numbers", count: 30, with_headers: true } 39 | 40 | it_behaves_like "an endpoint with existing Link headers" 41 | end 42 | 43 | context "with enough items to paginate" do 44 | context "when on the first page" do 45 | before { get "/numbers", count: 100 } 46 | 47 | it_behaves_like "an endpoint with a first page" 48 | end 49 | 50 | context "when on the last page" do 51 | before { get "/numbers", count: 100, page: 10 } 52 | 53 | it_behaves_like "an endpoint with a last page" 54 | end 55 | 56 | context "when somewhere comfortably in the middle" do 57 | before { get "/numbers", count: 100, page: 2 } 58 | 59 | it_behaves_like "an endpoint with a middle page" 60 | end 61 | 62 | context "without a max_per_page setting" do 63 | before { get "/numbers", count: 100, per_page: 30 } 64 | 65 | it "should list all numbers within per page in the response body" do 66 | body = "[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30]" 67 | 68 | expect(last_response.body).to eq(body) 69 | end 70 | end 71 | 72 | context "with a max_per_page setting not enforced" do 73 | before { get "/numbers_with_max_per_page", count: 100, per_page: 30 } 74 | 75 | it "should not go above the max_per_page_limit" do 76 | body = "[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25]" 77 | 78 | expect(last_response.body).to eq(body) 79 | end 80 | end 81 | 82 | context "with a max_per_page setting enforced" do 83 | before { get "/numbers_with_enforced_max_per_page", count: 100, per_page: 30 } 84 | 85 | it "should not allow value above the max_per_page_limit" do 86 | body = '{"error":"per_page does not have a valid value"}' 87 | 88 | expect(last_response.body).to eq(body) 89 | end 90 | end 91 | end 92 | 93 | context "with custom response headers" do 94 | before do 95 | ApiPagination.config.total_header = "X-Total-Count" 96 | ApiPagination.config.per_page_header = "X-Per-Page" 97 | ApiPagination.config.page_header = "X-Page" 98 | 99 | get "/numbers", count: 10 100 | end 101 | 102 | after do 103 | ApiPagination.config.total_header = "Total" 104 | ApiPagination.config.per_page_header = "Per-Page" 105 | ApiPagination.config.page_header = nil 106 | end 107 | 108 | let(:total) { last_response.header["X-Total-Count"].to_i } 109 | let(:per_page) { last_response.header["X-Per-Page"].to_i } 110 | let(:page) { last_response.header["X-Page"].to_i } 111 | 112 | it "should give a X-Total-Count header" do 113 | headers_keys = last_response.headers.keys 114 | 115 | expect(headers_keys).not_to include("Total") 116 | expect(headers_keys).to include("X-Total-Count") 117 | expect(total).to eq(10) 118 | end 119 | 120 | it "should give a X-Per-Page header" do 121 | headers_keys = last_response.headers.keys 122 | 123 | expect(headers_keys).not_to include("Per-Page") 124 | expect(headers_keys).to include("X-Per-Page") 125 | expect(per_page).to eq(10) 126 | end 127 | 128 | it "should give a X-Page header" do 129 | headers_keys = last_response.headers.keys 130 | 131 | expect(headers_keys).to include("X-Page") 132 | expect(page).to eq(1) 133 | end 134 | end 135 | 136 | context "configured not to include the total" do 137 | before { ApiPagination.config.include_total = false } 138 | 139 | it "should not include a Total header" do 140 | get "/numbers", count: 10 141 | 142 | expect(last_response.header["Total"]).to be_nil 143 | end 144 | 145 | it 'should not include a link with rel "last"' do 146 | get "/numbers", count: 100 147 | 148 | expect(link).to_not include('rel="last"') 149 | end 150 | 151 | after { ApiPagination.config.include_total = true } 152 | end 153 | 154 | context "with query string including array parameter" do 155 | before do 156 | get "/numbers", {count: 100, parity: ["odd", "even"]} 157 | end 158 | 159 | it "returns links with with same received parameters" do 160 | expect(links).to include('; rel="last"') 161 | expect(links).to include('; rel="next"') 162 | end 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # api-pagination 2 | 3 | Paginate in your headers, not in your response body. 4 | This follows the proposed [RFC-8288](https://tools.ietf.org/html/rfc8288) standard for Web linking. 5 | 6 | ## Installation 7 | 8 | In your `Gemfile`: 9 | 10 | ```ruby 11 | # Requires Rails (Rails-API is also supported), or Grape 12 | # v0.10.0 or later. If you're on an earlier version of 13 | # Grape, use api-pagination v3.0.2. 14 | gem 'rails', '>= 3.0.0' 15 | gem 'rails-api' 16 | gem 'grape', '>= 0.10.0' 17 | 18 | # Then choose your preferred paginator from the following: 19 | gem 'pagy', '>= 43.0.0' # Due to breaking changes, newer versions of api-pagination require pagy 43.0.0 or later 20 | gem 'kaminari' 21 | gem 'will_paginate' 22 | 23 | # Finally... 24 | gem 'api-pagination' 25 | ``` 26 | 27 | ## Configuration (optional) 28 | 29 | By default, api-pagination will detect whether you're using Pagy, Kaminari, or WillPaginate, and it will name headers appropriately. If you want to change any of the configurable settings, you may do so: 30 | 31 | ```ruby 32 | ApiPagination.configure do |config| 33 | # If you have more than one gem included, you can choose a paginator. 34 | config.paginator = :kaminari # or :will_paginate 35 | 36 | # By default, this is set to 'Total' 37 | config.total_header = 'X-Total' 38 | 39 | # By default, this is set to 'Per-Page' 40 | config.per_page_header = 'X-Per-Page' 41 | 42 | # Optional: set this to add a header with the current page number. 43 | config.page_header = 'X-Page' 44 | 45 | # Optional: set this to add other response format. Useful with tools that define :jsonapi format 46 | config.response_formats = [:json, :xml, :jsonapi] 47 | 48 | # Optional: what parameter should be used to set the page option 49 | config.page_param = :page 50 | # or 51 | config.page_param do |params| 52 | params[:page][:number] if params[:page].is_a?(ActionController::Parameters) 53 | end 54 | 55 | # Optional: what parameter should be used to set the per page option 56 | config.per_page_param = :per_page 57 | # or 58 | config.per_page_param do |params| 59 | params[:page][:size] if params[:page].is_a?(ActionController::Parameters) 60 | end 61 | 62 | # Optional: Include the total and last_page link header 63 | # By default, this is set to true 64 | # Note: When using kaminari, this prevents the count call to the database 65 | config.include_total = false 66 | end 67 | ``` 68 | 69 | ### Pagy-specific configuration 70 | 71 | Pagy does not have a built-in way to specify a maximum number of items per page, but `api-pagination` will check if you've set a `:max_per_page` variable. To configure this, you can use the following code somewhere in an initializer: 72 | 73 | ```ruby 74 | Pagy.options[:max_per_page] = 100 75 | ``` 76 | 77 | If left unconfigured, clients can request as many items per page as they wish, so it's highly recommended that you configure this. 78 | 79 | ## Rails 80 | 81 | In your controller, provide a pageable collection to the `paginate` method. In its most convenient form, `paginate` simply mimics `render`: 82 | 83 | ```ruby 84 | class MoviesController < ApplicationController 85 | # GET /movies 86 | def index 87 | movies = Movie.all # Movie.scoped if using ActiveRecord 3.x 88 | 89 | paginate json: movies 90 | end 91 | 92 | # GET /movies/:id/cast 93 | def cast 94 | actors = Movie.find(params[:id]).actors 95 | 96 | # Override how many Actors get returned. If unspecified, 97 | # params[:per_page] (which defaults to 25) will be used. 98 | paginate json: actors, per_page: 10 99 | end 100 | end 101 | ``` 102 | 103 | This will pull your collection from the `json` or `xml` option, paginate it for you using `params[:page]` and `params[:per_page]`, render Link headers, and call `ActionController::Base#render` with whatever you passed to `paginate`. This should work well with [ActiveModel::Serializers](https://github.com/rails-api/active_model_serializers). However, if you need more control over what is done with your paginated collection, you can pass the collection directly to `paginate` to receive a paginated collection and have your headers set. Then, you can pass that paginated collection to a serializer or do whatever you want with it: 104 | 105 | ```ruby 106 | class MoviesController < ApplicationController 107 | # GET /movies 108 | def index 109 | movies = paginate Movie.all 110 | 111 | render json: MoviesSerializer.new(movies) 112 | end 113 | 114 | # GET /movies/:id/cast 115 | def cast 116 | actors = paginate Movie.find(params[:id]).actors, per_page: 10 117 | 118 | render json: ActorsSerializer.new(actors) 119 | end 120 | end 121 | ``` 122 | 123 | Note that the collection sent to `paginate` _must_ respond to your paginator's methods. This is typically fine unless you're dealing with a stock Array. For Kaminari, `Kaminari.paginate_array` will be called for you behind-the-scenes. For WillPaginate, you're out of luck unless you call `require 'will_paginate/array'` somewhere. Because this pollutes `Array`, it won't be done for you automatically. If you use Pagy, it doesn't matter, because Pagy doesn't care what you're paginating. It will just work, as long as the collection responds to `count`. 124 | 125 | **NOTE:** In versions 4.4.0 and below, the `Rails::Pagination` module would end up included in `ActionController::Base` even if `ActionController::API` was defined. As of version 4.5.0, this is no longer the case. If for any reason your API controllers cannot easily changed be changed to inherit from `ActionController::API` instead, you can manually include the module: 126 | 127 | ```ruby 128 | class API::ApplicationController < ActionController::Base 129 | include Rails::Pagination 130 | end 131 | ``` 132 | 133 | ## Grape 134 | 135 | With Grape, `paginate` is used to declare that your endpoint takes a `:page` and `:per_page` param. You can also directly specify a `:max_per_page` that users aren't allowed to go over. Then, inside your API endpoint, it simply takes your collection: 136 | 137 | ```ruby 138 | class MoviesAPI < Grape::API 139 | format :json 140 | 141 | desc 'Return a paginated set of movies' 142 | paginate 143 | get do 144 | # This method must take an ActiveRecord::Relation 145 | # or some equivalent pageable set. 146 | paginate Movie.all 147 | end 148 | 149 | route_param :id do 150 | desc "Return one movie's cast, paginated" 151 | # Override how many Actors get returned. If unspecified, 152 | # params[:per_page] (which defaults to 25) will be used. 153 | # There is no default for `max_per_page`. 154 | paginate per_page: 10, max_per_page: 200 155 | get :cast do 156 | paginate Movie.find(params[:id]).actors 157 | end 158 | 159 | desc "Return one movie's awards, paginated" 160 | # Enforce max_per_page value will add the alowed values 161 | # to the swagger docs, and cause grape to return an error 162 | # if outside that range 163 | paginate per_page: 10, max_per_page: 200, enforce_max_per_page: true 164 | get :awards do 165 | paginate Movie.find(params[:id]).awards 166 | end 167 | end 168 | end 169 | ``` 170 | 171 | ## Headers 172 | 173 | Then `curl --include` to see your header-based pagination in action: 174 | 175 | ```bash 176 | $ curl --include 'https://localhost:3000/movies?page=5' 177 | HTTP/1.1 200 OK 178 | Link: ; rel="first", 179 | ; rel="last", 180 | ; rel="next", 181 | ; rel="prev" 182 | Total: 4321 183 | Per-Page: 10 184 | # ... 185 | ``` 186 | 187 | ## A Note on Kaminari and WillPaginate 188 | 189 | api-pagination requires either Kaminari or WillPaginate in order to function, but some users may find themselves in situations where their application includes both. For example, you may have included [ActiveAdmin][activeadmin] (which uses Kaminari for pagination) and WillPaginate to do your own pagination. While it's suggested that you remove one paginator gem or the other, if you're unable to do so, you _must_ configure api-pagination explicitly: 190 | 191 | ```ruby 192 | ApiPagination.configure do |config| 193 | config.paginator = :will_paginate 194 | end 195 | ``` 196 | 197 | If you don't do this, an annoying warning will print once your app starts seeing traffic. You should also configure Kaminari to use a different name for its `per_page` method (see https://github.com/activeadmin/activeadmin/wiki/How-to-work-with-will_paginate): 198 | 199 | ```ruby 200 | Kaminari.configure do |config| 201 | config.page_method_name = :per_page_kaminari 202 | end 203 | ``` 204 | 205 | [activeadmin]: https://github.com/activeadmin/activeadmin 206 | [kaminari]: https://github.com/amatsuda/kaminari 207 | [will_paginate]: https://github.com/mislav/will_paginate 208 | -------------------------------------------------------------------------------- /spec/rails_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "support/shared_examples/existing_headers" 3 | require "support/shared_examples/first_page" 4 | require "support/shared_examples/middle_page" 5 | require "support/shared_examples/last_page" 6 | 7 | describe NumbersController, type: :controller do 8 | before { request.host = "example.org" } 9 | 10 | describe "GET #index" do 11 | let(:link) { response.headers["Link"] } 12 | let(:links) { link.split(", ") } 13 | let(:total) { response.headers["Total"].to_i } 14 | let(:per_page) { response.headers["Per-Page"].to_i } 15 | 16 | context "without enough items to give more than one page" do 17 | before { get :index, params: {count: 10} } 18 | 19 | it "should not paginate" do 20 | expect(response.headers.keys).not_to include("Link") 21 | end 22 | 23 | it "should give a Total header" do 24 | expect(total).to eq(10) 25 | end 26 | 27 | it "should give a Per-Page header" do 28 | expect(per_page).to eq(10) 29 | end 30 | 31 | it "should list all numbers in the response body" do 32 | body = "[1,2,3,4,5,6,7,8,9,10]" 33 | expect(response.body).to eq(body) 34 | end 35 | end 36 | 37 | context "with existing Link headers" do 38 | before { get :index, params: {count: 30, with_headers: true} } 39 | 40 | it_behaves_like "an endpoint with existing Link headers" 41 | end 42 | 43 | context "with enough items to paginate" do 44 | context "when on the first page" do 45 | before { get :index, params: {count: 100} } 46 | 47 | it_behaves_like "an endpoint with a first page" 48 | end 49 | 50 | context "when on the last page" do 51 | before { get :index, params: {count: 100, page: 10} } 52 | 53 | it_behaves_like "an endpoint with a last page" 54 | end 55 | 56 | context "when somewhere comfortably in the middle" do 57 | before { get :index, params: {count: 100, page: 2} } 58 | 59 | it_behaves_like "an endpoint with a middle page" 60 | end 61 | end 62 | 63 | context "providing a block" do 64 | it "yields to the block instead of implicitly rendering" do 65 | get :index_with_custom_render, params: {count: 100} 66 | 67 | json = {numbers: (1..10).map { |n| {number: n} }}.to_json 68 | 69 | expect(response.body).to eq(json) 70 | end 71 | end 72 | 73 | context "with custom response headers" do 74 | before do 75 | ApiPagination.config.total_header = "X-Total-Count" 76 | ApiPagination.config.per_page_header = "X-Per-Page" 77 | ApiPagination.config.page_header = "X-Page" 78 | ApiPagination.config.base_url = "http://guybrush:3000" 79 | 80 | get :index, params: params 81 | end 82 | 83 | after do 84 | ApiPagination.config.total_header = "Total" 85 | ApiPagination.config.per_page_header = "Per-Page" 86 | ApiPagination.config.page_header = nil 87 | ApiPagination.config.base_url = nil 88 | end 89 | 90 | let(:params) { {count: 10} } 91 | let(:total) { response.header["X-Total-Count"].to_i } 92 | let(:per_page) { response.header["X-Per-Page"].to_i } 93 | let(:page) { response.header["X-Page"].to_i } 94 | let(:link) { response.header["Link"] } 95 | 96 | it "should give a X-Total-Count header" do 97 | headers_keys = response.headers.keys 98 | 99 | expect(headers_keys).not_to include("Total") 100 | expect(headers_keys).to include("X-Total-Count") 101 | expect(total).to eq(10) 102 | end 103 | 104 | it "should give a X-Per-Page header" do 105 | headers_keys = response.headers.keys 106 | 107 | expect(headers_keys).not_to include("Per-Page") 108 | expect(headers_keys).to include("X-Per-Page") 109 | expect(per_page).to eq(10) 110 | end 111 | 112 | it "should give a X-Page header" do 113 | headers_keys = response.headers.keys 114 | 115 | expect(headers_keys).to include("X-Page") 116 | expect(page).to eq(1) 117 | end 118 | 119 | context "with paginated result" do 120 | let(:params) { {count: 20} } 121 | it "should use custom base_url in the Link header" do 122 | expect(response.headers["Link"]).to eq( 123 | '; rel="last", ; rel="next"' 124 | ) 125 | end 126 | end 127 | end 128 | 129 | context "configured not to include the total" do 130 | before { ApiPagination.config.include_total = false } 131 | 132 | it "should not include a Total header" do 133 | get :index, params: {count: 10} 134 | 135 | expect(response.header["Total"]).to be_nil 136 | end 137 | 138 | it 'should not include a link with rel "last"' do 139 | get :index, params: {count: 100} 140 | 141 | expect(link).to_not include('rel="last"') 142 | end 143 | 144 | after { ApiPagination.config.include_total = true } 145 | end 146 | 147 | context "custom page param" do 148 | context "page_param as a symbol" do 149 | before do 150 | ApiPagination.config.page_param = :foo 151 | ApiPagination.config.page_header = "Page" 152 | end 153 | 154 | after do 155 | ApiPagination.config.page_param = :page 156 | ApiPagination.config.page_header = nil 157 | end 158 | 159 | it "should work" do 160 | get :index, params: {foo: 2, count: 100} 161 | 162 | expect(response.header["Page"]).to eq("2") 163 | end 164 | end 165 | 166 | context "page_param as a block" do 167 | before do 168 | ApiPagination.config.page_param do |params| 169 | params[:foo][:bar] 170 | end 171 | 172 | ApiPagination.config.page_header = "Page" 173 | end 174 | 175 | after do 176 | ApiPagination.config.page_param = :page 177 | ApiPagination.config.page_header = nil 178 | end 179 | 180 | it "should work" do 181 | get :index, params: {foo: {bar: 2}, count: 100} 182 | 183 | expect(response.header["Page"]).to eq("2") 184 | end 185 | end 186 | end 187 | 188 | context "custom per_page param" do 189 | context "per_page_param as a symbol" do 190 | before do 191 | ApiPagination.config.per_page_param = :foo 192 | end 193 | 194 | after do 195 | ApiPagination.config.per_page_param = :per_page 196 | end 197 | 198 | it "should work" do 199 | get :index_with_no_per_page, params: {foo: 2, count: 100} 200 | 201 | expect(response.header["Per-Page"]).to eq("2") 202 | end 203 | end 204 | 205 | context "page_param as a block" do 206 | before do 207 | ApiPagination.config.per_page_param do |params| 208 | params[:foo][:bar] 209 | end 210 | end 211 | 212 | after do 213 | ApiPagination.config.per_page_param = :per_page 214 | end 215 | 216 | it "should work" do 217 | get :index_with_no_per_page, params: {foo: {bar: 2}, count: 100} 218 | 219 | expect(response.header["Per-Page"]).to eq("2") 220 | end 221 | end 222 | end 223 | 224 | if ApiPagination.config.paginator.to_sym == :kaminari 225 | context "paginate array options" do 226 | let(:paginate_array_total_count) { 300 } 227 | let(:total_header) { 300 } 228 | let(:count) { 50 } 229 | let(:params) do 230 | { 231 | paginate_array_total_count: paginate_array_total_count, 232 | count: count 233 | } 234 | end 235 | 236 | it "has a properly set Total header" do 237 | get :index_with_paginate_array_options, params: params 238 | 239 | expect(response.header["Total"]).to be_kind_of(String) 240 | expect(response.header["Total"].to_i).to eq total_header 241 | end 242 | end 243 | end 244 | 245 | if [:will_paginate, :kaminari].include?(ApiPagination.config.paginator.to_sym) 246 | context "default per page in model" do 247 | before do 248 | # Use Integer instead of Fixnum (Fixnum was unified with Integer in Ruby 2.4+) 249 | Integer.class_eval do 250 | @default_per_page = 6 251 | @per_page = 6 252 | 253 | class << self 254 | attr_accessor :default_per_page, :per_page 255 | end 256 | end 257 | end 258 | 259 | after do 260 | Integer.class_eval do 261 | @default_per_page = 25 262 | @per_page = 25 263 | end 264 | end 265 | 266 | after :all do 267 | class << Integer 268 | undef_method :default_per_page, :per_page 269 | end 270 | end 271 | 272 | it "should use default per page from model" do 273 | get :index_with_no_per_page, params: {count: 100} 274 | 275 | expect(response.header["Per-Page"]).to eq("6") 276 | end 277 | 278 | it "should not fail if the model yields nil for per page" do 279 | Integer.class_eval do 280 | @default_per_page = nil 281 | @per_page = nil 282 | end 283 | 284 | get :index_with_no_per_page, params: {count: 100} 285 | 286 | expect(response.header["Per-Page"]).to eq( 287 | case ApiPagination.config.paginator 288 | when :pagy then Pagy.options[:limit].to_s 289 | when :kaminari then Kaminari.config.default_per_page.to_s 290 | when :will_paginate then WillPaginate.per_page.to_s 291 | end 292 | ) 293 | end 294 | end 295 | end 296 | 297 | context "default per page in objects without paginator defaults" do 298 | it "should not fail if model does not respond to per page" do 299 | get :index_with_no_per_page, params: {count: 100} 300 | 301 | expect(response.header["Per-Page"]).to eq( 302 | case ApiPagination.config.paginator 303 | when :pagy then Pagy.options[:limit].to_s 304 | when :kaminari then Kaminari.config.default_per_page.to_s 305 | when :will_paginate then WillPaginate.per_page.to_s 306 | end 307 | ) 308 | end 309 | end 310 | end 311 | end 312 | --------------------------------------------------------------------------------