Add LinkHeaderPagination class.

This expands on the "Header based pagination" example given in the
documentation but includes it in the app since it is a commonly used
pagination style.
This commit is contained in:
Tommy Beadle 2017-07-11 15:55:54 -04:00
parent 8a8389bd4b
commit bce4f73743
3 changed files with 180 additions and 23 deletions

View File

@ -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: <https://api.example.org/accounts/>; rel="first", <https://api.example.org/accounts/?page=3>; rel="prev", <https://api.example.org/accounts/?page=5>; rel="next", <https://api.example.org/accounts/?page=11>; 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

View File

@ -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:

View File

@ -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'] == (
'<http://testserver/?page=2>; rel="next", '
'<http://testserver/?page=20>; 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'] == (
'<http://testserver/>; rel="first", '
'<http://testserver/>; rel="prev", '
'<http://testserver/?page=3>; rel="next", '
'<http://testserver/?page=20>; 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'] == (
'<http://testserver/>; rel="first", '
'<http://testserver/?page=19>; 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`.