Commit 3910d408 authored by Adam Hegyi's avatar Adam Hegyi

Merge branch 'mmj-group-api-keyset-pagination' into 'master'

Introduce Keyset pagination for `GET /groups` API endpoint for unauthenticated users.

See merge request gitlab-org/gitlab!68346
parents 30e604cf 4c9e7cc5
---
name: keyset_pagination_for_groups_api
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68346
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/339831
milestone: '14.3'
type: development
group: group::access
default_enabled: false
# frozen_string_literal: true
class AddIndexOnNameAndIdToPublicGroups < Gitlab::Database::Migration[1.0]
INDEX_NAME = 'index_namespaces_public_groups_name_id'
PUBLIC_VISIBILITY_LEVEL = 20
disable_ddl_transaction!
def up
add_concurrent_index :namespaces, [:name, :id], name: INDEX_NAME,
where: "type = 'Group' AND visibility_level = #{PUBLIC_VISIBILITY_LEVEL}"
end
def down
remove_concurrent_index_by_name :namespaces, INDEX_NAME
end
end
9724a5fc1703418f9b1ea1d5375fc3b01834b30e5ff16c60537db5cb00bc210a
\ No newline at end of file
...@@ -25691,6 +25691,8 @@ CREATE INDEX index_namespaces_on_traversal_ids ON namespaces USING gin (traversa ...@@ -25691,6 +25691,8 @@ CREATE INDEX index_namespaces_on_traversal_ids ON namespaces USING gin (traversa
CREATE INDEX index_namespaces_on_type_and_id_partial ON namespaces USING btree (type, id) WHERE (type IS NOT NULL); CREATE INDEX index_namespaces_on_type_and_id_partial ON namespaces USING btree (type, id) WHERE (type IS NOT NULL);
CREATE INDEX index_namespaces_public_groups_name_id ON namespaces USING btree (name, id) WHERE (((type)::text = 'Group'::text) AND (visibility_level = 20));
CREATE INDEX index_non_requested_project_members_on_source_id_and_type ON members USING btree (source_id, source_type) WHERE ((requested_at IS NULL) AND ((type)::text = 'ProjectMember'::text)); CREATE INDEX index_non_requested_project_members_on_source_id_and_type ON members USING btree (source_id, source_type) WHERE ((requested_at IS NULL) AND ((type)::text = 'ProjectMember'::text));
CREATE UNIQUE INDEX index_note_diff_files_on_diff_note_id ON note_diff_files USING btree (diff_note_id); CREATE UNIQUE INDEX index_note_diff_files_on_diff_note_id ON note_diff_files USING btree (diff_note_id);
...@@ -13,6 +13,11 @@ authentication, only public groups are returned. ...@@ -13,6 +13,11 @@ authentication, only public groups are returned.
By default, this request returns 20 results at a time because the API results [are paginated](index.md#pagination). By default, this request returns 20 results at a time because the API results [are paginated](index.md#pagination).
When accessed without authentication, this endpoint also supports [keyset pagination](index.md#keyset-based-pagination):
- When requesting consecutive pages of results, we recommend you use keyset pagination.
- Beyond a specific offset limit (specified by [max offset allowed by the REST API for offset-based pagination](../administration/instance_limits.md#max-offset-allowed-by-the-rest-api-for-offset-based-pagination)), offset pagination is unavailable.
Parameters: Parameters:
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
......
...@@ -462,22 +462,43 @@ The response header includes a link to the next page. For example: ...@@ -462,22 +462,43 @@ The response header includes a link to the next page. For example:
```http ```http
HTTP/1.1 200 OK HTTP/1.1 200 OK
... ...
Links: <https://gitlab.example.com/api/v4/projects?pagination=keyset&per_page=50&order_by=id&sort=asc&id_after=42>; rel="next"
Link: <https://gitlab.example.com/api/v4/projects?pagination=keyset&per_page=50&order_by=id&sort=asc&id_after=42>; rel="next" Link: <https://gitlab.example.com/api/v4/projects?pagination=keyset&per_page=50&order_by=id&sort=asc&id_after=42>; rel="next"
Status: 200 OK Status: 200 OK
... ...
``` ```
The link to the next page contains an additional filter `id_after=42` that
excludes already-retrieved records.
As another example, the following request lists 50 [groups](groups.md) per page ordered
by `name` ascending using keyset pagination:
```shell
curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups?pagination=keyset&per_page=50&order_by=name&sort=asc"
```
The response header includes a link to the next page:
```http
HTTP/1.1 200 OK
...
Link: <https://gitlab.example.com/api/v4/groups?pagination=keyset&per_page=50&order_by=name&sort=asc&cursor=eyJuYW1lIjoiRmxpZ2h0anMiLCJpZCI6IjI2IiwiX2tkIjoibiJ9>; rel="next"
Status: 200 OK
...
```
The link to the next page contains an additional filter `cursor=eyJuYW1lIjoiRmxpZ2h0anMiLCJpZCI6IjI2IiwiX2tkIjoibiJ9` that
excludes already-retrieved records.
The type of filter depends on the
`order_by` option used, and we can have more than one additional filter.
WARNING: WARNING:
The `Links` header is scheduled to be removed in GitLab 14.0 to be aligned with the The `Links` header was removed in GitLab 14.0 to be aligned with the
[W3C `Link` specification](https://www.w3.org/wiki/LinkHeader). The `Link` [W3C `Link` specification](https://www.w3.org/wiki/LinkHeader). The `Link`
header was [added in GitLab 13.1](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33714) header was [added in GitLab 13.1](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33714)
and should be used instead. and should be used instead.
The link to the next page contains an additional filter `id_after=42` that
excludes already-retrieved records. The type of filter depends on the
`order_by` option used, and we may have more than one additional filter.
When the end of the collection is reached and there are no additional When the end of the collection is reached and there are no additional
records to retrieve, the `Link` header is absent and the resulting array is records to retrieve, the `Link` header is absent and the resulting array is
empty. empty.
...@@ -489,9 +510,10 @@ pagination headers. ...@@ -489,9 +510,10 @@ pagination headers.
Keyset-based pagination is supported only for selected resources and ordering Keyset-based pagination is supported only for selected resources and ordering
options: options:
| Resource | Order | | Resource | Options | Availability |
|-------------------------|-------| |:-------------------------|:---------------------------------|:----------------------------------------|
| [Projects](projects.md) | `order_by=id` only. | | [Projects](projects.md) | `order_by=id` only | Authenticated and unauthenticated users |
| [Groups](groups.md) | `order_by=name`, `sort=asc` only | Unauthenticated users only |
## Path parameters ## Path parameters
......
...@@ -108,6 +108,20 @@ module API ...@@ -108,6 +108,20 @@ module API
present paginate(groups), options present paginate(groups), options
end end
def present_groups_with_pagination_strategies(params, groups)
return present_groups(params, groups) if current_user.present? || Feature.disabled?(:keyset_pagination_for_groups_api)
options = {
with: Entities::Group,
current_user: nil,
statistics: false
}
groups, options = with_custom_attributes(groups, options)
present paginate_with_strategies(groups), options
end
def delete_group(group) def delete_group(group)
destroy_conditionally!(group) do |group| destroy_conditionally!(group) do |group|
::Groups::DestroyService.new(group, current_user).async_execute ::Groups::DestroyService.new(group, current_user).async_execute
...@@ -168,7 +182,7 @@ module API ...@@ -168,7 +182,7 @@ module API
end end
get do get do
groups = find_groups(declared_params(include_missing: false), params[:id]) groups = find_groups(declared_params(include_missing: false), params[:id])
present_groups params, groups present_groups_with_pagination_strategies params, groups
end end
desc 'Create a group. Available only for users who can create groups.' do desc 'Create a group. Available only for users who can create groups.' do
......
...@@ -3,10 +3,16 @@ ...@@ -3,10 +3,16 @@
module API module API
module Helpers module Helpers
module PaginationStrategies module PaginationStrategies
def paginate_with_strategies(relation, request_scope) def paginate_with_strategies(relation, request_scope = nil)
paginator = paginator(relation, request_scope) paginator = paginator(relation, request_scope)
yield(paginator.paginate(relation)).tap do |records, _| result = if block_given?
yield(paginator.paginate(relation))
else
paginator.paginate(relation)
end
result.tap do |records, _|
paginator.finalize(records) paginator.finalize(records)
end end
end end
...@@ -20,17 +26,31 @@ module API ...@@ -20,17 +26,31 @@ module API
private private
def keyset_paginator(relation) def keyset_paginator(relation)
request_context = Gitlab::Pagination::Keyset::RequestContext.new(self) if cursor_based_keyset_pagination_supported?(relation)
unless Gitlab::Pagination::Keyset.available?(request_context, relation) request_context_class = Gitlab::Pagination::Keyset::CursorBasedRequestContext
paginator_class = Gitlab::Pagination::Keyset::CursorPager
availability_checker = Gitlab::Pagination::CursorBasedKeyset
else
request_context_class = Gitlab::Pagination::Keyset::RequestContext
paginator_class = Gitlab::Pagination::Keyset::Pager
availability_checker = Gitlab::Pagination::Keyset
end
request_context = request_context_class.new(self)
unless availability_checker.available?(request_context, relation)
return error!('Keyset pagination is not yet available for this type of request', 405) return error!('Keyset pagination is not yet available for this type of request', 405)
end end
Gitlab::Pagination::Keyset::Pager.new(request_context) paginator_class.new(request_context)
end end
def offset_paginator(relation, request_scope) def offset_paginator(relation, request_scope)
offset_limit = limit_for_scope(request_scope) offset_limit = limit_for_scope(request_scope)
if Gitlab::Pagination::Keyset.available_for_type?(relation) && offset_limit_exceeded?(offset_limit) if (Gitlab::Pagination::Keyset.available_for_type?(relation) ||
cursor_based_keyset_pagination_supported?(relation)) &&
offset_limit_exceeded?(offset_limit)
return error!("Offset pagination has a maximum allowed offset of #{offset_limit} " \ return error!("Offset pagination has a maximum allowed offset of #{offset_limit} " \
"for requests that return objects of type #{relation.klass}. " \ "for requests that return objects of type #{relation.klass}. " \
"Remaining records can be retrieved using keyset pagination.", 405) "Remaining records can be retrieved using keyset pagination.", 405)
...@@ -39,6 +59,10 @@ module API ...@@ -39,6 +59,10 @@ module API
Gitlab::Pagination::OffsetPagination.new(self) Gitlab::Pagination::OffsetPagination.new(self)
end end
def cursor_based_keyset_pagination_supported?(relation)
Gitlab::Pagination::CursorBasedKeyset.available_for_type?(relation)
end
def keyset_pagination_enabled? def keyset_pagination_enabled?
params[:pagination] == 'keyset' params[:pagination] == 'keyset'
end end
......
# frozen_string_literal: true
module Gitlab
module Pagination
module CursorBasedKeyset
SUPPORTED_ORDERING = {
Group => { name: :asc }
}.freeze
def self.available_for_type?(relation)
SUPPORTED_ORDERING.key?(relation.klass)
end
def self.available?(cursor_based_request_context, relation)
available_for_type?(relation) &&
order_satisfied?(relation, cursor_based_request_context)
end
def self.order_satisfied?(relation, cursor_based_request_context)
order_by_from_request = cursor_based_request_context.order_by
SUPPORTED_ORDERING[relation.klass] == order_by_from_request
end
private_class_method :order_satisfied?
end
end
end
...@@ -4,11 +4,12 @@ module Gitlab ...@@ -4,11 +4,12 @@ module Gitlab
module Pagination module Pagination
module Keyset module Keyset
class CursorBasedRequestContext class CursorBasedRequestContext
attr_reader :request DEFAULT_SORT_DIRECTION = :desc
delegate :params, :header, to: :request attr_reader :request_context
delegate :params, to: :request_context
def initialize(request) def initialize(request_context)
@request = request @request_context = request_context
end end
def per_page def per_page
...@@ -21,9 +22,13 @@ module Gitlab ...@@ -21,9 +22,13 @@ module Gitlab
def apply_headers(cursor_for_next_page) def apply_headers(cursor_for_next_page)
Gitlab::Pagination::Keyset::HeaderBuilder Gitlab::Pagination::Keyset::HeaderBuilder
.new(self) .new(request_context)
.add_next_page_header({ cursor: cursor_for_next_page }) .add_next_page_header({ cursor: cursor_for_next_page })
end end
def order_by
{ params[:order_by].to_sym => params[:sort]&.to_sym || DEFAULT_SORT_DIRECTION }
end
end end
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Pagination::CursorBasedKeyset do
subject { described_class }
describe '.available_for_type?' do
it 'returns true for Group' do
expect(subject.available_for_type?(Group.all)).to be_truthy
end
it 'return false for other types of relations' do
expect(subject.available_for_type?(User.all)).to be_falsey
end
end
describe '.available?' do
let(:request_context) { double('request_context', params: { order_by: order_by, sort: sort }) }
let(:cursor_based_request_context) { Gitlab::Pagination::Keyset::CursorBasedRequestContext.new(request_context) }
context 'with order-by name asc' do
let(:order_by) { :name }
let(:sort) { :asc }
it 'returns true for Group' do
expect(subject.available?(cursor_based_request_context, Group.all)).to be_truthy
end
it 'return false for other types of relations' do
expect(subject.available?(cursor_based_request_context, User.all)).to be_falsey
end
end
context 'with other order-by columns' do
let(:order_by) { :path }
let(:sort) { :asc }
it 'returns false for Group' do
expect(subject.available?(cursor_based_request_context, Group.all)).to be_falsey
end
it 'return false for other types of relations' do
expect(subject.available?(cursor_based_request_context, User.all)).to be_falsey
end
end
end
end
...@@ -3,32 +3,40 @@ ...@@ -3,32 +3,40 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Gitlab::Pagination::Keyset::CursorBasedRequestContext do RSpec.describe Gitlab::Pagination::Keyset::CursorBasedRequestContext do
let(:params) { { per_page: 2, cursor: 'eyJuYW1lIjoiR2l0TGFiIEluc3RhbmNlIiwiaWQiOiI1MiIsIl9rZCI6Im4ifQ==' } } let(:params) { { per_page: 2, cursor: 'eyJuYW1lIjoiR2l0TGFiIEluc3RhbmNlIiwiaWQiOiI1MiIsIl9rZCI6Im4ifQ==', order_by: :name, sort: :asc } }
let(:request) { double('request', params: params) } let(:request) { double('request', url: 'http://localhost') }
let(:request_context) { double('request_context', header: nil, params: params, request: request) }
describe '#per_page' do describe '#per_page' do
subject(:per_page) { described_class.new(request).per_page } subject(:per_page) { described_class.new(request_context).per_page }
it { is_expected.to eq 2 } it { is_expected.to eq 2 }
end end
describe '#cursor' do describe '#cursor' do
subject(:cursor) { described_class.new(request).cursor } subject(:cursor) { described_class.new(request_context).cursor }
it { is_expected.to eq 'eyJuYW1lIjoiR2l0TGFiIEluc3RhbmNlIiwiaWQiOiI1MiIsIl9rZCI6Im4ifQ==' } it { is_expected.to eq 'eyJuYW1lIjoiR2l0TGFiIEluc3RhbmNlIiwiaWQiOiI1MiIsIl9rZCI6Im4ifQ==' }
end end
describe '#order_by' do
subject(:order_by) { described_class.new(request_context).order_by }
it { is_expected.to eq({ name: :asc }) }
end
describe '#apply_headers' do describe '#apply_headers' do
let(:request) { double('request', url: "http://#{Gitlab.config.gitlab.host}/api/v4/projects?per_page=3", params: params) } let(:request) { double('request', url: "http://#{Gitlab.config.gitlab.host}/api/v4/projects?per_page=3") }
let(:params) { { per_page: 3 } } let(:params) { { per_page: 3 } }
let(:request_context) { double('request_context', header: nil, params: params, request: request) }
let(:cursor_for_next_page) { 'eyJuYW1lIjoiSDVicCIsImlkIjoiMjgiLCJfa2QiOiJuIn0=' } let(:cursor_for_next_page) { 'eyJuYW1lIjoiSDVicCIsImlkIjoiMjgiLCJfa2QiOiJuIn0=' }
subject(:apply_headers) { described_class.new(request).apply_headers(cursor_for_next_page) } subject(:apply_headers) { described_class.new(request_context).apply_headers(cursor_for_next_page) }
it 'sets Link header with same host/path as the original request' do it 'sets Link header with same host/path as the original request' do
orig_uri = URI.parse(request.url) orig_uri = URI.parse(request_context.request.url)
expect(request).to receive(:header).once do |name, header| expect(request_context).to receive(:header).once do |name, header|
first_link, _ = /<([^>]+)>; rel="next"/.match(header).captures first_link, _ = /<([^>]+)>; rel="next"/.match(header).captures
uri = URI.parse(first_link) uri = URI.parse(first_link)
...@@ -42,9 +50,9 @@ RSpec.describe Gitlab::Pagination::Keyset::CursorBasedRequestContext do ...@@ -42,9 +50,9 @@ RSpec.describe Gitlab::Pagination::Keyset::CursorBasedRequestContext do
end end
it 'sets Link header with a cursor to the next page' do it 'sets Link header with a cursor to the next page' do
orig_uri = URI.parse(request.url) orig_uri = URI.parse(request_context.request.url)
expect(request).to receive(:header).once do |name, header| expect(request_context).to receive(:header).once do |name, header|
first_link, _ = /<([^>]+)>; rel="next"/.match(header).captures first_link, _ = /<([^>]+)>; rel="next"/.match(header).captures
query = CGI.parse(URI.parse(first_link).query) query = CGI.parse(URI.parse(first_link).query)
......
...@@ -6,8 +6,8 @@ RSpec.describe Gitlab::Pagination::Keyset::CursorPager do ...@@ -6,8 +6,8 @@ RSpec.describe Gitlab::Pagination::Keyset::CursorPager do
let(:relation) { Group.all.order(:name, :id) } let(:relation) { Group.all.order(:name, :id) }
let(:per_page) { 3 } let(:per_page) { 3 }
let(:params) { { cursor: nil, per_page: per_page } } let(:params) { { cursor: nil, per_page: per_page } }
let(:request) { double('request', params: params) } let(:request_context) { double('request_context', params: params) }
let(:cursor_based_request_context) { Gitlab::Pagination::Keyset::CursorBasedRequestContext.new(request) } let(:cursor_based_request_context) { Gitlab::Pagination::Keyset::CursorBasedRequestContext.new(request_context) }
before_all do before_all do
create_list(:group, 7) create_list(:group, 7)
...@@ -33,7 +33,7 @@ RSpec.describe Gitlab::Pagination::Keyset::CursorPager do ...@@ -33,7 +33,7 @@ RSpec.describe Gitlab::Pagination::Keyset::CursorPager do
it 'passes information about next page to request' do it 'passes information about next page to request' do
cursor_for_next_page = relation.keyset_paginate(**params).cursor_for_next_page cursor_for_next_page = relation.keyset_paginate(**params).cursor_for_next_page
expect_next_instance_of(Gitlab::Pagination::Keyset::HeaderBuilder, cursor_based_request_context) do |builder| expect_next_instance_of(Gitlab::Pagination::Keyset::HeaderBuilder, request_context) do |builder|
expect(builder).to receive(:add_next_page_header).with({ cursor: cursor_for_next_page }) expect(builder).to receive(:add_next_page_header).with({ cursor: cursor_for_next_page })
end end
......
...@@ -158,6 +158,127 @@ RSpec.describe API::Groups do ...@@ -158,6 +158,127 @@ RSpec.describe API::Groups do
end end
end end
context 'pagination strategies' do
let_it_be(:group_1) { create(:group, name: '1_group') }
let_it_be(:group_2) { create(:group, name: '2_group') }
context 'when the user is anonymous' do
context 'offset pagination' do
context 'on making requests beyond the allowed offset pagination threshold' do
it 'returns error and suggests to use keyset pagination' do
get api('/groups'), params: { page: 3000, per_page: 25 }
expect(response).to have_gitlab_http_status(:method_not_allowed)
expect(json_response['error']).to eq(
'Offset pagination has a maximum allowed offset of 50000 for requests that return objects of type Group. '\
'Remaining records can be retrieved using keyset pagination.'
)
end
context 'when the feature flag `keyset_pagination_for_groups_api` is disabled' do
before do
stub_feature_flags(keyset_pagination_for_groups_api: false)
end
it 'returns successful response' do
get api('/groups'), params: { page: 3000, per_page: 25 }
expect(response).to have_gitlab_http_status(:ok)
end
end
end
context 'on making requests below the allowed offset pagination threshold' do
it 'paginates the records' do
get api('/groups'), params: { page: 1, per_page: 1 }
expect(response).to have_gitlab_http_status(:ok)
records = json_response
expect(records.size).to eq(1)
expect(records.first['id']).to eq(group_1.id)
# next page
get api('/groups'), params: { page: 2, per_page: 1 }
expect(response).to have_gitlab_http_status(:ok)
records = Gitlab::Json.parse(response.body)
expect(records.size).to eq(1)
expect(records.first['id']).to eq(group_2.id)
end
end
end
context 'keyset pagination' do
def pagination_links(response)
link = response.headers['LINK']
return unless link
link.split(',').map do |link|
match = link.match(/<(?<url>.*)>; rel="(?<rel>\w+)"/)
break nil unless match
{ url: match[:url], rel: match[:rel] }
end.compact
end
def params_for_next_page(response)
next_url = pagination_links(response).find { |link| link[:rel] == 'next' }[:url]
Rack::Utils.parse_query(URI.parse(next_url).query)
end
context 'on making requests with supported ordering structure' do
it 'paginates the records correctly' do
# first page
get api('/groups'), params: { pagination: 'keyset', per_page: 1 }
expect(response).to have_gitlab_http_status(:ok)
records = json_response
expect(records.size).to eq(1)
expect(records.first['id']).to eq(group_1.id)
params_for_next_page = params_for_next_page(response)
expect(params_for_next_page).to include('cursor')
get api('/groups'), params: params_for_next_page
expect(response).to have_gitlab_http_status(:ok)
records = Gitlab::Json.parse(response.body)
expect(records.size).to eq(1)
expect(records.first['id']).to eq(group_2.id)
end
context 'when the feature flag `keyset_pagination_for_groups_api` is disabled' do
before do
stub_feature_flags(keyset_pagination_for_groups_api: false)
end
it 'ignores the keyset pagination params and performs offset pagination' do
get api('/groups'), params: { pagination: 'keyset', per_page: 1 }
expect(response).to have_gitlab_http_status(:ok)
records = json_response
expect(records.size).to eq(1)
expect(records.first['id']).to eq(group_1.id)
params_for_next_page = params_for_next_page(response)
expect(params_for_next_page).not_to include('cursor')
end
end
end
context 'on making requests with unsupported ordering structure' do
it 'returns error' do
get api('/groups'), params: { pagination: 'keyset', per_page: 1, order_by: 'path', sort: 'desc' }
expect(response).to have_gitlab_http_status(:method_not_allowed)
expect(json_response['error']).to eq('Keyset pagination is not yet available for this type of request')
end
end
end
end
end
context "when authenticated as admin" do context "when authenticated as admin" do
it "admin: returns an array of all groups" do it "admin: returns an array of all groups" do
get api("/groups", admin) get api("/groups", admin)
......
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