Skip to main content

tapes_client/
page.rs

1//! One pagination convention.
2//!
3//! # The convention
4//!
5//! Every paginated tapes listing answers with `items` and `next_cursor`, and
6//! takes the cursor back under the query name `cursor`. A page is the last one
7//! when `next_cursor` is absent, `null`, or empty — three spellings of the same
8//! fact, which is exactly why reading it belongs in one place. A client that
9//! treated `""` as a cursor would ask for a fourth page forever.
10//!
11//! # Why this is a floor and not a helper on one surface
12//!
13//! Paging was previously each consumer's own loop, and the loops differed: one
14//! checked `next_cursor` for null and refused to page at all, one read it as a
15//! string. Neither carried a stop condition for a server that repeats a cursor.
16//! Both surfaces page the same way, so the convention is written once here and
17//! [`walk`] is the only loop.
18
19use serde::Deserialize;
20use serde_json::Value;
21
22use crate::error::Result;
23use crate::transport::WireRequest;
24
25/// The query parameter a page cursor travels under.
26pub const CURSOR_PARAM: &str = "cursor";
27
28/// The query parameter a page size travels under.
29pub const LIMIT_PARAM: &str = "limit";
30
31/// One page of a listing.
32#[derive(Debug, Clone, Deserialize)]
33pub struct Page<T> {
34    /// This page's items.
35    #[serde(default = "Vec::new")]
36    pub items: Vec<T>,
37    /// The cursor for the next page, when there is one.
38    #[serde(default)]
39    pub next_cursor: Option<String>,
40}
41
42impl<T> Page<T> {
43    /// The cursor to ask for the next page with, or `None` at the end.
44    ///
45    /// Empty is end-of-listing, not a cursor: a server that renders "no more
46    /// pages" as `""` rather than `null` must not send a client round again.
47    #[must_use]
48    pub fn next(&self) -> Option<&str> {
49        self.next_cursor
50            .as_deref()
51            .filter(|cursor| !cursor.is_empty())
52    }
53}
54
55impl<T> Default for Page<T> {
56    fn default() -> Self {
57        Self {
58            items: Vec::new(),
59            next_cursor: None,
60        }
61    }
62}
63
64/// Whether a raw listing document says there are more pages.
65///
66/// For callers holding an undecoded document — the fidelity operations keep
67/// their responses as [`Value`] — so that "is this listing complete?" is read
68/// the same way whether or not the items were modelled.
69#[must_use]
70pub fn more_pages(document: &Value) -> bool {
71    document
72        .get("next_cursor")
73        .and_then(Value::as_str)
74        .is_some_and(|cursor| !cursor.is_empty())
75}
76
77/// Set a page cursor on a request, replacing any cursor already on it.
78pub fn set_cursor(request: &mut WireRequest<'_>, cursor: Option<&str>) {
79    request.query.retain(|(name, _)| name != CURSOR_PARAM);
80    if let Some(cursor) = cursor {
81        request
82            .query
83            .push((CURSOR_PARAM.to_owned(), cursor.to_owned()));
84    }
85}
86
87/// Follow `next_cursor` to the end of a listing, collecting every item.
88///
89/// `fetch` is called once per page with the cursor for that page — `None` for
90/// the first. The walk stops when a page reports no next cursor, and also if a
91/// server repeats a cursor it already served: that is a server bug, but an
92/// unbounded loop in a CLI reads as a hang, and a hang is the hardest failure
93/// to attribute.
94pub async fn walk<T, F, Fut>(mut fetch: F) -> Result<Vec<T>>
95where
96    F: FnMut(Option<String>) -> Fut,
97    Fut: Future<Output = Result<Page<T>>>,
98{
99    let mut items = Vec::new();
100    let mut cursor: Option<String> = None;
101    let mut seen: Vec<String> = Vec::new();
102
103    loop {
104        let mut page = fetch(cursor.clone()).await?;
105        items.append(&mut page.items);
106        match page.next() {
107            Some(next) if !seen.iter().any(|prior| prior == next) => {
108                seen.push(next.to_owned());
109                cursor = Some(next.to_owned());
110            }
111            _ => return Ok(items),
112        }
113    }
114}
115
116#[cfg(test)]
117#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
118mod tests {
119    use super::*;
120    use std::cell::RefCell;
121
122    /// Decode one canned page body.
123    fn parse(body: &str) -> Page<i32> {
124        serde_json::from_str(body).unwrap()
125    }
126
127    #[test]
128    fn an_empty_cursor_is_the_end_of_the_listing_not_a_fourth_page() {
129        // The three spellings of "no more pages", read one way.
130        for raw in [
131            r#"{"items":[]}"#,
132            r#"{"items":[],"next_cursor":null}"#,
133            r#"{"items":[],"next_cursor":""}"#,
134        ] {
135            let page: Page<Value> = serde_json::from_str(raw).unwrap();
136            assert_eq!(page.next(), None, "{raw}");
137        }
138        let page: Page<Value> = serde_json::from_str(r#"{"items":[],"next_cursor":"c1"}"#).unwrap();
139        assert_eq!(page.next(), Some("c1"));
140    }
141
142    #[test]
143    fn a_raw_document_reads_the_same_way_as_a_decoded_page() {
144        // The fidelity operations never decode their items; "is there more?"
145        // must not depend on whether they did.
146        assert!(!more_pages(&serde_json::json!({"items": []})));
147        assert!(!more_pages(
148            &serde_json::json!({"items": [], "next_cursor": null})
149        ));
150        assert!(!more_pages(
151            &serde_json::json!({"items": [], "next_cursor": ""})
152        ));
153        assert!(more_pages(
154            &serde_json::json!({"items": [], "next_cursor": "c1"})
155        ));
156    }
157
158    #[tokio::test]
159    async fn a_walk_follows_cursors_to_the_end() {
160        let pages = [
161            (None, r#"{"items":[1,2],"next_cursor":"c1"}"#),
162            (Some("c1"), r#"{"items":[3],"next_cursor":"c2"}"#),
163            (Some("c2"), r#"{"items":[4],"next_cursor":null}"#),
164        ];
165        let asked: RefCell<Vec<Option<String>>> = RefCell::new(Vec::new());
166
167        let fetch = |cursor: Option<String>| {
168            asked.borrow_mut().push(cursor.clone());
169            let page = pages
170                .iter()
171                .find(|(want, _)| want.map(ToOwned::to_owned) == cursor)
172                .map(|(_, body)| parse(body))
173                .unwrap();
174            std::future::ready(Ok(page))
175        };
176        let items: Vec<i32> = walk(fetch).await.unwrap();
177
178        assert_eq!(items, vec![1, 2, 3, 4]);
179        assert_eq!(
180            asked.into_inner(),
181            vec![None, Some("c1".to_owned()), Some("c2".to_owned())],
182        );
183    }
184
185    #[tokio::test]
186    async fn a_repeated_cursor_stops_the_walk_rather_than_hanging() {
187        // A server that keeps handing back the same cursor is a server bug,
188        // but an unbounded loop in a CLI reads as a hang — the hardest kind of
189        // failure to attribute to its cause.
190        let calls = RefCell::new(0_usize);
191        let fetch = |_| {
192            *calls.borrow_mut() += 1;
193            std::future::ready(Ok(parse(r#"{"items":[1],"next_cursor":"stuck"}"#)))
194        };
195        let items: Vec<i32> = walk(fetch).await.unwrap();
196
197        assert_eq!(*calls.borrow(), 2, "the repeat must end the walk");
198        assert_eq!(items, vec![1, 1]);
199    }
200
201    #[test]
202    fn setting_a_cursor_replaces_rather_than_appends() {
203        // Two `cursor` parameters on one URL is a request whose meaning is the
204        // server's to guess.
205        let mut request = WireRequest {
206            method: "GET",
207            path: "/v1/sessions",
208            query: vec![
209                ("limit".to_owned(), "25".to_owned()),
210                (CURSOR_PARAM.to_owned(), "old".to_owned()),
211            ],
212            ..Default::default()
213        };
214        set_cursor(&mut request, Some("new"));
215        assert_eq!(
216            request.query,
217            vec![
218                ("limit".to_owned(), "25".to_owned()),
219                (CURSOR_PARAM.to_owned(), "new".to_owned()),
220            ],
221        );
222        set_cursor(&mut request, None);
223        assert_eq!(request.query, vec![("limit".to_owned(), "25".to_owned())]);
224    }
225}