use std::collections::HashSet;
use std::fmt;
use std::marker::PhantomData;
use std::pin::Pin;
use std::task::{Context, Poll};
use bytes::Bytes;
use futures_util::stream::{self, BoxStream};
use futures_util::{Stream, StreamExt};
use reqwest::header::LOCATION;
use reqwest::{Response, Url};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Deserializer, Serialize};
use crate::client::{Client, Request, is_http_url};
use crate::error::{Error, Result, api_error, require_ok};
use crate::retry::RetryPolicy;
use crate::stream::EventStream;
use crate::timestamp::Timestamp;
const NDJSON: &str = "application/x-ndjson";
const MAX_STORAGE_REDIRECTS: usize = 10;
pub struct Feed<T> {
client: Client,
pub(crate) path: &'static [&'static str],
event: PhantomData<fn() -> T>,
}
impl<T> Clone for Feed<T> {
fn clone(&self) -> Self {
Self::new(self.client.clone(), self.path)
}
}
impl<T> fmt::Debug for Feed<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Feed")
.field("path", &self.path.join("/"))
.finish_non_exhaustive()
}
}
impl<T> Feed<T> {
pub(crate) fn new(client: Client, path: &'static [&'static str]) -> Self {
Self {
client,
path,
event: PhantomData,
}
}
pub async fn list_exports(
&self,
limit: Option<u32>,
cursor: Option<&str>,
) -> Result<ExportPage> {
let mut url = self
.client
.endpoint(self.path.iter().copied().chain(["export"]));
if let Some(limit) = limit {
url.query_pairs_mut()
.append_pair("limit", &limit.to_string());
}
if let Some(cursor) = cursor {
url.query_pairs_mut().append_pair("cursor", cursor);
}
self.client
.request_json(Request::get(url, RetryPolicy::Transient))
.await
}
pub fn all_exports(&self, limit: Option<u32>, cursor: Option<&str>) -> ExportStream
where
T: 'static,
{
let pages = Pages {
feed: self.clone(),
limit,
cursor: cursor.map(str::to_owned),
done: false,
seen: HashSet::new(),
exports: Vec::new().into_iter(),
};
let inner = stream::try_unfold(pages, |mut pages| async move {
let export = pages.next().await?;
Ok(export.map(|export| (export, pages)))
});
EventStream::from_stream(inner.boxed())
}
pub async fn export_meta(&self, id: &str) -> Result<ExportMeta> {
let url = self.export_url(id, &["meta"])?;
self.client
.request_json(Request::get(url, RetryPolicy::Transient))
.await
}
pub async fn download_url(&self, id: &str) -> Result<Url> {
let request = Request::get(self.export_url(id, &[])?, RetryPolicy::Transient);
self.client
.execute(&request, async |response| {
export_location(&request.url, response).await
})
.await
}
pub async fn download(&self, id: &str) -> Result<Download> {
let request = Request::get(self.export_url(id, &[])?, RetryPolicy::Transient);
self.client
.execute(&request, async |response| {
let mut url = export_location(&request.url, response).await?;
for _ in 0..=MAX_STORAGE_REDIRECTS {
let response = self.client.get_storage(url.clone()).await?;
if response.status().is_redirection() {
url = redirect_target(&url, &response)?;
continue;
}
return require_ok(response).await.map(Download::new);
}
Err(Error::Protocol("too many export redirects".to_owned()))
})
.await
}
fn export_url(&self, id: &str, tail: &[&str]) -> Result<Url> {
let export = export_segments(id)?;
let segments = self.path.iter().copied().chain(["export"]);
Ok(self.client.endpoint(
segments
.chain(export.iter().map(String::as_str))
.chain(tail.iter().copied()),
))
}
}
impl<T: DeserializeOwned + Send + 'static> Feed<T> {
pub async fn stream(&self) -> Result<EventStream<T>> {
let url = self
.client
.endpoint(self.path.iter().copied().chain(["stream"]));
let request = Request {
accept: NDJSON,
..Request::get(url, RetryPolicy::Transient)
};
let response = self.client.execute(&request, require_ok).await?;
Ok(EventStream::new(response))
}
}
fn export_segments(id: &str) -> Result<Vec<String>> {
let invalid = || Error::InvalidInput(format!("invalid export ID {id:?}"));
if id == "latest" {
return Ok(vec![id.to_owned()]);
}
let (date, hour) = match id.split_once('/') {
Some((date, hour)) => (date, Some(hour)),
None => (id, None),
};
let mut parts = date.split('-');
let (Some(year), Some(month), Some(day), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return Err(invalid());
};
let valid_date = digits(year, 4).is_some()
&& digits(month, 2).is_some_and(|month| (1..=12).contains(&month))
&& digits(day, 2).is_some_and(|day| (1..=31).contains(&day));
if !valid_date {
return Err(invalid());
}
let Some(hour) = hour else {
return Ok(vec![date.to_owned()]);
};
let hour = digits(hour, 1)
.or_else(|| digits(hour, 2))
.filter(|hour| *hour <= 23)
.ok_or_else(invalid)?;
Ok(vec![date.to_owned(), hour.to_string()])
}
fn digits(text: &str, len: usize) -> Option<u32> {
if text.len() != len || !text.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
text.parse().ok()
}
async fn export_location(from: &Url, response: Response) -> Result<Url> {
if response.status().is_client_error() || response.status().is_server_error() {
return Err(api_error(response).await);
}
if !response.status().is_redirection() {
return Err(Error::Protocol(format!(
"expected a redirect to the export, got {}",
response.status()
)));
}
redirect_target(from, &response)
}
fn redirect_target(from: &Url, response: &Response) -> Result<Url> {
let invalid = || Error::Protocol("export redirect has no valid location".to_owned());
let location = response
.headers()
.get(LOCATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(invalid)?;
let target = from.join(location.trim()).map_err(|_| invalid())?;
if !is_http_url(&target) {
return Err(invalid());
}
if from.scheme() == "https" && target.scheme() != "https" {
return Err(Error::Protocol(
"export redirect does not use https".to_owned(),
));
}
Ok(target)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ExportPage {
pub stream: String,
#[serde(rename = "feeds")]
pub exports: Vec<Export>,
#[serde(default, deserialize_with = "non_empty")]
pub next_cursor: Option<String>,
}
fn non_empty<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
let value = Option::<String>::deserialize(deserializer)?;
Ok(value.filter(|value| !value.is_empty()))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Export {
pub id: String,
pub kind: ExportKind,
pub date: String,
pub hour: Option<u8>,
pub size_bytes: i64,
pub row_count: i64,
pub checksum: String,
pub created_at: Timestamp,
pub download_path: String,
}
wire_enum! {
pub enum ExportKind {
Hourly = "hourly",
Daily = "daily",
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ExportMeta {
pub id: String,
pub stream: String,
pub kind: ExportKind,
pub format: String,
pub date: Timestamp,
pub hour: Option<u8>,
pub rows: i64,
pub size: i64,
pub checksum: String,
pub created_at: Timestamp,
pub schema: ParquetSchema,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ParquetSchema {
pub fields: Vec<ParquetColumn>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ParquetColumn {
pub name: String,
pub r#type: ParquetType,
}
wire_enum! {
pub enum ParquetType {
String = "string",
Int64 = "int64",
Uint32 = "uint32",
Uint64 = "uint64",
Bool = "bool",
Bytes = "bytes",
}
}
struct Pages<T> {
feed: Feed<T>,
limit: Option<u32>,
cursor: Option<String>,
done: bool,
seen: HashSet<String>,
exports: std::vec::IntoIter<Export>,
}
impl<T> Pages<T> {
async fn next(&mut self) -> Result<Option<Export>> {
loop {
if let Some(export) = self.exports.next() {
return Ok(Some(export));
}
if self.done {
return Ok(None);
}
if let Some(cursor) = &self.cursor {
if !self.seen.insert(cursor.clone()) {
return Err(Error::Protocol(
"export listing returned a repeated cursor".to_owned(),
));
}
}
let page = self
.feed
.list_exports(self.limit, self.cursor.as_deref())
.await?;
self.exports = page.exports.into_iter();
self.done = page.next_cursor.is_none();
self.cursor = page.next_cursor;
}
}
}
pub type ExportStream = EventStream<Export>;
pub struct Download {
inner: BoxStream<'static, Result<Bytes>>,
content_length: Option<u64>,
}
impl Download {
fn new(response: Response) -> Self {
let content_length = response.content_length();
let inner = stream::try_unfold(response, |mut response| async move {
let chunk = response.chunk().await?;
Ok(chunk.map(|chunk| (chunk, response)))
});
Self {
inner: inner.boxed(),
content_length,
}
}
pub fn content_length(&self) -> Option<u64> {
self.content_length
}
pub async fn next(&mut self) -> Option<Result<Bytes>> {
self.inner.next().await
}
}
impl Stream for Download {
type Item = Result<Bytes>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
self.inner.poll_next_unpin(cx)
}
}
impl fmt::Debug for Download {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Download")
.field("content_length", &self.content_length)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn export_ids_become_path_segments() {
let cases: [(&str, &[&str]); 5] = [
("latest", &["latest"]),
("2026-05-03", &["2026-05-03"]),
("2026-05-03/7", &["2026-05-03", "7"]),
("2026-05-03/07", &["2026-05-03", "7"]),
("2026-05-03/23", &["2026-05-03", "23"]),
];
for (id, want) in cases {
assert_eq!(export_segments(id).unwrap(), want, "{id:?}");
}
}
#[test]
fn rejects_invalid_export_ids() {
let invalid = [
"",
"LATEST",
"latest/1",
"2026-5-03",
"2026-13-01",
"2026-00-01",
"2026-01-32",
"2026-01-01/24",
"2026-01-01/007",
"2026-01-01/",
"2026-01-01/+1",
"2026-01-01/1/meta",
"2026-01-01-01",
"../2026-01-01",
"2026-01-01",
];
for id in invalid {
assert!(
matches!(export_segments(id), Err(Error::InvalidInput(_))),
"{id:?}"
);
}
}
}