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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct Pagination {
38 pub limit: u32,
40 pub offset: u32,
42}
43
44impl Pagination {
45 pub const DEFAULT_LIMIT: u32 = 50;
47 pub const MAX_LIMIT: u32 = 100;
49
50 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#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct CursorPagination {
109 pub cursor: Option<String>,
111 pub limit: u32,
113}
114
115impl CursorPagination {
116 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
156fn clamp_limit(limit: Option<u32>) -> u32 {
158 limit
159 .unwrap_or(Pagination::DEFAULT_LIMIT)
160 .clamp(1, Pagination::MAX_LIMIT)
161}
162
163fn 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}