Skip to main content

ocpi_kit/transport/
pagination.rs

1//! Pagination: the query parameters, the three response headers, and the crawl.
2//!
3//! Spec: 2.3.0 §transport_and_format_pagination
4
5use core::fmt;
6
7use http::HeaderMap;
8use serde::{Deserialize, Serialize};
9
10use crate::types::{DateTime, Url};
11
12use super::headers::{LINK, X_LIMIT, X_TOTAL_COUNT, header_str, header_u64, link_next, parse_link_next};
13
14/// The query parameters of a paginated GET.
15///
16/// > *`date_from`: Only return objects that have `last_updated` after or equal to this Date/Time
17/// > (inclusive). `date_to`: … up to this Date/Time, but not including (exclusive).*
18///
19/// The half-open interval is the point: *"when sequential requests to the same end-point are
20/// done, the next interval will have no overlap and the `date_from` of the next interval is
21/// simply the `date_to` of the previous interval."* [`PageQuery::next_interval`] does that.
22///
23/// Spec: 2.3.0 §transport_and_format_paginated_request
24#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26pub struct PageQuery {
27    /// Only objects with `last_updated` at or after this time. Inclusive.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub date_from: Option<DateTime>,
30    /// Only objects with `last_updated` before this time. Exclusive.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub date_to: Option<DateTime>,
33    /// The offset of the first object returned. Absent means 0.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub offset: Option<u64>,
36    /// The maximum number of objects to return. The server may return fewer.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub limit: Option<u64>,
39}
40
41impl PageQuery {
42    /// An empty query: everything, from the beginning, at the server's own page size.
43    #[must_use]
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Everything updated at or after `from`.
49    #[must_use]
50    pub fn since(from: DateTime) -> Self {
51        Self { date_from: Some(from), ..Self::default() }
52    }
53
54    /// Everything updated in `[from, to)`.
55    #[must_use]
56    pub fn between(from: DateTime, to: DateTime) -> Self {
57        Self { date_from: Some(from), date_to: Some(to), ..Self::default() }
58    }
59
60    /// This query with an explicit page size.
61    #[must_use]
62    pub fn with_limit(mut self, limit: u64) -> Self {
63        self.limit = Some(limit);
64        self
65    }
66
67    /// This query with an explicit offset.
68    #[must_use]
69    pub fn with_offset(mut self, offset: u64) -> Self {
70        self.offset = Some(offset);
71        self
72    }
73
74    /// The offset, applying the spec's default of 0.
75    #[must_use]
76    pub fn offset_or_default(&self) -> u64 {
77        self.offset.unwrap_or(0)
78    }
79
80    /// The query for the next time interval, starting where this one ended.
81    ///
82    /// Returns `None` when this query has no `date_to` to continue from.
83    #[must_use]
84    pub fn next_interval(&self, new_end: DateTime) -> Option<Self> {
85        let start = self.date_to?;
86        Some(Self { date_from: Some(start), date_to: Some(new_end), offset: None, limit: self.limit })
87    }
88
89    /// The query string, with the parameters in the order the spec's examples use.
90    ///
91    /// Returns an empty string when nothing is set, so it can be appended unconditionally.
92    #[must_use]
93    pub fn to_query_string(&self) -> String {
94        let mut parts: Vec<String> = Vec::new();
95        if let Some(offset) = self.offset {
96            parts.push(format!("offset={offset}"));
97        }
98        if let Some(limit) = self.limit {
99            parts.push(format!("limit={limit}"));
100        }
101        if let Some(from) = self.date_from {
102            parts.push(format!("date_from={}", encode(&from.to_string())));
103        }
104        if let Some(to) = self.date_to {
105            parts.push(format!("date_to={}", encode(&to.to_string())));
106        }
107        parts.join("&")
108    }
109
110    /// Applies this query to a base URL.
111    #[must_use]
112    pub fn apply_to(&self, base: &Url) -> Url {
113        base.with_query(&self.to_query_string())
114    }
115
116    /// Clamps `limit` to `max`, as a peer's advertised `X-Limit` requires.
117    #[must_use]
118    pub fn clamped_to(mut self, max: u64) -> Self {
119        self.limit = Some(self.limit.map_or(max, |l| l.min(max)));
120        self
121    }
122}
123
124/// Percent-encodes the characters a `DateTime` contributes that are unsafe in a query value.
125fn encode(value: &str) -> String {
126    let mut out = String::with_capacity(value.len());
127    for ch in value.chars() {
128        match ch {
129            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(ch),
130            _ => {
131                use core::fmt::Write as _;
132                let mut buf = [0u8; 4];
133                for byte in ch.encode_utf8(&mut buf).as_bytes() {
134                    let _ = write!(out, "%{byte:02X}");
135                }
136            }
137        }
138    }
139    out
140}
141
142/// The three headers a paginated response carries.
143///
144/// Spec: 2.3.0 §transport_and_format_paginated_response
145#[derive(Clone, Debug, PartialEq, Eq, Default)]
146pub struct PageMeta {
147    /// The URL of the next page, present only when this is not the last page.
148    pub next: Option<Url>,
149    /// The total number of objects matching the query, excluding `limit` and `offset`.
150    pub total_count: Option<u64>,
151    /// The maximum number of objects the server will return.
152    ///
153    /// > *Note that this is an upper limit. If there are not enough remaining objects to return,
154    /// > fewer objects than this upper limit number will be returned, X-Limit SHALL then still
155    /// > show the upper limit, not the number of objects returned.*
156    pub limit: Option<u64>,
157}
158
159impl PageMeta {
160    /// Reads the pagination headers from a response.
161    #[must_use]
162    pub fn from_headers(headers: &HeaderMap) -> Self {
163        Self {
164            next: header_str(headers, &LINK).and_then(parse_link_next),
165            total_count: header_u64(headers, &X_TOTAL_COUNT),
166            limit: header_u64(headers, &X_LIMIT),
167        }
168    }
169
170    /// Writes the pagination headers into a response.
171    pub fn write_to(&self, headers: &mut HeaderMap) {
172        use http::HeaderValue;
173        if let Some(next) = &self.next
174            && let Ok(v) = HeaderValue::from_str(&link_next(next))
175        {
176            headers.insert(LINK, v);
177        }
178        if let Some(total) = self.total_count {
179            headers.insert(X_TOTAL_COUNT, HeaderValue::from(total));
180        }
181        if let Some(limit) = self.limit {
182            headers.insert(X_LIMIT, HeaderValue::from(limit));
183        }
184    }
185
186    /// Whether there is another page to fetch.
187    #[must_use]
188    pub const fn has_next(&self) -> bool {
189        self.next.is_some()
190    }
191}
192
193/// One page of a list endpoint: the objects and the metadata the headers carry.
194#[derive(Clone, Debug, PartialEq)]
195pub struct Page<T> {
196    /// The objects on this page.
197    pub items: Vec<T>,
198    /// The pagination headers that came with them.
199    pub meta: PageMeta,
200}
201
202impl<T> Page<T> {
203    /// A page with no next link, for a server returning everything at once.
204    #[must_use]
205    pub fn single(items: Vec<T>) -> Self {
206        let total = items.len() as u64;
207        Self { items, meta: PageMeta { next: None, total_count: Some(total), limit: None } }
208    }
209
210    /// Whether there is another page to fetch.
211    #[must_use]
212    pub const fn has_next(&self) -> bool {
213        self.meta.has_next()
214    }
215}
216
217/// What a client should do after a page whose `X-Total-Count` moved.
218///
219/// > *NOTE: Some query parameters can cause concurrency problems. … While crawling over the pages
220/// > one of these objects is updated. The client detects this: `X-Total-Count` will be lower in
221/// > the next request. It is advised to redo the previous GET with the `offset` lowered by 1 (if
222/// > the `offset` was not 0) and after that continue crawling the 'next' page links.*
223///
224/// Spec: 2.3.0 §transport_and_format_paginated_response
225#[derive(Clone, Debug, PartialEq, Eq)]
226pub enum CrawlAdjustment {
227    /// The count is stable or grew; follow the next link as normal.
228    ///
229    /// > *the client does not have to retry any requests when this happens because only the last
230    /// > page will be different.*
231    Continue,
232    /// The count shrank; re-fetch at this offset before continuing.
233    RefetchAt(u64),
234}
235
236/// Decides how to continue a crawl after seeing a new total count.
237///
238/// `previous_offset` is the offset of the page just fetched.
239#[must_use]
240pub fn crawl_adjustment(
241    previous_total: Option<u64>,
242    new_total: Option<u64>,
243    previous_offset: u64,
244) -> CrawlAdjustment {
245    match (previous_total, new_total) {
246        (Some(before), Some(now)) if now < before && previous_offset > 0 => {
247            CrawlAdjustment::RefetchAt(previous_offset - 1)
248        }
249        _ => CrawlAdjustment::Continue,
250    }
251}
252
253impl fmt::Display for PageQuery {
254    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
255        let q = self.to_query_string();
256        if q.is_empty() { f.write_str("(no filters)") } else { f.write_str(&q) }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use http::HeaderValue;
264
265    fn dt(s: &str) -> DateTime {
266        s.parse().unwrap()
267    }
268
269    #[test]
270    fn query_strings_match_the_spec_examples() {
271        let q = PageQuery::between(dt("2016-01-01T00:00:00Z"), dt("2016-12-31T23:59:59Z"));
272        let base = Url::new("https://www.server.com/ocpi/cpo/2.3.0/cdrs/").unwrap();
273        assert_eq!(
274            q.apply_to(&base).as_str(),
275            "https://www.server.com/ocpi/cpo/2.3.0/cdrs/\
276             ?date_from=2016-01-01T00%3A00%3A00Z&date_to=2016-12-31T23%3A59%3A59Z"
277        );
278        assert_eq!(PageQuery::new().apply_to(&base), base, "an empty query changes nothing");
279    }
280
281    #[test]
282    fn the_next_interval_starts_where_the_last_one_ended() {
283        let first = PageQuery::between(dt("2016-01-01T00:00:00Z"), dt("2016-02-01T00:00:00Z"));
284        let second = first.next_interval(dt("2016-03-01T00:00:00Z")).unwrap();
285        assert_eq!(second.date_from, first.date_to, "half-open intervals do not overlap");
286        assert_eq!(second.date_to, Some(dt("2016-03-01T00:00:00Z")));
287        assert!(
288            PageQuery::since(dt("2016-01-01T00:00:00Z")).next_interval(dt("2016-02-01T00:00:00Z")).is_none()
289        );
290    }
291
292    #[test]
293    fn limits_are_clamped_to_what_the_peer_advertises() {
294        assert_eq!(PageQuery::new().with_limit(2000).clamped_to(100).limit, Some(100));
295        assert_eq!(PageQuery::new().with_limit(50).clamped_to(100).limit, Some(50));
296        assert_eq!(PageQuery::new().clamped_to(100).limit, Some(100));
297    }
298
299    #[test]
300    fn page_meta_round_trips_through_headers() {
301        let meta = PageMeta {
302            next: Some(Url::new("https://e.com/cdrs/?offset=150&limit=50").unwrap()),
303            total_count: Some(1234),
304            limit: Some(50),
305        };
306        let mut headers = HeaderMap::new();
307        meta.write_to(&mut headers);
308        assert_eq!(
309            headers.get(LINK).unwrap(),
310            HeaderValue::from_static(r#"<https://e.com/cdrs/?offset=150&limit=50>; rel="next""#)
311        );
312        assert_eq!(PageMeta::from_headers(&headers), meta);
313    }
314
315    #[test]
316    fn a_shrinking_total_count_rewinds_the_crawl_by_one() {
317        assert_eq!(crawl_adjustment(Some(1000), Some(999), 150), CrawlAdjustment::RefetchAt(149));
318        // Growing is fine: only the last page differs.
319        assert_eq!(crawl_adjustment(Some(1000), Some(1001), 150), CrawlAdjustment::Continue);
320        // "if the offset was not 0"
321        assert_eq!(crawl_adjustment(Some(1000), Some(999), 0), CrawlAdjustment::Continue);
322        assert_eq!(crawl_adjustment(None, Some(999), 150), CrawlAdjustment::Continue);
323    }
324
325    #[test]
326    fn a_last_page_has_no_next_link() {
327        let page = Page::single(vec![1, 2, 3]);
328        assert!(!page.has_next());
329        assert_eq!(page.meta.total_count, Some(3));
330    }
331}