feat(export): stream_entries — CSV/JSON streaming via BoxStream<Bytes>
This commit is contained in:
@@ -1,51 +1,89 @@
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, ExportFormat},
|
||||
ports::DiaryExporter,
|
||||
};
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
pub struct ExportAdapter;
|
||||
|
||||
#[async_trait]
|
||||
impl DiaryExporter for ExportAdapter {
|
||||
async fn serialize_entries(
|
||||
fn stream_entries(
|
||||
&self,
|
||||
entries: &[DiaryEntry],
|
||||
stream: BoxStream<'static, Result<DiaryEntry, DomainError>>,
|
||||
format: ExportFormat,
|
||||
) -> Result<Vec<u8>, DomainError> {
|
||||
) -> BoxStream<'static, Result<Bytes, DomainError>> {
|
||||
match format {
|
||||
ExportFormat::Csv => serialize_csv(entries),
|
||||
ExportFormat::Json => serialize_json(entries),
|
||||
ExportFormat::Csv => stream_csv(stream),
|
||||
ExportFormat::Json => stream_json(stream),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_csv(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> {
|
||||
let mut out =
|
||||
String::from("title,year,director,rating,comment,watched_at,external_metadata_id\n");
|
||||
for e in entries {
|
||||
let title = csv_escape(e.movie().title().value());
|
||||
let year = e.movie().release_year().value();
|
||||
let director = e.movie().director().map(csv_escape).unwrap_or_default();
|
||||
let rating = e.review().rating().value();
|
||||
let comment = e
|
||||
.review()
|
||||
.comment()
|
||||
.map(|c| csv_escape(c.value()))
|
||||
.unwrap_or_default();
|
||||
let watched_at = e.review().watched_at().format("%Y-%m-%d");
|
||||
let ext_id = e
|
||||
.movie()
|
||||
.external_metadata_id()
|
||||
.map(|id| id.value().to_string())
|
||||
.unwrap_or_default();
|
||||
out.push_str(&format!(
|
||||
"{},{},{},{},{},{},{}\n",
|
||||
title, year, director, rating, comment, watched_at, ext_id
|
||||
));
|
||||
}
|
||||
Ok(out.into_bytes())
|
||||
fn stream_csv(
|
||||
entries: BoxStream<'static, Result<DiaryEntry, DomainError>>,
|
||||
) -> BoxStream<'static, Result<Bytes, DomainError>> {
|
||||
use futures::StreamExt;
|
||||
let header = futures::stream::once(async {
|
||||
Ok(Bytes::from_static(
|
||||
b"title,year,director,rating,comment,watched_at,external_metadata_id\n",
|
||||
))
|
||||
});
|
||||
let rows = entries.map(|r| r.map(|e| Bytes::from(csv_row(&e))));
|
||||
Box::pin(header.chain(rows))
|
||||
}
|
||||
|
||||
fn stream_json(
|
||||
stream: BoxStream<'static, Result<DiaryEntry, DomainError>>,
|
||||
) -> BoxStream<'static, Result<Bytes, DomainError>> {
|
||||
Box::pin(async_stream::stream! {
|
||||
futures::pin_mut!(stream);
|
||||
let mut is_first = true;
|
||||
while let Some(r) = futures::StreamExt::next(&mut stream).await {
|
||||
match r {
|
||||
Err(e) => { yield Err(e); return; }
|
||||
Ok(entry) => {
|
||||
let json = serde_json::to_string(&entry_to_json(&entry))
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()));
|
||||
let json = match json {
|
||||
Ok(s) => s,
|
||||
Err(e) => { yield Err(e); return; }
|
||||
};
|
||||
let prefix = if is_first { "[" } else { "," };
|
||||
is_first = false;
|
||||
yield Ok(Bytes::from(format!("{}{}", prefix, json)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_first {
|
||||
yield Ok(Bytes::from_static(b"[]"));
|
||||
} else {
|
||||
yield Ok(Bytes::from_static(b"]"));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn csv_row(e: &DiaryEntry) -> String {
|
||||
let title = csv_escape(e.movie().title().value());
|
||||
let year = e.movie().release_year().value();
|
||||
let director = e.movie().director().map(csv_escape).unwrap_or_default();
|
||||
let rating = e.review().rating().value();
|
||||
let comment = e
|
||||
.review()
|
||||
.comment()
|
||||
.map(|c| csv_escape(c.value()))
|
||||
.unwrap_or_default();
|
||||
let watched_at = e.review().watched_at().format("%Y-%m-%d");
|
||||
let ext_id = e
|
||||
.movie()
|
||||
.external_metadata_id()
|
||||
.map(|id| id.value().to_string())
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"{},{},{},{},{},{},{}\n",
|
||||
title, year, director, rating, comment, watched_at, ext_id
|
||||
)
|
||||
}
|
||||
|
||||
fn csv_escape(s: &str) -> String {
|
||||
@@ -56,22 +94,16 @@ fn csv_escape(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_json(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> {
|
||||
let arr: Vec<serde_json::Value> = entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"title": e.movie().title().value(),
|
||||
"year": e.movie().release_year().value(),
|
||||
"director": e.movie().director(),
|
||||
"rating": e.review().rating().value(),
|
||||
"comment": e.review().comment().map(|c| c.value()),
|
||||
"watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
|
||||
"external_metadata_id": e.movie().external_metadata_id().map(|id| id.value()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_vec_pretty(&arr).map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
fn entry_to_json(e: &DiaryEntry) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"title": e.movie().title().value(),
|
||||
"year": e.movie().release_year().value(),
|
||||
"director": e.movie().director(),
|
||||
"rating": e.review().rating().value(),
|
||||
"comment": e.review().comment().map(|c| c.value().to_string()),
|
||||
"watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
|
||||
"external_metadata_id": e.movie().external_metadata_id().map(|id| id.value().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user