Skip to main content

axum_api_kit/
pagination.rs

1use axum::{
2    extract::{rejection::QueryRejection, FromRequestParts, Query},
3    http::{request::Parts, StatusCode},
4    Json,
5};
6use serde::{Deserialize, Serialize};
7
8use crate::{ApiError, CursorResponse, ListResponse};
9
10/// Offset/limit pagination parameters parsed from the query string.
11///
12/// Reads the `limit` and `offset` query parameters. A missing `limit` falls back to
13/// [`Pagination::DEFAULT_LIMIT`] and a missing `offset` to `0`. `limit` is clamped to
14/// `1..=`[`Pagination::MAX_LIMIT`]. A non-numeric value rejects the request with a
15/// `400 Bad Request` carrying code `INVALID_QUERY`.
16///
17/// Requires the `extract` feature.
18///
19/// # Example
20///
21/// ```rust,no_run
22/// use axum_api_kit::{ListResponse, Pagination};
23/// use serde::Serialize;
24///
25/// #[derive(Serialize)]
26/// struct Item {
27///     id: u64,
28/// }
29///
30/// async fn list(page: Pagination) -> ListResponse<Item> {
31///     // Query your store using page.limit / page.offset...
32///     let items = vec![Item { id: 1 }];
33///     page.list_response(items, 1)
34/// }
35/// ```
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct Pagination {
38    /// Maximum number of items to return (clamped to `1..=`[`Pagination::MAX_LIMIT`]).
39    pub limit: u32,
40    /// Zero-based offset of the first item in the page.
41    pub offset: u32,
42}
43
44impl Pagination {
45    /// Page size used when the `limit` query parameter is absent.
46    pub const DEFAULT_LIMIT: u32 = 50;
47    /// Largest page size accepted; larger requests are clamped down to this value.
48    pub const MAX_LIMIT: u32 = 100;
49
50    /// Build a [`ListResponse`] for this page from its items and the total match count.
51    pub fn list_response<T: Serialize>(&self, data: Vec<T>, total: i64) -> ListResponse<T> {
52        ListResponse {
53            data,
54            total,
55            limit: self.limit,
56            offset: self.offset,
57        }
58    }
59}
60
61#[derive(Deserialize)]
62struct PaginationParams {
63    limit: Option<u32>,
64    offset: Option<u32>,
65}
66
67impl<S> FromRequestParts<S> for Pagination
68where
69    S: Send + Sync,
70{
71    type Rejection = (StatusCode, Json<ApiError>);
72
73    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
74        let Query(params) = Query::<PaginationParams>::from_request_parts(parts, state)
75            .await
76            .map_err(query_rejection_to_api_error)?;
77
78        Ok(Pagination {
79            limit: clamp_limit(params.limit),
80            offset: params.offset.unwrap_or(0),
81        })
82    }
83}
84
85/// Cursor-based pagination parameters parsed from the query string.
86///
87/// Reads an opaque `cursor` token (absent on the first page) and a `limit` that is clamped
88/// the same way as [`Pagination`]. Requires the `extract` feature.
89///
90/// # Example
91///
92/// ```rust,no_run
93/// use axum_api_kit::{CursorPagination, CursorResponse};
94/// use serde::Serialize;
95///
96/// #[derive(Serialize)]
97/// struct Item {
98///     id: u64,
99/// }
100///
101/// async fn feed(page: CursorPagination) -> CursorResponse<Item> {
102///     // Decode page.cursor, fetch page.limit + 1 rows, derive the next token...
103///     let items = vec![Item { id: 1 }];
104///     page.cursor_response(items, Some("next".into()))
105/// }
106/// ```
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct CursorPagination {
109    /// Opaque cursor token for the requested page. `None` on the first page.
110    pub cursor: Option<String>,
111    /// Maximum number of items to return (clamped to `1..=`[`Pagination::MAX_LIMIT`]).
112    pub limit: u32,
113}
114
115impl CursorPagination {
116    /// Build a [`CursorResponse`] from this page's items and the next-page cursor.
117    ///
118    /// `has_more` is set to `next_cursor.is_some()`.
119    pub fn cursor_response<T: Serialize>(
120        &self,
121        data: Vec<T>,
122        next_cursor: Option<String>,
123    ) -> CursorResponse<T> {
124        CursorResponse {
125            has_more: next_cursor.is_some(),
126            next_cursor,
127            data,
128        }
129    }
130}
131
132#[derive(Deserialize)]
133struct CursorParams {
134    cursor: Option<String>,
135    limit: Option<u32>,
136}
137
138impl<S> FromRequestParts<S> for CursorPagination
139where
140    S: Send + Sync,
141{
142    type Rejection = (StatusCode, Json<ApiError>);
143
144    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
145        let Query(params) = Query::<CursorParams>::from_request_parts(parts, state)
146            .await
147            .map_err(query_rejection_to_api_error)?;
148
149        Ok(CursorPagination {
150            cursor: params.cursor,
151            limit: clamp_limit(params.limit),
152        })
153    }
154}
155
156/// Apply the default and the `1..=MAX_LIMIT` clamp shared by both extractors.
157fn clamp_limit(limit: Option<u32>) -> u32 {
158    limit
159        .unwrap_or(Pagination::DEFAULT_LIMIT)
160        .clamp(1, Pagination::MAX_LIMIT)
161}
162
163/// Map an Axum [`QueryRejection`] onto an [`ApiError`], preserving its HTTP status.
164fn query_rejection_to_api_error(rejection: QueryRejection) -> (StatusCode, Json<ApiError>) {
165    (
166        rejection.status(),
167        Json(ApiError::new("INVALID_QUERY", rejection.body_text())),
168    )
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use axum::{body::Body, http::Request};
175
176    async fn pagination(uri: &str) -> Result<Pagination, (StatusCode, ApiError)> {
177        let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
178        let (mut parts, _) = req.into_parts();
179        Pagination::from_request_parts(&mut parts, &())
180            .await
181            .map_err(|(status, Json(err))| (status, err))
182    }
183
184    async fn cursor(uri: &str) -> Result<CursorPagination, (StatusCode, ApiError)> {
185        let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
186        let (mut parts, _) = req.into_parts();
187        CursorPagination::from_request_parts(&mut parts, &())
188            .await
189            .map_err(|(status, Json(err))| (status, err))
190    }
191
192    #[tokio::test]
193    async fn pagination_defaults_when_absent() {
194        let p = pagination("/items").await.unwrap();
195        assert_eq!(p.limit, Pagination::DEFAULT_LIMIT);
196        assert_eq!(p.offset, 0);
197    }
198
199    #[tokio::test]
200    async fn pagination_parses_limit_and_offset() {
201        let p = pagination("/items?limit=10&offset=20").await.unwrap();
202        assert_eq!(p.limit, 10);
203        assert_eq!(p.offset, 20);
204    }
205
206    #[tokio::test]
207    async fn pagination_clamps_limit_to_max() {
208        let p = pagination("/items?limit=100000").await.unwrap();
209        assert_eq!(p.limit, Pagination::MAX_LIMIT);
210    }
211
212    #[tokio::test]
213    async fn pagination_clamps_zero_limit_to_one() {
214        let p = pagination("/items?limit=0").await.unwrap();
215        assert_eq!(p.limit, 1);
216    }
217
218    #[tokio::test]
219    async fn pagination_rejects_non_numeric_limit() {
220        let (status, err) = pagination("/items?limit=abc").await.unwrap_err();
221        assert_eq!(status, StatusCode::BAD_REQUEST);
222        assert_eq!(err.code, "INVALID_QUERY");
223    }
224
225    #[tokio::test]
226    async fn pagination_builds_list_response() {
227        let p = pagination("/items?limit=5&offset=15").await.unwrap();
228        let resp = p.list_response(vec![1, 2, 3], 42);
229        assert_eq!(resp.limit, 5);
230        assert_eq!(resp.offset, 15);
231        assert_eq!(resp.total, 42);
232        assert_eq!(resp.data.len(), 3);
233    }
234
235    #[tokio::test]
236    async fn cursor_defaults_when_absent() {
237        let c = cursor("/feed").await.unwrap();
238        assert_eq!(c.cursor, None);
239        assert_eq!(c.limit, Pagination::DEFAULT_LIMIT);
240    }
241
242    #[tokio::test]
243    async fn cursor_parses_cursor_and_limit() {
244        let c = cursor("/feed?cursor=abc123&limit=5").await.unwrap();
245        assert_eq!(c.cursor.as_deref(), Some("abc123"));
246        assert_eq!(c.limit, 5);
247    }
248
249    #[tokio::test]
250    async fn cursor_response_sets_has_more_from_next_cursor() {
251        let c = cursor("/feed").await.unwrap();
252        let more = c.cursor_response(vec![1], Some("next".into()));
253        assert!(more.has_more);
254        assert_eq!(more.next_cursor.as_deref(), Some("next"));
255
256        let done = c.cursor_response(vec![1], None);
257        assert!(!done.has_more);
258        assert_eq!(done.next_cursor, None);
259    }
260}