diff --git a/docs/api-guide/pagination.md b/docs/api-guide/pagination.md index 888390018..7767d3a39 100644 --- a/docs/api-guide/pagination.md +++ b/docs/api-guide/pagination.md @@ -110,6 +110,40 @@ To set these attributes you should override the `PageNumberPagination` class, an --- +## LinkHeaderPagination + +This pagination style accepts a single page number in the request query parameters. The response uses an HTTP header called `Link` to provide the URLs for the next, previous, first, and last pages as described in [Github's Developer Documentation][github-link-pagination]. If you are using Python's [Requests][requests] library to make the request, this header is automatically parsed for you as described [here][requests-link-header]. + +**Request**: + + GET https://api.example.org/accounts/?page=4 + +**Response**: + + HTTP 200 OK + Link: ; rel="first", ; rel="prev", ; rel="next", ; rel="last" + + [ + … + ] + +#### Setup + +To enable the `LinkHeaderPagination` style globally, use the following configuration, modifying the `PAGE_SIZE` as desired: + + REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LinkHeaderPagination', + 'PAGE_SIZE': 100 + } + +On `GenericAPIView` subclasses you may also set the `pagination_class` attribute to select `LinkHeaderPagination` on a per-view basis. + +#### Configuration + +The configuration is the same as for `PageNumberPagination` described above. + +--- + ## LimitOffsetPagination This pagination style mirrors the syntax used when looking up multiple database records. The client includes both a "limit" and an @@ -242,29 +276,6 @@ We'd then need to setup the custom class in our configuration: Note that if you care about how the ordering of keys is displayed in responses in the browsable API you might choose to use an `OrderedDict` when constructing the body of paginated responses, but this is optional. -## Header based pagination - -Let's modify the built-in `PageNumberPagination` style, so that instead of include the pagination links in the body of the response, we'll instead include a `Link` header, in a [similar style to the GitHub API][github-link-pagination]. - - class LinkHeaderPagination(pagination.PageNumberPagination): - def get_paginated_response(self, data): - next_url = self.get_next_link() - previous_url = self.get_previous_link() - - if next_url is not None and previous_url is not None: - link = '<{next_url}>; rel="next", <{previous_url}>; rel="prev"' - elif next_url is not None: - link = '<{next_url}>; rel="next"' - elif previous_url is not None: - link = '<{previous_url}>; rel="prev"' - else: - link = '' - - link = link.format(next_url=next_url, previous_url=previous_url) - headers = {'Link': link} if link else {} - - return Response(data, headers=headers) - ## Using your custom pagination class To have your custom pagination class be used by default, use the `DEFAULT_PAGINATION_CLASS` setting: @@ -335,3 +346,5 @@ The [`drf-proxy-pagination` package][drf-proxy-pagination] includes a `ProxyPagi [paginate-by-max-mixin]: http://chibisov.github.io/drf-extensions/docs/#paginatebymaxmixin [drf-proxy-pagination]: https://github.com/tuffnatty/drf-proxy-pagination [disqus-cursor-api]: http://cramer.io/2011/03/08/building-cursors-for-the-disqus-api +[requests]: http://docs.python-requests.org +[requests-link-header]: http://docs.python-requests.org/en/master/user/advanced/#link-headers diff --git a/rest_framework/pagination.py b/rest_framework/pagination.py index 0255cfc7f..2310cbebd 100644 --- a/rest_framework/pagination.py +++ b/rest_framework/pagination.py @@ -316,6 +316,50 @@ class PageNumberPagination(BasePagination): return fields +class LinkHeaderPagination(PageNumberPagination): + """ Inform the user of pagination links via response headers, similar to + what's described in + https://developer.github.com/guides/traversing-with-pagination/. + """ + def get_paginated_response(self, data): + next_url = self.get_next_link() + previous_url = self.get_previous_link() + first_url = self.get_first_link() + last_url = self.get_last_link() + + links = [] + for url, label in ( + (first_url, 'first'), + (previous_url, 'prev'), + (next_url, 'next'), + (last_url, 'last'), + ): + if url is not None: + links.append('<{}>; rel="{}"'.format(url, label)) + + headers = {'Link': ', '.join(links)} if links else {} + + return Response(data, headers=headers) + + def get_first_link(self): + if not self.page.has_previous(): + return None + else: + url = self.request.build_absolute_uri() + return remove_query_param(url, self.page_query_param) + + def get_last_link(self): + if not self.page.has_next(): + return None + else: + url = self.request.build_absolute_uri() + return replace_query_param( + url, + self.page_query_param, + self.page.paginator.num_pages, + ) + + class LimitOffsetPagination(BasePagination): """ A limit/offset based style. For example: diff --git a/tests/test_pagination.py b/tests/test_pagination.py index dd7f70330..11a890a2c 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -263,6 +263,106 @@ class TestPageNumberPagination: self.paginate_queryset(request) +class TestLinkHeaderPagination: + """ + Unit tests for `pagination.LinkHeaderPagination`. + """ + + def setup(self): + class ExamplePagination(pagination.LinkHeaderPagination): + page_size = 5 + + self.pagination = ExamplePagination() + self.queryset = range(1, 101) + + def paginate_queryset(self, request): + return list(self.pagination.paginate_queryset(self.queryset, request)) + + def get_paginated_response(self, queryset): + return self.pagination.get_paginated_response(queryset) + + def get_html_context(self): + return self.pagination.get_html_context() + + def test_no_page_number(self): + request = Request(factory.get('/')) + queryset = self.paginate_queryset(request) + response = self.get_paginated_response(queryset) + context = self.get_html_context() + assert queryset == [1, 2, 3, 4, 5] + assert response.data == [1, 2, 3, 4, 5] + assert response['Link'] == ( + '; rel="next", ' + '; rel="last"' + ) + assert context == { + 'previous_url': None, + 'next_url': 'http://testserver/?page=2', + 'page_links': [ + PageLink('http://testserver/', 1, True, False), + PageLink('http://testserver/?page=2', 2, False, False), + PageLink('http://testserver/?page=3', 3, False, False), + PAGE_BREAK, + PageLink('http://testserver/?page=20', 20, False, False), + ] + } + assert self.pagination.display_page_controls + assert isinstance(self.pagination.to_html(), type('')) + + def test_second_page(self): + request = Request(factory.get('/', {'page': 2})) + queryset = self.paginate_queryset(request) + response = self.get_paginated_response(queryset) + context = self.get_html_context() + assert queryset == [6, 7, 8, 9, 10] + assert response.data == [6, 7, 8, 9, 10] + assert response['Link'] == ( + '; rel="first", ' + '; rel="prev", ' + '; rel="next", ' + '; rel="last"' + ) + assert context == { + 'previous_url': 'http://testserver/', + 'next_url': 'http://testserver/?page=3', + 'page_links': [ + PageLink('http://testserver/', 1, False, False), + PageLink('http://testserver/?page=2', 2, True, False), + PageLink('http://testserver/?page=3', 3, False, False), + PAGE_BREAK, + PageLink('http://testserver/?page=20', 20, False, False), + ] + } + + def test_last_page(self): + request = Request(factory.get('/', {'page': 'last'})) + queryset = self.paginate_queryset(request) + response = self.get_paginated_response(queryset) + context = self.get_html_context() + assert queryset == [96, 97, 98, 99, 100] + assert response.data == [96, 97, 98, 99, 100] + assert response['Link'] == ( + '; rel="first", ' + '; rel="prev"' + ) + assert context == { + 'previous_url': 'http://testserver/?page=19', + 'next_url': None, + 'page_links': [ + PageLink('http://testserver/', 1, False, False), + PAGE_BREAK, + PageLink('http://testserver/?page=18', 18, False, False), + PageLink('http://testserver/?page=19', 19, False, False), + PageLink('http://testserver/?page=20', 20, True, False), + ] + } + + def test_invalid_page(self): + request = Request(factory.get('/', {'page': 'invalid'})) + with pytest.raises(exceptions.NotFound): + self.paginate_queryset(request) + + class TestPageNumberPaginationOverride: """ Unit tests for `pagination.PageNumberPagination`.