Skip to main content

axum_api_kit/
cursor.rs

1use axum::{
2    response::{IntoResponse, Response},
3    Json,
4};
5use serde::Serialize;
6
7/// A cursor-based paginated collection response.
8///
9/// Unlike [`ListResponse`](crate::ListResponse) which uses offset/limit pagination,
10/// `CursorResponse` uses opaque cursor tokens for keyset-based pagination.
11/// This is ideal for large or streaming datasets where total count is expensive to compute.
12///
13/// Serializes as:
14/// ```json
15/// { "data": [...], "next_cursor": "abc123", "has_more": true }
16/// { "data": [...], "next_cursor": null, "has_more": false }
17/// ```
18///
19/// # Example
20///
21/// ```rust
22/// use axum::response::IntoResponse;
23/// use axum_api_kit::CursorResponse;
24/// use serde::Serialize;
25///
26/// #[derive(Serialize)]
27/// struct Item { id: String }
28///
29/// async fn list_items(cursor: Option<String>) -> impl IntoResponse {
30///     CursorResponse {
31///         data: vec![Item { id: "1".into() }],
32///         next_cursor: Some("cursor_for_page_2".into()),
33///         has_more: true,
34///     }
35/// }
36/// ```
37#[derive(Debug, Clone, Serialize)]
38#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
39pub struct CursorResponse<T: Serialize> {
40    /// The items in this page.
41    pub data: Vec<T>,
42    /// Opaque cursor token for fetching the next page.
43    /// `None` indicates this is the last page.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub next_cursor: Option<String>,
46    /// Whether more items exist after this page.
47    pub has_more: bool,
48}
49
50impl<T: Serialize> IntoResponse for CursorResponse<T> {
51    fn into_response(self) -> Response {
52        Json(self).into_response()
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59    use serde_json::json;
60
61    #[derive(Serialize)]
62    struct Item {
63        id: String,
64    }
65
66    #[test]
67    fn serializes_with_next_cursor() {
68        let resp = CursorResponse {
69            data: vec![Item { id: "1".into() }],
70            next_cursor: Some("cursor_abc".into()),
71            has_more: true,
72        };
73        let v = serde_json::to_value(&resp).unwrap();
74        assert_eq!(v["data"][0]["id"], "1");
75        assert_eq!(v["next_cursor"], "cursor_abc");
76        assert_eq!(v["has_more"], true);
77    }
78
79    #[test]
80    fn serializes_without_next_cursor_when_none() {
81        let resp: CursorResponse<Item> = CursorResponse {
82            data: vec![],
83            next_cursor: None,
84            has_more: false,
85        };
86        let v = serde_json::to_value(&resp).unwrap();
87        assert!(v.get("next_cursor").is_none());
88        assert_eq!(v["has_more"], false);
89    }
90
91    #[test]
92    fn has_more_false_at_end() {
93        let resp = CursorResponse {
94            data: vec![Item { id: "final".into() }],
95            next_cursor: None,
96            has_more: false,
97        };
98        let v = serde_json::to_value(&resp).unwrap();
99        assert_eq!(v["has_more"], false);
100        assert!(v.get("next_cursor").is_none());
101    }
102
103    #[test]
104    fn has_more_true_with_cursor() {
105        let resp = CursorResponse {
106            data: vec![Item { id: "x".into() }],
107            next_cursor: Some("token".into()),
108            has_more: true,
109        };
110        let v = serde_json::to_value(&resp).unwrap();
111        assert_eq!(v["has_more"], true);
112        assert_eq!(v["next_cursor"], "token");
113    }
114
115    #[test]
116    fn empty_data_with_no_cursor() {
117        let resp: CursorResponse<Item> = CursorResponse {
118            data: vec![],
119            next_cursor: None,
120            has_more: false,
121        };
122        let v = serde_json::to_value(&resp).unwrap();
123        assert_eq!(v["data"], json!([]));
124        assert_eq!(v["has_more"], false);
125    }
126
127    #[test]
128    fn multiple_items() {
129        let resp = CursorResponse {
130            data: vec![
131                Item { id: "1".into() },
132                Item { id: "2".into() },
133                Item { id: "3".into() },
134            ],
135            next_cursor: Some("next".into()),
136            has_more: true,
137        };
138        let v = serde_json::to_value(&resp).unwrap();
139        assert_eq!(v["data"].as_array().unwrap().len(), 3);
140        assert_eq!(v["data"][1]["id"], "2");
141    }
142}