use std::pin::Pin;
use futures_util::StreamExt;
use lago_core::error::{LagoError, LagoResult};
use lago_core::event::EventEnvelope;
use lago_core::id::{BranchId, EventId, SeqNo, SessionId};
use lago_core::journal::{EventQuery, EventStream, Journal};
use lago_core::session::Session;
use reqwest::Client;
use reqwest_eventsource::{Event, EventSource};
use serde::Deserialize;
use tracing::warn;
#[derive(Deserialize)]
struct AppendEventResponse {
seq: SeqNo,
}
#[derive(Deserialize)]
struct HeadSeqResponse {
seq: SeqNo,
}
pub struct RemoteLagoJournal {
client: tokio::sync::OnceCell<Client>,
base_url: String,
}
impl Clone for RemoteLagoJournal {
fn clone(&self) -> Self {
Self {
client: match self.client.get() {
Some(c) => {
let cell = tokio::sync::OnceCell::new();
let _ = cell.set(c.clone());
cell
}
None => tokio::sync::OnceCell::new(),
},
base_url: self.base_url.clone(),
}
}
}
impl RemoteLagoJournal {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
client: tokio::sync::OnceCell::new(),
base_url: base_url.into().trim_end_matches('/').to_string(),
}
}
async fn client(&self) -> &Client {
self.client.get_or_init(|| async { Client::new() }).await
}
fn url(&self, path: &str) -> String {
format!("{}/v1{}", self.base_url, path)
}
async fn req_err(resp: reqwest::Response) -> LagoError {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
LagoError::Journal(format!("lago HTTP {status}: {body}"))
}
}
type BoxFuture<'a, T> = Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
impl Journal for RemoteLagoJournal {
fn append(&self, event: EventEnvelope) -> BoxFuture<'_, LagoResult<SeqNo>> {
Box::pin(async move {
let url = self.url(&format!("/sessions/{}/events", event.session_id));
let body = serde_json::json!({ "event": event });
let resp = self
.client()
.await
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| LagoError::Journal(e.to_string()))?;
if !resp.status().is_success() {
return Err(Self::req_err(resp).await);
}
let ar: AppendEventResponse = resp
.json()
.await
.map_err(|e| LagoError::Journal(e.to_string()))?;
Ok(ar.seq)
})
}
fn append_batch(&self, events: Vec<EventEnvelope>) -> BoxFuture<'_, LagoResult<SeqNo>> {
Box::pin(async move {
let mut last_seq = 0u64;
for event in events {
last_seq = self.append(event).await?;
}
Ok(last_seq)
})
}
fn read(&self, query: EventQuery) -> BoxFuture<'_, LagoResult<Vec<EventEnvelope>>> {
Box::pin(async move {
let session_id = query
.session_id
.as_ref()
.ok_or_else(|| LagoError::InvalidArgument("session_id required for read".into()))?;
let branch = query
.branch_id
.as_ref()
.map(std::string::ToString::to_string)
.unwrap_or_else(|| "main".to_string());
let after_seq = query.after_seq.unwrap_or(0);
let mut url = self.url(&format!("/sessions/{session_id}/events/read"));
url.push_str(&format!("?branch={branch}&after_seq={after_seq}"));
if let Some(limit) = query.limit {
url.push_str(&format!("&limit={limit}"));
}
let resp = self
.client()
.await
.get(&url)
.send()
.await
.map_err(|e| LagoError::Journal(e.to_string()))?;
if !resp.status().is_success() {
return Err(Self::req_err(resp).await);
}
resp.json::<Vec<EventEnvelope>>()
.await
.map_err(|e| LagoError::Journal(e.to_string()))
})
}
fn get_event(&self, _event_id: &EventId) -> BoxFuture<'_, LagoResult<Option<EventEnvelope>>> {
Box::pin(async { Ok(None) })
}
fn head_seq(
&self,
session_id: &SessionId,
branch_id: &BranchId,
) -> BoxFuture<'_, LagoResult<SeqNo>> {
let url = self.url(&format!(
"/sessions/{session_id}/events/head?branch={branch_id}"
));
Box::pin(async move {
let resp = self
.client()
.await
.get(&url)
.send()
.await
.map_err(|e| LagoError::Journal(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(0);
}
if !resp.status().is_success() {
return Err(Self::req_err(resp).await);
}
let hr: HeadSeqResponse = resp
.json()
.await
.map_err(|e| LagoError::Journal(e.to_string()))?;
Ok(hr.seq)
})
}
fn stream(
&self,
session_id: SessionId,
branch_id: BranchId,
after_seq: SeqNo,
) -> BoxFuture<'_, LagoResult<EventStream>> {
let url = self.url(&format!(
"/sessions/{session_id}/events?format=lago&after_seq={after_seq}&branch={branch_id}"
));
Box::pin(async move {
let rb = self.client().await.get(&url);
let es = EventSource::new(rb).map_err(|e| LagoError::Journal(e.to_string()))?;
let stream = es.filter_map(|item| async move {
match item {
Ok(Event::Message(msg)) if msg.event == "event" => Some(
serde_json::from_str::<EventEnvelope>(&msg.data).map_err(LagoError::from),
),
Ok(Event::Message(msg)) if msg.event == "done" => None,
Ok(_) => None,
Err(e) => {
warn!(error = %e, "lago SSE stream error");
Some(Err(LagoError::Journal(e.to_string())))
}
}
});
Ok(Box::pin(stream) as EventStream)
})
}
fn put_session(&self, session: Session) -> BoxFuture<'_, LagoResult<()>> {
let url = self.url(&format!("/sessions/{}", session.session_id));
Box::pin(async move {
let resp = self
.client()
.await
.put(&url)
.json(&session)
.send()
.await
.map_err(|e| LagoError::Journal(e.to_string()))?;
if resp.status().is_success() || resp.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(());
}
Err(Self::req_err(resp).await)
})
}
fn get_session(&self, session_id: &SessionId) -> BoxFuture<'_, LagoResult<Option<Session>>> {
let url = self.url(&format!("/sessions/{session_id}"));
Box::pin(async move {
let resp = self
.client()
.await
.get(&url)
.send()
.await
.map_err(|e| LagoError::Journal(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !resp.status().is_success() {
return Err(Self::req_err(resp).await);
}
let raw: serde_json::Value = resp
.json()
.await
.map_err(|e| LagoError::Journal(e.to_string()))?;
let session: Session = serde_json::from_value(raw).map_err(LagoError::from)?;
Ok(Some(session))
})
}
fn list_sessions(&self) -> BoxFuture<'_, LagoResult<Vec<Session>>> {
let url = self.url("/sessions");
Box::pin(async move {
let resp = self
.client()
.await
.get(&url)
.send()
.await
.map_err(|e| LagoError::Journal(e.to_string()))?;
if !resp.status().is_success() {
return Err(Self::req_err(resp).await);
}
let raw: serde_json::Value = resp
.json()
.await
.map_err(|e| LagoError::Journal(e.to_string()))?;
let sessions: Vec<Session> = serde_json::from_value(raw).map_err(LagoError::from)?;
Ok(sessions)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_does_not_require_tokio_runtime() {
let journal = RemoteLagoJournal::new("http://localhost:9999");
assert_eq!(journal.base_url, "http://localhost:9999");
assert!(journal.client.get().is_none());
}
#[test]
fn clone_empty_journal_does_not_panic() {
let a = RemoteLagoJournal::new("http://localhost:9999");
let b = a.clone();
assert_eq!(b.base_url, "http://localhost:9999");
assert!(b.client.get().is_none());
}
}