use axum::response::{IntoResponse, Response};
use super::responses::{format_sse_event, into_sse_response};
#[derive(Default)]
pub struct SseResponse {
body: String,
}
impl SseResponse {
pub fn new() -> Self {
Self::default()
}
pub fn patch<T: askama::Template>(mut self, template: T) -> crate::error::Result<Self> {
let html = template
.render()
.map_err(|err| crate::error::Error::Internal(err.to_string()))?;
self.body.push_str(&format_sse_event(
"datastar-patch-elements",
"elements",
&html,
));
Ok(self)
}
pub fn patch_html(mut self, html: &str) -> Self {
self.body.push_str(&format_sse_event(
"datastar-patch-elements",
"elements",
html,
));
self
}
pub fn signals<T: serde::Serialize>(mut self, data: &T) -> crate::error::Result<Self> {
let json = serde_json::to_string(data)
.map_err(|err| crate::error::Error::Internal(err.to_string()))?;
self.body.push_str(&format_sse_event(
"datastar-patch-signals",
"signals",
&json,
));
Ok(self)
}
pub fn is_empty(&self) -> bool {
self.body.is_empty()
}
}
impl IntoResponse for SseResponse {
fn into_response(self) -> Response {
into_sse_response(self.body)
}
}
#[cfg(test)]
mod tests {
use super::*;
use askama::Template;
use axum::body::to_bytes;
use axum::http::header;
use serde_json::json;
async fn response_body(resp: Response) -> String {
let bytes = to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("read body");
String::from_utf8(bytes.to_vec()).expect("valid utf-8")
}
#[derive(Template)]
#[template(source = "<div id=\"list\">{{ content }}</div>", ext = "html")]
struct TestFragment<'a> {
content: &'a str,
}
#[tokio::test]
async fn empty_response_has_sse_headers() {
let resp = SseResponse::new().into_response();
let ct = resp
.headers()
.get(header::CONTENT_TYPE)
.expect("Content-Type")
.to_str()
.expect("valid str");
assert!(ct.contains("text/event-stream"));
let cc = resp
.headers()
.get(header::CACHE_CONTROL)
.expect("Cache-Control")
.to_str()
.expect("valid str");
assert_eq!(cc, "no-cache");
}
#[tokio::test]
async fn empty_response_has_empty_body() {
let resp = SseResponse::new().into_response();
let body = response_body(resp).await;
assert!(body.is_empty());
}
#[test]
fn is_empty_on_new() {
assert!(SseResponse::new().is_empty());
}
#[test]
fn is_not_empty_after_patch_html() {
assert!(!SseResponse::new().patch_html("<p>hi</p>").is_empty());
}
#[tokio::test]
async fn patch_renders_template() {
let resp = SseResponse::new()
.patch(TestFragment { content: "hello" })
.expect("render")
.into_response();
let body = response_body(resp).await;
assert!(body.contains("event: datastar-patch-elements\n"));
assert!(body.contains("<div id=\"list\">hello</div>"));
}
#[tokio::test]
async fn patch_html_sends_raw_html() {
let resp = SseResponse::new()
.patch_html("<span>raw</span>")
.into_response();
let body = response_body(resp).await;
assert!(body.contains("event: datastar-patch-elements\n"));
assert!(body.contains("<span>raw</span>"));
}
#[tokio::test]
async fn signals_sends_json() {
let resp = SseResponse::new()
.signals(&json!({"title": "", "count": 0}))
.expect("serialize")
.into_response();
let body = response_body(resp).await;
assert!(body.contains("event: datastar-patch-signals\n"));
assert!(body.contains("\"title\":\"\""));
assert!(body.contains("\"count\":0"));
}
#[tokio::test]
async fn combined_patch_and_signals() {
let resp = SseResponse::new()
.patch(TestFragment { content: "items" })
.expect("render")
.signals(&json!({"form": ""}))
.expect("serialize")
.into_response();
let body = response_body(resp).await;
assert!(body.contains("event: datastar-patch-elements\n"));
assert!(body.contains("event: datastar-patch-signals\n"));
let patch_pos = body.find("datastar-patch-elements").expect("patch event");
let signals_pos = body.find("datastar-patch-signals").expect("signals event");
assert!(patch_pos < signals_pos, "patch must precede signals");
}
#[tokio::test]
async fn multiple_patches_preserve_order() {
let resp = SseResponse::new()
.patch_html("<div id=\"a\">first</div>")
.patch_html("<div id=\"b\">second</div>")
.into_response();
let body = response_body(resp).await;
let first = body.find("first").expect("first fragment");
let second = body.find("second").expect("second fragment");
assert!(first < second, "fragments must appear in insertion order");
}
#[tokio::test]
async fn multiline_html_collapsed_to_single_line() {
let html = "<div>\n <p>inner</p>\n</div>";
let resp = SseResponse::new().patch_html(html).into_response();
let body = response_body(resp).await;
assert!(body.contains("<div> <p>inner</p> </div>"));
}
#[tokio::test]
async fn each_event_terminated_by_double_newline() {
let resp = SseResponse::new()
.patch_html("<p>a</p>")
.signals(&json!({"x": 1}))
.expect("serialize")
.into_response();
let body = response_body(resp).await;
let events: Vec<&str> = body.split("\n\n").filter(|s| !s.is_empty()).collect();
assert_eq!(events.len(), 2, "expected 2 events, got: {events:?}");
}
#[test]
fn default_and_new_are_equivalent() {
let a = SseResponse::new();
let b = SseResponse::default();
assert_eq!(a.body, b.body);
}
}