Skip to main content

hey_sdk/
pagination.rs

1use std::ops::Deref;
2
3use crate::http::Method;
4use serde_json::Value;
5use url::Url;
6
7use crate::client::{Client, Response};
8use crate::error::Error;
9use crate::observability::OperationInfo;
10use crate::operation::Operation;
11use crate::route::Route;
12use crate::security::is_same_origin;
13
14/// One page of a paginated read, with the cursor HEY handed out for the next one.
15///
16/// The page derefs to its value, so `page.postings` reads the same as it would on the
17/// response itself.
18#[derive(Debug, Clone)]
19pub struct Page<T> {
20    value: T,
21    next_url: Option<Url>,
22    next_cursor: Option<String>,
23    total_count: Option<u64>,
24    /// What the read that produced this page announced itself as, so the reads that walk
25    /// on from it can say the same.
26    info: OperationInfo,
27    /// The route the first page came from, so every page after it is resent under the
28    /// same policy.
29    route: Option<&'static Route>,
30}
31
32impl<T> Page<T> {
33    pub(crate) fn new(
34        value: T,
35        response: &Response,
36        info: OperationInfo,
37        route: Option<&'static Route>,
38    ) -> Page<T> {
39        let next_url = response
40            .headers
41            .get("link")
42            .and_then(|value| value.to_str().ok())
43            .and_then(next_link)
44            .and_then(|target| response.url.join(&target).ok());
45        let next_cursor = next_url.as_ref().and_then(|url| {
46            url.query_pairs()
47                .find(|(name, _)| name == "page")
48                .map(|(_, value)| value.into_owned())
49        });
50        let total_count = response
51            .headers
52            .get("x-total-count")
53            .and_then(|value| value.to_str().ok())
54            .and_then(|value| value.trim().parse().ok());
55        Page {
56            value,
57            next_url,
58            next_cursor,
59            total_count,
60            info,
61            route,
62        }
63    }
64
65    pub(crate) fn info(&self) -> &OperationInfo {
66        &self.info
67    }
68
69    pub(crate) fn route(&self) -> Option<&'static Route> {
70        self.route
71    }
72
73    /// The page's value, giving up the cursor.
74    pub fn into_inner(self) -> T {
75        self.value
76    }
77
78    /// The page's value: the response as HEY answered it.
79    pub fn value(&self) -> &T {
80        &self.value
81    }
82
83    /// The opaque cursor for the page after this one, to pass as `page` on the same read.
84    pub fn next_page(&self) -> Option<&str> {
85        self.next_cursor.as_deref()
86    }
87
88    /// The URL of the page after this one, as HEY's `Link` header named it.
89    pub fn next_url(&self) -> Option<&Url> {
90        self.next_url.as_ref()
91    }
92
93    /// Whether HEY named a page after this one.
94    pub fn has_next(&self) -> bool {
95        self.next_url.is_some()
96    }
97
98    /// The `X-Total-Count` header, when the read carried one.
99    pub fn total_count(&self) -> Option<u64> {
100        self.total_count
101    }
102
103    /// The same page over another value — the records pulled out of the response, say —
104    /// with the cursor kept.
105    pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Page<U> {
106        Page {
107            value: f(self.value),
108            next_url: self.next_url,
109            next_cursor: self.next_cursor,
110            total_count: self.total_count,
111            info: self.info,
112            route: self.route,
113        }
114    }
115}
116
117impl<T> Deref for Page<T> {
118    type Target = T;
119
120    fn deref(&self) -> &T {
121        &self.value
122    }
123}
124
125impl Client {
126    /// Reads a paginated path to its end and hands back the items of every page as one
127    /// list. Each page has to decode as a JSON array. Use this for the paths the model
128    /// does not cover; a modelled read walks with [`Client::each_page`], which keeps the
129    /// records typed. A walk that reaches the client's page limit with pages still to
130    /// read is an error, [`Error::pagination_capped`], rather than a shorter list that
131    /// looks complete.
132    pub async fn get_all(&self, path: &str) -> Result<Vec<Value>, Error> {
133        self.get_all_with_limit(path, 0).await
134    }
135
136    /// Reads a paginated path until `limit` items are in hand, or to its end when `limit`
137    /// is zero. The last page is trimmed to land on exactly `limit`.
138    pub async fn get_all_with_limit(&self, path: &str, limit: usize) -> Result<Vec<Value>, Error> {
139        self.within_limit(Box::pin(async move {
140            let mut operation = self.raw(Method::GET, path)?;
141            let started_at = self.url_for(&operation)?;
142            let mut collected: Vec<Value> = Vec::new();
143            let mut pages = 0;
144
145            loop {
146                let response = self.execute(operation).await?;
147                collected.extend(response.json::<Vec<Value>>()?);
148                pages += 1;
149
150                if limit > 0 && collected.len() >= limit {
151                    collected.truncate(limit);
152                    break;
153                }
154                match next_page_url(&response, &started_at)? {
155                    Some(next) if pages < self.max_pages() => {
156                        operation = Operation::at(Method::GET, next);
157                    }
158                    Some(_) => return Err(Error::pagination_capped(self.max_pages())),
159                    None => break,
160                }
161            }
162            Ok(collected)
163        }))
164        .await
165    }
166
167    /// Reads the pages after one already in hand, and hands back their items. Say how many
168    /// the first page held as `first_page_count`, so a `limit` counts the whole walk;
169    /// `limit` of zero reads to the end.
170    ///
171    /// A [`Response`] names no route, so the pages are read on the client's own retry
172    /// settings, as [`Client::get_all`] reads them. A modelled read walks on with
173    /// [`Client::next_page`] or [`Client::each_page`], which keep its policy.
174    pub async fn follow_pagination(
175        &self,
176        first: &Response,
177        first_page_count: usize,
178        limit: usize,
179    ) -> Result<Vec<Value>, Error> {
180        self.within_limit(Box::pin(async move {
181            if limit > 0 && first_page_count >= limit {
182                return Ok(Vec::new());
183            }
184
185            let started_at = first.url.clone();
186            let mut next = next_page_url(first, &started_at)?;
187            let mut collected: Vec<Value> = Vec::new();
188            let mut count = first_page_count;
189            let mut pages = 1;
190
191            while let Some(url) = next {
192                if pages >= self.max_pages() {
193                    return Err(Error::pagination_capped(self.max_pages()));
194                }
195                let response = self.execute(Operation::at(Method::GET, url)).await?;
196                let items: Vec<Value> = response.json()?;
197                count += items.len();
198                collected.extend(items);
199                pages += 1;
200
201                if limit > 0 && count >= limit {
202                    collected.truncate(collected.len().saturating_sub(count - limit));
203                    break;
204                }
205                next = next_page_url(&response, &started_at)?;
206            }
207            Ok(collected)
208        }))
209        .await
210    }
211}
212
213/// The page after this one, as the `Link` header named it, resolved against the answer it
214/// came in. A target off the origin the walk started on is refused rather than followed:
215/// the header is the server's to write, and following it would carry the credentials
216/// somewhere they were never meant to go.
217fn next_page_url(response: &Response, started_at: &Url) -> Result<Option<Url>, Error> {
218    match response.header("link").and_then(next_link) {
219        None => Ok(None),
220        Some(target) => {
221            let next = response.url.join(&target)?;
222            if is_same_origin(&next, started_at) {
223                Ok(Some(next))
224            } else {
225                Err(Error::usage(format!(
226                    "pagination Link header points to a different origin: {next}"
227                )))
228            }
229        }
230    }
231}
232
233/// The target of the `rel="next"` link in an RFC 8288 `Link` header. Targets are read
234/// between angle brackets, so commas inside a URL do not split it, and `rel` is a
235/// space-separated set matched case-insensitively.
236pub fn next_link(header: &str) -> Option<String> {
237    let mut remaining = header;
238    while let Some(start) = remaining.find('<') {
239        let after_start = &remaining[start + 1..];
240        let end = after_start.find('>')?;
241        let target = &after_start[..end];
242        let rest = &after_start[end + 1..];
243        let params_end = rest.find('<').unwrap_or(rest.len());
244        if link_is_next(&rest[..params_end]) {
245            return Some(target.to_string());
246        }
247        remaining = &rest[params_end..];
248    }
249    None
250}
251
252fn link_is_next(params: &str) -> bool {
253    params.split(';').any(|param| {
254        let mut parts = param.splitn(2, '=');
255        let name = parts.next().unwrap_or_default().trim();
256        let value = parts.next().unwrap_or_default().trim().trim_matches('"');
257        name.eq_ignore_ascii_case("rel")
258            && value
259                .split_whitespace()
260                .any(|rel| rel.eq_ignore_ascii_case("next"))
261    })
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn finds_the_next_link_among_others() {
270        let header = r#"<https://app.hey.com/imbox.json?page=a,b>; rel="prev", <https://app.hey.com/imbox.json?page=c>; rel="next""#;
271        assert_eq!(
272            next_link(header).as_deref(),
273            Some("https://app.hey.com/imbox.json?page=c")
274        );
275    }
276
277    #[test]
278    fn matches_rel_sets_and_case() {
279        assert_eq!(
280            next_link(r#"</x?page=2>; REL="prev next""#).as_deref(),
281            Some("/x?page=2")
282        );
283        assert_eq!(next_link(r#"</x?page=2>; rel="last""#), None);
284        assert_eq!(next_link(""), None);
285    }
286}