Skip to main content

atrg_core/
pagination.rs

1//! Cursor-based pagination helpers.
2//!
3//! All list endpoints in atrg use cursor-based pagination with the format:
4//! `?cursor=<opaque>&limit=<n>` and return `{"items": [...], "cursor": "<next>"}`.
5
6use base64::Engine;
7use serde::Deserialize;
8
9/// Maximum items per page.
10pub const MAX_LIMIT: u32 = 100;
11/// Default items per page.
12pub const DEFAULT_LIMIT: u32 = 50;
13
14/// Pagination query parameters accepted by list endpoints.
15#[derive(Debug, Deserialize)]
16pub struct PaginationParams {
17    /// Opaque cursor from a previous response.
18    pub cursor: Option<String>,
19    /// Number of items to return (max 100, default 50).
20    pub limit: Option<u32>,
21}
22
23impl PaginationParams {
24    /// Get the effective limit, clamped to [1, MAX_LIMIT].
25    pub fn effective_limit(&self) -> u32 {
26        self.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
27    }
28}
29
30/// Encode a cursor from a timestamp and record key.
31pub fn encode_cursor(timestamp_ms: i64, rkey: &str) -> String {
32    let raw = format!("{}:{}", timestamp_ms, rkey);
33    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
34}
35
36/// Decode a cursor into (timestamp_ms, rkey).
37pub fn decode_cursor(cursor: &str) -> Result<(i64, String), crate::error::AtrgError> {
38    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
39        .decode(cursor)
40        .map_err(|_| crate::error::AtrgError::BadRequest("invalid cursor".to_string()))?;
41
42    let s = String::from_utf8(bytes)
43        .map_err(|_| crate::error::AtrgError::BadRequest("invalid cursor encoding".to_string()))?;
44
45    let (ts_str, rkey) = s
46        .split_once(':')
47        .ok_or_else(|| crate::error::AtrgError::BadRequest("malformed cursor".to_string()))?;
48
49    let ts: i64 = ts_str
50        .parse()
51        .map_err(|_| crate::error::AtrgError::BadRequest("invalid cursor timestamp".to_string()))?;
52
53    Ok((ts, rkey.to_string()))
54}
55
56/// Build a paginated JSON response.
57pub fn paginated_response<T: serde::Serialize>(
58    items: Vec<T>,
59    cursor: Option<String>,
60) -> serde_json::Value {
61    serde_json::json!({
62        "items": items,
63        "cursor": cursor,
64    })
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    #[test]
72    fn encode_decode_roundtrip() {
73        let encoded = encode_cursor(1234567890, "abc123");
74        let (ts, rkey) = decode_cursor(&encoded).unwrap();
75        assert_eq!(ts, 1234567890);
76        assert_eq!(rkey, "abc123");
77    }
78
79    #[test]
80    fn decode_invalid_base64() {
81        let result = decode_cursor("!!!not-base64!!!");
82        assert!(result.is_err());
83    }
84
85    #[test]
86    fn decode_missing_separator() {
87        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"noseparator");
88        let result = decode_cursor(&encoded);
89        assert!(result.is_err());
90    }
91
92    #[test]
93    fn decode_non_numeric_timestamp() {
94        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"abc:key");
95        let result = decode_cursor(&encoded);
96        assert!(result.is_err());
97    }
98
99    #[test]
100    fn effective_limit_clamps() {
101        let p = PaginationParams {
102            cursor: None,
103            limit: Some(200),
104        };
105        assert_eq!(p.effective_limit(), 100);
106
107        let p = PaginationParams {
108            cursor: None,
109            limit: Some(0),
110        };
111        assert_eq!(p.effective_limit(), 1);
112
113        let p = PaginationParams {
114            cursor: None,
115            limit: None,
116        };
117        assert_eq!(p.effective_limit(), 50);
118    }
119
120    #[test]
121    fn paginated_response_shape() {
122        let resp = paginated_response(vec!["a", "b"], Some("cursor123".into()));
123        assert!(resp["items"].is_array());
124        assert_eq!(resp["cursor"], "cursor123");
125
126        let resp2 = paginated_response(vec!["x"], None::<String>);
127        assert!(resp2["cursor"].is_null());
128    }
129}