nolgia 0.1.0

Official Rust SDK for the Nolgia API: image, video, audio and agent generation, generated from the OpenAPI spec
Documentation
//! Hand-written. The API's two server-sent event streams: one job's status
//! (`GET /jobs/{id}/sse`) and one agent session's transcript
//! (`GET /agent/sessions/{id}/events`).

use bytes::{Bytes, BytesMut};
use futures_util::{Stream, StreamExt, stream};
use serde::Deserialize;
use serde_json::Value;
use uuid::Uuid;

use crate::{
    Client, Typed,
    error::{Error, Result},
};

/// One raw server-sent event.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct SseEvent {
    /// The `event:` name (`message` when the server sent none).
    pub event: String,
    /// The `data:` lines, joined with newlines.
    pub data: String,
    /// The `id:`, when present. Pass the last one back as `last_event_id`
    /// to resume a session stream without losing events.
    pub id: Option<String>,
}

/// Incremental `text/event-stream` parser.
#[derive(Debug, Default)]
pub(crate) struct SseParser {
    buffer: BytesMut,
    event: Option<String>,
    data: Vec<String>,
    id: Option<String>,
}

impl SseParser {
    pub(crate) fn push(&mut self, chunk: &[u8]) -> Vec<SseEvent> {
        self.buffer.extend_from_slice(chunk);
        let mut out = Vec::new();
        while let Some(end) = self.buffer.iter().position(|b| *b == b'\n') {
            let line = self.buffer.split_to(end + 1);
            let line = String::from_utf8_lossy(&line);
            let line = line.trim_end_matches(['\n', '\r']);
            if line.is_empty() {
                if !self.data.is_empty() || self.event.is_some() {
                    out.push(SseEvent {
                        event: self.event.take().unwrap_or_else(|| "message".into()),
                        data: std::mem::take(&mut self.data).join("\n"),
                        id: self.id.clone(),
                    });
                }
                continue;
            }
            if line.starts_with(':') {
                continue; // Comment (`: heartbeat`).
            }
            let (field, value) = match line.split_once(':') {
                Some((field, value)) => (field, value.strip_prefix(' ').unwrap_or(value)),
                None => (line, ""),
            };
            match field {
                "event" => self.event = Some(value.to_owned()),
                "data" => self.data.push(value.to_owned()),
                "id" => self.id = Some(value.to_owned()),
                _ => {} // `retry:` and unknown fields.
            }
        }
        out
    }
}

pub(crate) fn sse_stream(
    response: reqwest::Response,
) -> impl Stream<Item = Result<SseEvent>> + Send + 'static {
    let bytes = response.bytes_stream();
    stream::unfold(
        (
            bytes,
            SseParser::default(),
            std::collections::VecDeque::new(),
            false,
        ),
        |(mut bytes, mut parser, mut pending, done)| async move {
            loop {
                if let Some(event) = pending.pop_front() {
                    return Some((Ok(event), (bytes, parser, pending, done)));
                }
                if done {
                    return None;
                }
                match bytes.next().await {
                    Some(Ok(chunk)) => {
                        let chunk: Bytes = chunk;
                        pending.extend(parser.push(&chunk));
                    }
                    Some(Err(err)) => {
                        return Some((Err(Error::transport(err)), (bytes, parser, pending, true)));
                    }
                    None => {
                        // A final event without its blank line still counts.
                        pending.extend(parser.push(b"\n\n"));
                        if pending.is_empty() {
                            return None;
                        }
                        return Some((
                            Ok(pending.pop_front().expect("non-empty")),
                            (bytes, parser, pending, true),
                        ));
                    }
                }
            }
        },
    )
}

/// One frame of a job's status stream.
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[non_exhaustive]
pub struct JobEvent {
    /// `status` for an update, `complete` for the final frame.
    #[serde(skip)]
    pub event: String,
    /// `queued`, `running`, `succeeded`, `failed` or `canceled`.
    pub status: String,
    /// Progress from 0 to 100, when the provider reports it.
    #[serde(default)]
    pub progress: Option<f64>,
    /// The result URL on a succeeded frame.
    #[serde(default)]
    pub asset_url: Option<String>,
    /// The failure or cancellation message.
    #[serde(default)]
    pub error: Option<String>,
    /// `failure.kind` on a failed frame.
    #[serde(default)]
    pub failure_kind: Option<String>,
    /// `failure.code` on a failed frame.
    #[serde(default)]
    pub failure_code: Option<String>,
    /// What the ledger did with the hold, on a failed frame. `None` is unknown,
    /// never "no charge".
    #[serde(default)]
    pub credits_refunded: Option<bool>,
}

impl JobEvent {
    /// The last frame: the job reached a terminal status.
    pub fn is_complete(&self) -> bool {
        self.event == "complete"
    }
}

/// One frame of an agent session's event stream.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct SessionEvent {
    /// The envelope type: `message`, `step`, `session_status`, `job`, `ready`,
    /// or a newer one (ignore what you do not recognise).
    pub event: String,
    /// The watermark. Reconnect with it as `last_event_id` to replay what you
    /// missed.
    pub id: Option<String>,
    /// The frame's JSON payload.
    pub data: Value,
}

impl Client {
    /// Live status for one job: the current state first, then a frame per
    /// update, ending after the terminal (`complete`) frame.
    ///
    /// ```no_run
    /// # async fn run(nolgia: nolgia::Client, job: uuid::Uuid) -> nolgia::Result<()> {
    /// use nolgia::StreamExt;
    ///
    /// let mut events = Box::pin(nolgia.job_events(job).await?);
    /// while let Some(event) = events.next().await {
    ///     let event = event?;
    ///     println!("{} {:?}", event.status, event.progress);
    /// }
    /// # Ok(()) }
    /// ```
    pub async fn job_events(
        &self,
        job_id: Uuid,
    ) -> Result<impl Stream<Item = Result<JobEvent>> + Send + 'static> {
        let ticket = self
            .create_job_sse_ticket()
            .id(job_id)
            .send()
            .typed()
            .await?;
        // The ticket is the credential here (single use); the request goes to
        // the API host, so the client's own headers are fine to send.
        let response = self
            .send(
                self.http()
                    .get(self.url(&format!("/jobs/{job_id}/sse")))
                    .query(&[("ticket", ticket.ticket.as_str())])
                    .timeout(std::time::Duration::from_secs(3 * 60 * 60))
                    .header(reqwest::header::ACCEPT, "text/event-stream"),
                "stream_job_sse",
            )
            .await?;
        Ok(sse_stream(response).filter_map(|event| async move {
            match event {
                Err(err) => Some(Err(err)),
                Ok(raw) if raw.data.is_empty() => None,
                Ok(raw) => Some(
                    serde_json::from_str::<JobEvent>(&raw.data)
                        .map(|mut event| {
                            event.event = raw.event;
                            event
                        })
                        .map_err(|err| Error::decode(None, err)),
                ),
            }
        }))
    }

    /// An agent session's live events (`GET /agent/sessions/{id}/events`).
    ///
    /// Pass the last [`SessionEvent::id`] you saw as `last_event_id` after a
    /// disconnect and the server replays every write since, so nothing is
    /// lost. The stream ends when the server closes it; reconnect to carry on.
    pub async fn session_events(
        &self,
        session_id: Uuid,
        last_event_id: Option<&str>,
    ) -> Result<impl Stream<Item = Result<SessionEvent>> + Send + 'static> {
        let mut request = self
            .http()
            .get(self.url(&format!("/agent/sessions/{session_id}/events")))
            .timeout(std::time::Duration::from_secs(24 * 60 * 60))
            .header(reqwest::header::ACCEPT, "text/event-stream");
        if let Some(id) = last_event_id {
            request = request.header("last-event-id", id);
        }
        let response = self.send(request, "stream_agent_session_events").await?;
        Ok(sse_stream(response).map(|event| {
            event.map(|raw| SessionEvent {
                data: serde_json::from_str(&raw.data).unwrap_or(Value::String(raw.data)),
                event: raw.event,
                id: raw.id,
            })
        }))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_events_split_across_chunks() {
        let mut parser = SseParser::default();
        assert!(
            parser
                .push(b"retry: 3000\n\n: heartbeat\n\nevent: sta")
                .is_empty()
        );
        let events = parser.push(b"tus\ndata: {\"status\":\"running\"}\n\n");
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].event, "status");
        assert_eq!(events[0].data, r#"{"status":"running"}"#);
        let events = parser.push(b"id: 42\r\ndata: a\r\ndata: b\r\n\r\n");
        assert_eq!(events[0].event, "message");
        assert_eq!(events[0].data, "a\nb");
        assert_eq!(events[0].id.as_deref(), Some("42"));
    }
}