aion-core 0.25.0

Pure domain model and shared vocabulary for Aion durable workflows.
Documentation
//! Opaque serialized payloads carried through histories and errors.

use serde::{Deserialize, Serialize};

/// Type-erased user data with an explicit content type tag.
///
/// Payload is a dumb carrier: it stores bytes and a tag but does not
/// validate that the bytes match the tag on construction. Validation
/// happens on read (e.g. [`Payload::to_json`] returns `Result`).
/// Use [`Payload::from_json`] for a validated construction path.
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct Payload {
    content_type: ContentType,
    #[ts(type = "Array<number> | PayloadElision")]
    bytes: Vec<u8>,
}

/// Wire marker that replaces `Payload` bytes when an HTTP projection elides a
/// payload larger than the caller's byte limit.
///
/// Elision applies to serialized JSON projections only — the durable payload
/// is untouched. `size_bytes` reports the original byte length so consoles
/// can offer on-demand loading. The marker field is always `true` on the
/// wire, which the generated TypeScript states as a literal.
#[derive(Serialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
pub struct PayloadElision {
    #[serde(rename = "__elided")]
    #[ts(type = "true")]
    elided: bool,
    size_bytes: u64,
}

impl PayloadElision {
    /// Creates the elision marker for an original payload of `size_bytes`.
    #[must_use]
    pub const fn new(size_bytes: u64) -> Self {
        Self {
            elided: true,
            size_bytes,
        }
    }
}

impl Payload {
    /// Creates an opaque payload from a content type and raw bytes.
    ///
    /// No validation is performed — the bytes are not checked against the
    /// content type. Prefer [`Payload::from_json`] when constructing from
    /// a known value. Conversion methods (e.g. [`Payload::to_json`])
    /// validate on read.
    #[must_use]
    pub fn new(content_type: ContentType, bytes: Vec<u8>) -> Self {
        Self {
            content_type,
            bytes,
        }
    }

    /// Serializes a JSON value into a payload tagged as JSON.
    ///
    /// # Errors
    ///
    /// Returns an error if the JSON value cannot be serialized.
    pub fn from_json(value: &serde_json::Value) -> Result<Self, PayloadError> {
        let bytes = serde_json::to_vec(value)?;
        Ok(Self::new(ContentType::Json, bytes))
    }

    /// The JSON `null` document, tagged as JSON.
    ///
    /// This is the single canonical spelling of "the caller supplied nothing"
    /// for surfaces that take a payload but allow it to be absent — notably
    /// workflow query arguments, where every carrier (HTTP DTO, gRPC request,
    /// CLI flag, client SDK) maps an omitted value to this document so the
    /// workflow handler always receives one well-formed JSON input.
    #[must_use]
    pub fn json_null() -> Self {
        Self::new(ContentType::Json, b"null".to_vec())
    }

    /// Deserializes this payload as a JSON value.
    ///
    /// # Errors
    ///
    /// Returns an error if the payload is not tagged as JSON or the bytes do not contain valid
    /// JSON.
    pub fn to_json(&self) -> Result<serde_json::Value, PayloadError> {
        match self.content_type {
            ContentType::Json => Ok(serde_json::from_slice(&self.bytes)?),
        }
    }

    /// Returns the payload content type tag.
    #[must_use]
    pub const fn content_type(&self) -> &ContentType {
        &self.content_type
    }

    /// Returns the opaque serialized bytes.
    #[must_use]
    pub fn bytes(&self) -> &[u8] {
        &self.bytes
    }
}

/// Stable tag describing the encoding used for a payload's bytes.
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq, Hash)]
pub enum ContentType {
    /// A `serde_json::Value` serialized as UTF-8 JSON bytes.
    Json,
}

/// Errors produced when converting payloads to or from typed values.
#[derive(thiserror::Error, Debug)]
pub enum PayloadError {
    /// JSON serialization or deserialization failed.
    #[error("json payload conversion failed: {0}")]
    Json(#[from] serde_json::Error),
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::{ContentType, Payload};

    #[test]
    fn json_values_round_trip_losslessly() -> Result<(), Box<dyn std::error::Error>> {
        let values = [
            serde_json::Value::Null,
            json!(true),
            json!(123.45),
            json!("hello"),
            json!([null, false, 7, "item"]),
            json!({"nested": {"value": 1}, "array": [true, false]}),
        ];

        for value in values {
            let payload = Payload::from_json(&value)?;
            assert_eq!(payload.content_type(), &ContentType::Json);
            assert_eq!(payload.to_json()?, value);
        }

        Ok(())
    }

    #[test]
    fn json_null_is_the_serialized_null_document() -> Result<(), Box<dyn std::error::Error>> {
        let payload = Payload::json_null();

        assert_eq!(payload.content_type(), &ContentType::Json);
        assert_eq!(payload.bytes(), b"null");
        assert_eq!(payload.to_json()?, serde_json::Value::Null);
        // The convenience constructor and the validated path agree, so the
        // "nothing supplied" document has exactly one byte spelling.
        assert_eq!(payload, Payload::from_json(&serde_json::Value::Null)?);
        Ok(())
    }
}