use std::collections::VecDeque;
use http::Method;
use serde::de::DeserializeOwned;
use crate::ModuleId;
use crate::convert::wire::ObjectKind;
use crate::transport::{CrawlAdjustment, OcpiError, OcpiRequest, PageMeta, RoutingHeaders, crawl_adjustment};
use crate::types::Url;
use super::http::Transport;
use super::peer::Peer;
pub struct PageStream<'a, T> {
transport: &'a Transport,
peer: &'a Peer,
module: ModuleId,
routing: RoutingHeaders,
next: Option<Url>,
buffer: VecDeque<T>,
last_total: Option<u64>,
last_offset: u64,
pages: usize,
seen: usize,
corrections: usize,
max_pages: usize,
bridge: Option<ObjectKind>,
}
pub const DEFAULT_MAX_PAGES: usize = 10_000;
impl<'a, T: DeserializeOwned> PageStream<'a, T> {
#[must_use]
pub fn new(
transport: &'a Transport,
peer: &'a Peer,
module: ModuleId,
routing: RoutingHeaders,
first: Url,
) -> Self {
Self {
transport,
peer,
module,
routing,
next: Some(first),
buffer: VecDeque::new(),
last_total: None,
last_offset: 0,
pages: 0,
seen: 0,
corrections: 0,
max_pages: DEFAULT_MAX_PAGES,
bridge: None,
}
}
#[must_use]
pub const fn bridging(mut self, kind: ObjectKind) -> Self {
self.bridge = Some(kind);
self
}
#[must_use]
pub const fn with_max_pages(mut self, max_pages: usize) -> Self {
self.max_pages = max_pages;
self
}
pub async fn next(&mut self) -> Result<Option<T>, OcpiError> {
loop {
if let Some(item) = self.buffer.pop_front() {
self.seen += 1;
return Ok(Some(item));
}
let Some(url) = self.next.take() else { return Ok(None) };
if self.pages >= self.max_pages {
return Err(OcpiError::Transport(format!(
"pagination did not terminate after {} pages; the peer keeps returning a \
`Link` header",
self.max_pages
)));
}
self.fetch(url).await?;
}
}
pub async fn collect_all(mut self) -> Result<Vec<T>, OcpiError> {
let mut out = Vec::new();
while let Some(item) = self.next().await? {
out.push(item);
}
Ok(out)
}
#[must_use]
pub const fn pages_fetched(&self) -> usize {
self.pages
}
#[must_use]
pub const fn seen(&self) -> usize {
self.seen
}
#[must_use]
pub const fn corrections(&self) -> usize {
self.corrections
}
#[must_use]
pub const fn total_count(&self) -> Option<u64> {
self.last_total
}
async fn fetch(&mut self, url: Url) -> Result<(), OcpiError> {
let offset = offset_of(&url);
let request =
OcpiRequest::new(Method::GET, url.clone(), self.module.clone()).routed(self.routing.clone());
let page = self.page(&request).await?;
self.pages += 1;
match crawl_adjustment(self.last_total, page.meta.total_count, offset) {
CrawlAdjustment::RefetchAt(new_offset) => {
self.corrections += 1;
tracing::debug!(
previous_total = self.last_total,
new_total = page.meta.total_count,
new_offset,
"X-Total-Count shrank mid-crawl; rewinding one object as the spec advises",
);
self.last_total = page.meta.total_count;
self.next = Some(with_offset(&url, new_offset));
Ok(())
}
CrawlAdjustment::Continue => {
self.last_total = page.meta.total_count;
self.last_offset = offset;
self.buffer.extend(page.items);
self.next = next_url(&page.meta);
Ok(())
}
}
}
async fn page(&self, request: &OcpiRequest) -> Result<crate::transport::Page<T>, OcpiError> {
let theirs = self.peer.version();
let Some(kind) = self.bridge.filter(|_| *theirs != crate::CANONICAL_VERSION) else {
return self.transport.send_page::<T>(request, self.peer.token(), self.peer.quirks()).await;
};
let page = self
.transport
.send_page::<serde_json::Value>(request, self.peer.token(), self.peer.quirks())
.await?;
let converted = kind
.bridge(theirs, &crate::CANONICAL_VERSION, serde_json::Value::Array(page.items))
.map_err(|e| OcpiError::Decode { path: "/".to_owned(), message: e.to_string() })?;
let items = serde_path_to_error::deserialize(converted.value).map_err(|e| OcpiError::Decode {
path: e.path().to_string(),
message: e.into_inner().to_string(),
})?;
Ok(crate::transport::Page { items, meta: page.meta })
}
}
fn next_url(meta: &PageMeta) -> Option<Url> {
meta.next.clone()
}
fn offset_of(url: &Url) -> u64 {
url.as_str()
.split_once('?')
.map(|(_, query)| query)
.and_then(|query| {
query.split('&').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
(key == "offset").then(|| value.parse().ok())?
})
})
.unwrap_or(0)
}
fn with_offset(url: &Url, offset: u64) -> Url {
let (base, query) = match url.as_str().split_once('?') {
Some((base, query)) => (base, Some(query)),
None => (url.as_str(), None),
};
let mut parts: Vec<String> = query
.map(|q| q.split('&').filter(|pair| !pair.starts_with("offset=")).map(ToOwned::to_owned).collect())
.unwrap_or_default();
parts.insert(0, format!("offset={offset}"));
Url::new_lenient(format!("{base}?{}", parts.join("&")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_offset_is_read_from_and_written_to_the_query() {
let url = Url::new("https://e.com/cdrs?offset=150&limit=50").unwrap();
assert_eq!(offset_of(&url), 150);
assert_eq!(with_offset(&url, 149).as_str(), "https://e.com/cdrs?offset=149&limit=50");
let bare = Url::new("https://e.com/cdrs").unwrap();
assert_eq!(offset_of(&bare), 0);
assert_eq!(with_offset(&bare, 10).as_str(), "https://e.com/cdrs?offset=10");
}
#[test]
fn other_filters_survive_a_rewind() {
let url =
Url::new("https://e.com/cdrs?offset=100&limit=100&date_from=2016-01-01T00%3A00%3A00Z").unwrap();
assert_eq!(
with_offset(&url, 99).as_str(),
"https://e.com/cdrs?offset=99&limit=100&date_from=2016-01-01T00%3A00%3A00Z"
);
}
}