1use 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26pub struct PageQuery {
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub date_from: Option<DateTime>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub date_to: Option<DateTime>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub offset: Option<u64>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub limit: Option<u64>,
39}
40
41impl PageQuery {
42 #[must_use]
44 pub fn new() -> Self {
45 Self::default()
46 }
47
48 #[must_use]
50 pub fn since(from: DateTime) -> Self {
51 Self { date_from: Some(from), ..Self::default() }
52 }
53
54 #[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 #[must_use]
62 pub fn with_limit(mut self, limit: u64) -> Self {
63 self.limit = Some(limit);
64 self
65 }
66
67 #[must_use]
69 pub fn with_offset(mut self, offset: u64) -> Self {
70 self.offset = Some(offset);
71 self
72 }
73
74 #[must_use]
76 pub fn offset_or_default(&self) -> u64 {
77 self.offset.unwrap_or(0)
78 }
79
80 #[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 #[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 #[must_use]
112 pub fn apply_to(&self, base: &Url) -> Url {
113 base.with_query(&self.to_query_string())
114 }
115
116 #[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
124fn 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#[derive(Clone, Debug, PartialEq, Eq, Default)]
146pub struct PageMeta {
147 pub next: Option<Url>,
149 pub total_count: Option<u64>,
151 pub limit: Option<u64>,
157}
158
159impl PageMeta {
160 #[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 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 #[must_use]
188 pub const fn has_next(&self) -> bool {
189 self.next.is_some()
190 }
191}
192
193#[derive(Clone, Debug, PartialEq)]
195pub struct Page<T> {
196 pub items: Vec<T>,
198 pub meta: PageMeta,
200}
201
202impl<T> Page<T> {
203 #[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 #[must_use]
212 pub const fn has_next(&self) -> bool {
213 self.meta.has_next()
214 }
215}
216
217#[derive(Clone, Debug, PartialEq, Eq)]
226pub enum CrawlAdjustment {
227 Continue,
232 RefetchAt(u64),
234}
235
236#[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 assert_eq!(crawl_adjustment(Some(1000), Some(1001), 150), CrawlAdjustment::Continue);
320 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}