use rama_core::bytes::Bytes;
use rama_core::error::BoxError;
use rama_core::futures::FutureExt;
use rama_core::futures::future::BoxFuture;
use rama_core::futures::stream::{self, StreamExt};
use rama_http_headers::ContentType;
use rama_http_types::{Body, Response};
use crate::protocols::html::{IntoHtml, template};
use super::{Headers, IntoResponse};
#[must_use]
pub struct PartialUpdates<H> {
shell: H,
fragments: Vec<Fragment>,
}
type FragmentRender = Box<dyn FnOnce(&mut String) + Send + 'static>;
struct Fragment {
name: &'static str,
future: BoxFuture<'static, Result<FragmentRender, BoxError>>,
}
impl<H: IntoHtml> PartialUpdates<H> {
pub fn new(shell: H) -> Self {
Self {
shell,
fragments: Vec::new(),
}
}
pub fn fragment<F, T>(mut self, name: &'static str, fut: F) -> Self
where
F: Future<Output = T> + Send + 'static,
T: IntoHtml + Send + 'static,
{
self.fragments.push(Fragment {
name,
future: async move {
let value = fut.await;
let render: FragmentRender = Box::new(move |buf| value.escape_and_write(buf));
Ok(render)
}
.boxed(),
});
self
}
pub fn try_fragment<F, T, E>(mut self, name: &'static str, fut: F) -> Self
where
F: Future<Output = Result<T, E>> + Send + 'static,
T: IntoHtml + Send + 'static,
E: Into<BoxError> + Send + 'static,
{
self.fragments.push(Fragment {
name,
future: async move {
let value = fut.await.map_err(Into::into)?;
let render: FragmentRender = Box::new(move |buf| value.escape_and_write(buf));
Ok(render)
}
.boxed(),
});
self
}
}
impl<H> IntoResponse for PartialUpdates<H>
where
H: IntoHtml + Send + 'static,
{
fn into_response(self) -> Response {
let shell = self.shell.into_string();
let shell_chunk = stream::once(async move { Ok::<_, BoxError>(Bytes::from(shell)) });
let frag_count = self.fragments.len().max(1);
let fragment_chunks = stream::iter(self.fragments)
.map(|Fragment { name, future }| async move {
let render = future.await?;
let mut s = template!(r#for = name, render).into_string();
s.push_str("\n<wbr>");
Ok::<_, BoxError>(Bytes::from(s))
})
.buffer_unordered(frag_count);
(
Headers::single(ContentType::html_utf8()),
Body::from_stream(shell_chunk.chain(fragment_chunks)),
)
.into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocols::html::{html, marker, p};
use std::time::Duration;
use tokio::time::{Instant, sleep};
#[tokio::test(start_paused = true)]
async fn shell_arrives_before_slowest_fragment() {
let shell = html!(p!(marker("slow")));
let res = PartialUpdates::new(shell)
.fragment("slow", async {
sleep(Duration::from_millis(500)).await;
"ok"
})
.into_response();
let mut body = res.into_body();
let t0 = Instant::now();
let first = body.chunk().await.unwrap().unwrap();
let t_first = t0.elapsed();
assert!(
t_first < Duration::from_millis(50),
"shell should flush immediately, got {t_first:?}"
);
let first = std::str::from_utf8(&first).unwrap();
assert!(first.contains(r#"<?marker name="slow">"#));
assert!(!first.contains("<template for="));
let second = body.chunk().await.unwrap().unwrap();
let t_second = t0.elapsed();
assert!(
t_second >= Duration::from_millis(500),
"fragment should wait for its delay, got {t_second:?}"
);
assert_eq!(
std::str::from_utf8(&second).unwrap(),
"<template for=\"slow\">ok</template>\n<wbr>"
);
assert!(body.chunk().await.unwrap().is_none());
}
#[tokio::test(start_paused = true)]
async fn fragment_macro_node_keeps_tags() {
let shell = html!(p!(marker("x")));
let res = PartialUpdates::new(shell)
.fragment("x", async { p!("<not-a-tag>") })
.into_response();
let mut body = res.into_body();
let _shell = body.chunk().await.unwrap().unwrap();
let tpl = body.chunk().await.unwrap().unwrap();
assert_eq!(
std::str::from_utf8(&tpl).unwrap(),
"<template for=\"x\"><p><not-a-tag></p></template>\n<wbr>"
);
}
#[tokio::test(start_paused = true)]
async fn fragment_name_is_html_escaped() {
let shell = html!(p!(marker("a<b")));
let res = PartialUpdates::new(shell)
.fragment("a<b", async { "ok" })
.into_response();
let mut body = res.into_body();
let _shell = body.chunk().await.unwrap().unwrap();
let tpl = body.chunk().await.unwrap().unwrap();
assert_eq!(
std::str::from_utf8(&tpl).unwrap(),
"<template for=\"a<b\">ok</template>\n<wbr>"
);
}
#[tokio::test(start_paused = true)]
async fn fragments_stream_in_completion_order() {
let shell = html!(p!(marker("a"), marker("b"), marker("c")));
let res = PartialUpdates::new(shell)
.fragment("a", async {
sleep(Duration::from_millis(600)).await;
"A"
})
.fragment("b", async {
sleep(Duration::from_millis(100)).await;
"B"
})
.fragment("c", async {
sleep(Duration::from_millis(300)).await;
"C"
})
.into_response();
let mut body = res.into_body();
let t0 = Instant::now();
let mut chunks: Vec<(Duration, String)> = Vec::new();
while let Some(chunk) = body.chunk().await.unwrap() {
chunks.push((
t0.elapsed(),
String::from_utf8(chunk.to_vec()).expect("utf8"),
));
}
assert_eq!(chunks.len(), 4, "shell + 3 fragments");
assert!(chunks[0].1.contains(r#"<?marker name="a">"#));
assert_eq!(chunks[1].1, "<template for=\"b\">B</template>\n<wbr>");
assert_eq!(chunks[2].1, "<template for=\"c\">C</template>\n<wbr>");
assert_eq!(chunks[3].1, "<template for=\"a\">A</template>\n<wbr>");
let spread = chunks[3].0.checked_sub(chunks[1].0).unwrap();
assert!(
spread >= Duration::from_millis(400),
"fragment chunks must arrive spread out over time, got {spread:?}"
);
}
}