Skip to main content

forge_core/
pagination.rs

1//! Cursor-based pagination types for the wire format.
2//!
3//! These types establish the pagination contract before GA so that adding
4//! query helpers later is purely additive.
5
6use serde::{Deserialize, Serialize};
7
8/// Opaque cursor for keyset pagination.
9///
10/// Clients receive and return this as a string. The internal format
11/// is an implementation detail (base64-encoded JSON).
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct Cursor(String);
14
15impl Cursor {
16    pub fn new(value: impl Into<String>) -> Self {
17        Self(value.into())
18    }
19
20    pub fn as_str(&self) -> &str {
21        &self.0
22    }
23}
24
25/// A page of results with cursor-based navigation.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[non_exhaustive]
28pub struct Page<T> {
29    pub items: Vec<T>,
30    pub page_info: PageInfo,
31}
32
33impl<T> Page<T> {
34    pub fn new(items: Vec<T>, page_info: PageInfo) -> Self {
35        Self { items, page_info }
36    }
37}
38
39/// Pagination metadata returned alongside a page of results.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[non_exhaustive]
42pub struct PageInfo {
43    pub has_next_page: bool,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub end_cursor: Option<Cursor>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub total_count: Option<i64>,
48}
49
50impl PageInfo {
51    pub fn last_page() -> Self {
52        Self {
53            has_next_page: false,
54            end_cursor: None,
55            total_count: None,
56        }
57    }
58}
59
60#[cfg(test)]
61#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn cursor_round_trip() {
67        let cursor = Cursor::new("eyJpZCI6NDJ9");
68        let json = serde_json::to_string(&cursor).unwrap();
69        let back: Cursor = serde_json::from_str(&json).unwrap();
70        assert_eq!(cursor, back);
71    }
72
73    #[test]
74    fn page_serialization_shape() {
75        let page = Page::new(
76            vec!["alice", "bob"],
77            PageInfo {
78                has_next_page: true,
79                end_cursor: Some(Cursor::new("abc")),
80                total_count: Some(10),
81            },
82        );
83        let json: serde_json::Value = serde_json::to_value(&page).unwrap();
84        assert_eq!(json["items"], serde_json::json!(["alice", "bob"]));
85        assert_eq!(json["page_info"]["has_next_page"], true);
86        assert_eq!(json["page_info"]["end_cursor"], "abc");
87        assert_eq!(json["page_info"]["total_count"], 10);
88    }
89
90    #[test]
91    fn page_info_skips_none_fields() {
92        let info = PageInfo::last_page();
93        let json: serde_json::Value = serde_json::to_value(&info).unwrap();
94        assert!(!json.as_object().unwrap().contains_key("end_cursor"));
95        assert!(!json.as_object().unwrap().contains_key("total_count"));
96    }
97}