use crate::api::AppState;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Query, State};
use axum::response::Response;
use futures_util::stream::SplitSink;
use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
use tokio::sync::broadcast::error::RecvError;
use tracing::debug;
#[derive(Debug, Deserialize)]
pub struct StreamQuery {
topic: Option<String>,
}
pub async fn get_stream(
State(state): State<Arc<AppState>>,
Query(q): Query<StreamQuery>,
ws: WebSocketUpgrade,
) -> Response {
ws.on_upgrade(move |socket| handle_socket(socket, state, q.topic))
}
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, topic: Option<String>) {
let (mut sink, mut stream) = socket.split();
let mut rx = state.bus.subscribe();
loop {
tokio::select! {
msg = stream.next() => {
match msg {
Some(Ok(Message::Text(t))) => {
if !handle_inbound(&state, &mut sink, t.as_bytes()).await {
break;
}
}
Some(Ok(Message::Binary(b))) => {
if !handle_inbound(&state, &mut sink, &b).await {
break;
}
}
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
Some(Ok(_)) => {} }
}
evt = rx.recv() => {
match evt {
Ok(ev) => {
if topic.as_deref().is_none_or(|t| ev.topic == t) {
let text = serde_json::to_string(&ev).unwrap_or_default();
if sink.send(Message::Text(text.into())).await.is_err() {
break;
}
}
}
Err(RecvError::Lagged(n)) => {
let notice = json!({"type": "lagged", "skipped": n}).to_string();
if sink.send(Message::Text(notice.into())).await.is_err() {
break;
}
}
Err(RecvError::Closed) => break,
}
}
}
}
}
async fn handle_inbound(
state: &AppState,
sink: &mut SplitSink<WebSocket, Message>,
bytes: &[u8],
) -> bool {
match crate::decode::decode(bytes, crate::event::now_micros()) {
Ok(ev) => {
debug!(topic = %ev.topic, "ws ingest");
let _ = state.bus.publish(ev);
true
}
Err(e) => {
let frame = json!({"type": "error", "error": e.to_string()}).to_string();
sink.send(Message::Text(frame.into())).await.is_ok()
}
}
}
#[cfg(test)]
mod tests {
use crate::api::tests::{request, test_app};
use axum::Router;
use futures_util::{SinkExt, StreamExt};
use std::net::SocketAddr;
use tokio_tungstenite::tungstenite::Message as TMsg;
async fn ws_connect(
app: Router,
path: &str,
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>
{
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr: SocketAddr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let (ws, _resp) = tokio_tungstenite::connect_async(format!("ws://{addr}{path}"))
.await
.expect("ws connect failed");
ws
}
async fn recv_json(
ws: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> serde_json::Value {
let msg = tokio::time::timeout(std::time::Duration::from_secs(2), ws.next())
.await
.expect("recv timed out")
.expect("stream closed")
.expect("frame error");
let TMsg::Text(t) = msg else {
panic!("expected text frame, got {msg:?}")
};
serde_json::from_str(&t).unwrap()
}
#[tokio::test]
async fn ws_receives_published_events() {
let (bus, app) = test_app(vec![]).await;
let mut ws = ws_connect(app, "/api/v1/stream").await;
bus.publish(crate::storage::tests::ev("news", 1)).unwrap();
let v = recv_json(&mut ws).await;
assert_eq!(v["topic"], "news");
}
#[tokio::test]
async fn ws_topic_filter_blocks_others() {
let (bus, app) = test_app(vec![]).await;
let mut ws = ws_connect(app, "/api/v1/stream?topic=a").await;
bus.publish(crate::storage::tests::ev("b", 1)).unwrap();
bus.publish(crate::storage::tests::ev("a", 2)).unwrap();
let v = recv_json(&mut ws).await;
assert_eq!(v["topic"], "a"); }
#[tokio::test]
async fn ws_inbound_json_packet_is_ingested() {
let (_bus, app) = test_app(vec![]).await;
let mut ws = ws_connect(app.clone(), "/api/v1/stream").await;
ws.send(TMsg::Text(
r#"{"topic":"via-ws","id":"x","class":"c","ts":5,"payload":{}}"#.into(),
))
.await
.unwrap();
let v = recv_json(&mut ws).await;
assert_eq!(v["topic"], "via-ws");
assert!(v["ingress_ts"].as_u64().unwrap() > 0);
let (_s, events) = request(app, "GET", "/api/v1/events?topic=via-ws", None).await;
assert_eq!(events.as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn ws_inbound_msgpack_packet_is_ingested() {
let (_bus, app) = test_app(vec![]).await;
let mut ws = ws_connect(app, "/api/v1/stream?topic=mp").await;
let packet = rmp_serde::to_vec_named(&serde_json::json!({
"topic": "mp", "id": "x", "class": "c", "ts": 7, "payload": {"n": 1}
}))
.unwrap();
ws.send(TMsg::Binary(packet.into())).await.unwrap();
let v = recv_json(&mut ws).await;
assert_eq!(v["topic"], "mp");
assert_eq!(v["payload"]["n"], 1);
}
#[tokio::test]
async fn ws_malformed_inbound_gets_error_frame() {
let (_bus, app) = test_app(vec![]).await;
let mut ws = ws_connect(app, "/api/v1/stream").await;
ws.send(TMsg::Text("not json at all".into())).await.unwrap();
let v = recv_json(&mut ws).await;
assert_eq!(v["type"], "error");
}
}