Skip to content

Commit 77d9697

Browse files
committed
feat(utils): add pagination support
1 parent e9e7e2c commit 77d9697

7 files changed

Lines changed: 286 additions & 2 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<p align="center">
2-
<em>Quicker FastApi developing tools</em>
2+
<em>Quicker FastAPI development tools</em>
33
</p>
44
<p align="center">
55
<a href="https://github.com/dmontagu/fastapi-utils" target="_blank">
@@ -51,9 +51,10 @@ It also adds a variety of more basic utilities that are useful across a wide var
5151
* **APISettings**: A subclass of `pydantic.BaseSettings` that makes it easy to configure FastAPI through environment variables
5252
* **String-Valued Enums**: The `StrEnum` and `CamelStrEnum` classes make string-valued enums easier to maintain
5353
* **CamelCase Conversions**: Convenience functions for converting strings from `snake_case` to `camelCase` or `PascalCase` and back
54+
* **Pagination**: Reusable limit/offset pagination parameters and response models
5455
* **GUID Type**: The provided GUID type makes it easy to use UUIDs as the primary keys for your database tables
5556

56-
See the [docs](https://fastapiutils.github.io/fastapi-utils//) for more details and examples.
57+
See the [docs](https://fastapiutils.github.io/fastapi-utils/) for more details and examples.
5758

5859
## Requirements
5960

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ It also adds a variety of more basic utilities that are useful across a wide var
5151
* **APISettings**: A subclass of `pydantic.BaseSettings` that makes it easy to configure FastAPI through environment variables
5252
* **String-Valued Enums**: The `StrEnum` and `CamelStrEnum` classes make string-valued enums easier to maintain
5353
* **CamelCase Conversions**: Convenience functions for converting strings from `snake_case` to `camelCase` or `PascalCase` and back
54+
* **Pagination**: Reusable limit/offset pagination parameters and response models
5455
* **GUID Type**: The provided GUID type makes it easy to use UUIDs as the primary keys for your database tables
5556

5657
See the [docs](https://https://fastapiutils.github.io/fastapi-utils//) for more details and examples.

docs/src/pagination1.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from fastapi import Depends, FastAPI
2+
from pydantic import BaseModel
3+
4+
from fastapi_utils.pagination import LimitOffsetParams, Page, paginate
5+
6+
app = FastAPI()
7+
8+
9+
class UserOut(BaseModel):
10+
id: int
11+
name: str
12+
13+
14+
USERS = [
15+
UserOut(id=1, name="Ada"),
16+
UserOut(id=2, name="Grace"),
17+
UserOut(id=3, name="Linus"),
18+
UserOut(id=4, name="Margaret"),
19+
]
20+
21+
22+
@app.get("/users", response_model=Page[UserOut])
23+
def list_users(params: LimitOffsetParams = Depends()) -> Page[UserOut]:
24+
return paginate(
25+
USERS[params.offset : params.offset + params.limit],
26+
total=len(USERS),
27+
params=params,
28+
)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#### Source module: [`fastapi_utils.pagination`](https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/pagination.py){.internal-link target=_blank}
2+
3+
---
4+
5+
Many APIs expose list endpoints that need the same query parameters and response shape over and over.
6+
The pagination utilities provide a reusable limit/offset dependency and a generic response model for those endpoints.
7+
8+
## Limit and offset pagination
9+
10+
Use `LimitOffsetParams` as a FastAPI dependency, then return a `Page` response with `paginate`:
11+
12+
```python hl_lines="4 21 22"
13+
{!./src/pagination1.py!}
14+
```
15+
16+
A request to `/users?limit=2&offset=1` returns:
17+
18+
```JSON
19+
{
20+
"items": [
21+
{"id": 2, "name": "Grace"},
22+
{"id": 3, "name": "Linus"}
23+
],
24+
"total": 4,
25+
"limit": 2,
26+
"offset": 1,
27+
"nextOffset": 3,
28+
"previousOffset": 0
29+
}
30+
```
31+
32+
The Python model fields use `snake_case`, while the generated OpenAPI schema and responses use `camelCase`,
33+
matching the behavior of `APIModel`.

fastapi_utils/pagination.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import annotations
2+
3+
from functools import partial
4+
from typing import Generic, List, Optional, Sequence, TypeVar
5+
6+
import pydantic
7+
from fastapi import Query
8+
from pydantic import Field
9+
10+
from .api_model import APIModel
11+
from .camelcase import snake2camel
12+
13+
PYDANTIC_VERSION = pydantic.VERSION
14+
15+
DEFAULT_LIMIT = 50
16+
MAX_LIMIT = 100
17+
18+
T = TypeVar("T")
19+
20+
21+
class LimitOffsetParams:
22+
"""
23+
A reusable FastAPI dependency for limit/offset pagination query parameters.
24+
"""
25+
26+
def __init__(
27+
self,
28+
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
29+
offset: int = Query(0, ge=0),
30+
):
31+
if not isinstance(limit, int):
32+
limit = DEFAULT_LIMIT
33+
if not isinstance(offset, int):
34+
offset = 0
35+
self._validate(limit=limit, offset=offset)
36+
self.limit = limit
37+
self.offset = offset
38+
39+
@staticmethod
40+
def _validate(*, limit: int, offset: int) -> None:
41+
if limit < 1:
42+
raise ValueError("limit must be greater than or equal to 1")
43+
if limit > MAX_LIMIT:
44+
raise ValueError(f"limit must be less than or equal to {MAX_LIMIT}")
45+
if offset < 0:
46+
raise ValueError("offset must be greater than or equal to 0")
47+
48+
49+
if PYDANTIC_VERSION[0] == "2":
50+
51+
class _PageBase(APIModel):
52+
pass
53+
54+
else:
55+
from pydantic.generics import GenericModel
56+
57+
class _PageBase(GenericModel):
58+
class Config:
59+
orm_mode = True
60+
allow_population_by_field_name = True
61+
alias_generator = partial(snake2camel, start_lower=True)
62+
63+
64+
class Page(_PageBase, Generic[T]):
65+
"""
66+
A generic response model for limit/offset paginated endpoints.
67+
"""
68+
69+
items: List[T]
70+
total: int = Field(..., ge=0)
71+
limit: int = Field(..., ge=1)
72+
offset: int = Field(..., ge=0)
73+
next_offset: Optional[int] = Field(None, ge=0)
74+
previous_offset: Optional[int] = Field(None, ge=0)
75+
76+
77+
def paginate(items: Sequence[T], *, total: int, params: LimitOffsetParams) -> Page[T]:
78+
"""
79+
Builds a paginated response from an already-paginated sequence and total count.
80+
"""
81+
if total < 0:
82+
raise ValueError("total must be greater than or equal to 0")
83+
84+
next_offset = params.offset + params.limit
85+
if next_offset >= total:
86+
next_offset = None
87+
88+
previous_offset = None
89+
if params.offset > 0:
90+
previous_offset = max(params.offset - params.limit, 0)
91+
92+
return Page(
93+
items=list(items),
94+
total=total,
95+
limit=params.limit,
96+
offset=params.offset,
97+
next_offset=next_offset,
98+
previous_offset=previous_offset,
99+
)

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ nav:
2727
- APISettings: "user-guide/basics/api-settings.md"
2828
- String-Valued Enums: "user-guide/basics/enums.md"
2929
- CamelCase Conversion: "user-guide/basics/camelcase.md"
30+
- Pagination: "user-guide/basics/pagination.md"
3031
- GUID Type: "user-guide/basics/guid-type.md"
3132
- Get Help: "help-fastapi-utils.md"
3233
- Development - Contributing: "contributing.md"

tests/test_pagination.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from __future__ import annotations
2+
3+
import pydantic
4+
import pytest
5+
from fastapi import Depends, FastAPI
6+
from starlette.testclient import TestClient
7+
8+
from fastapi_utils.pagination import LimitOffsetParams, Page, paginate
9+
10+
PYDANTIC_VERSION = pydantic.VERSION
11+
12+
13+
def model_dump_by_alias(model: Page[int]) -> dict[str, object]:
14+
if PYDANTIC_VERSION[0] == "2":
15+
return model.model_dump(by_alias=True)
16+
return model.dict(by_alias=True)
17+
18+
19+
def test_paginate_first_page() -> None:
20+
params = LimitOffsetParams(limit=2, offset=0)
21+
22+
page = paginate([1, 2], total=5, params=params)
23+
24+
assert page.items == [1, 2]
25+
assert page.total == 5
26+
assert page.limit == 2
27+
assert page.offset == 0
28+
assert page.next_offset == 2
29+
assert page.previous_offset is None
30+
31+
32+
def test_paginate_middle_page() -> None:
33+
params = LimitOffsetParams(limit=2, offset=2)
34+
35+
page = paginate([3, 4], total=5, params=params)
36+
37+
assert page.next_offset == 4
38+
assert page.previous_offset == 0
39+
40+
41+
def test_paginate_last_page() -> None:
42+
params = LimitOffsetParams(limit=2, offset=4)
43+
44+
page = paginate([5], total=5, params=params)
45+
46+
assert page.next_offset is None
47+
assert page.previous_offset == 2
48+
49+
50+
def test_paginate_previous_offset_does_not_go_below_zero() -> None:
51+
params = LimitOffsetParams(limit=50, offset=20)
52+
53+
page = paginate([1], total=100, params=params)
54+
55+
assert page.previous_offset == 0
56+
57+
58+
def test_paginate_rejects_negative_total() -> None:
59+
params = LimitOffsetParams()
60+
61+
with pytest.raises(ValueError, match="total"):
62+
paginate([], total=-1, params=params)
63+
64+
65+
def test_limit_offset_params_can_be_constructed_without_fastapi() -> None:
66+
params = LimitOffsetParams()
67+
68+
assert params.limit == 50
69+
assert params.offset == 0
70+
71+
72+
def test_limit_offset_params_validate_direct_values() -> None:
73+
with pytest.raises(ValueError, match="limit"):
74+
LimitOffsetParams(limit=0)
75+
76+
with pytest.raises(ValueError, match="limit"):
77+
LimitOffsetParams(limit=101)
78+
79+
with pytest.raises(ValueError, match="offset"):
80+
LimitOffsetParams(offset=-1)
81+
82+
83+
def test_page_uses_camel_case_aliases() -> None:
84+
page = Page[int](
85+
items=[1],
86+
total=2,
87+
limit=1,
88+
offset=0,
89+
next_offset=1,
90+
previous_offset=None,
91+
)
92+
93+
dumped = model_dump_by_alias(page)
94+
95+
assert dumped["nextOffset"] == 1
96+
assert dumped["previousOffset"] is None
97+
98+
99+
def test_limit_offset_params_work_as_fastapi_dependency() -> None:
100+
app = FastAPI()
101+
values = [1, 2, 3, 4]
102+
103+
@app.get("/items", response_model=Page[int])
104+
def list_items(params: LimitOffsetParams = Depends()) -> Page[int]:
105+
return paginate(
106+
values[params.offset : params.offset + params.limit],
107+
total=len(values),
108+
params=params,
109+
)
110+
111+
response = TestClient(app).get("/items?limit=2&offset=1")
112+
113+
assert response.status_code == 200
114+
assert response.json() == {
115+
"items": [2, 3],
116+
"total": 4,
117+
"limit": 2,
118+
"offset": 1,
119+
"nextOffset": 3,
120+
"previousOffset": 0,
121+
}

0 commit comments

Comments
 (0)