Skip to main content

aion_core/
payload.rs

1//! Opaque serialized payloads carried through histories and errors.
2
3use serde::{Deserialize, Serialize};
4
5/// Type-erased user data with an explicit content type tag.
6///
7/// Payload is a dumb carrier: it stores bytes and a tag but does not
8/// validate that the bytes match the tag on construction. Validation
9/// happens on read (e.g. [`Payload::to_json`] returns `Result`).
10/// Use [`Payload::from_json`] for a validated construction path.
11#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
12pub struct Payload {
13    content_type: ContentType,
14    #[ts(type = "Array<number> | PayloadElision")]
15    bytes: Vec<u8>,
16}
17
18/// Wire marker that replaces `Payload` bytes when an HTTP projection elides a
19/// payload larger than the caller's byte limit.
20///
21/// Elision applies to serialized JSON projections only — the durable payload
22/// is untouched. `size_bytes` reports the original byte length so consoles
23/// can offer on-demand loading. The marker field is always `true` on the
24/// wire, which the generated TypeScript states as a literal.
25#[derive(Serialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
26pub struct PayloadElision {
27    #[serde(rename = "__elided")]
28    #[ts(type = "true")]
29    elided: bool,
30    size_bytes: u64,
31}
32
33impl PayloadElision {
34    /// Creates the elision marker for an original payload of `size_bytes`.
35    #[must_use]
36    pub const fn new(size_bytes: u64) -> Self {
37        Self {
38            elided: true,
39            size_bytes,
40        }
41    }
42}
43
44impl Payload {
45    /// Creates an opaque payload from a content type and raw bytes.
46    ///
47    /// No validation is performed — the bytes are not checked against the
48    /// content type. Prefer [`Payload::from_json`] when constructing from
49    /// a known value. Conversion methods (e.g. [`Payload::to_json`])
50    /// validate on read.
51    #[must_use]
52    pub fn new(content_type: ContentType, bytes: Vec<u8>) -> Self {
53        Self {
54            content_type,
55            bytes,
56        }
57    }
58
59    /// Serializes a JSON value into a payload tagged as JSON.
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if the JSON value cannot be serialized.
64    pub fn from_json(value: &serde_json::Value) -> Result<Self, PayloadError> {
65        let bytes = serde_json::to_vec(value)?;
66        Ok(Self::new(ContentType::Json, bytes))
67    }
68
69    /// The JSON `null` document, tagged as JSON.
70    ///
71    /// This is the single canonical spelling of "the caller supplied nothing"
72    /// for surfaces that take a payload but allow it to be absent — notably
73    /// workflow query arguments, where every carrier (HTTP DTO, gRPC request,
74    /// CLI flag, client SDK) maps an omitted value to this document so the
75    /// workflow handler always receives one well-formed JSON input.
76    #[must_use]
77    pub fn json_null() -> Self {
78        Self::new(ContentType::Json, b"null".to_vec())
79    }
80
81    /// Deserializes this payload as a JSON value.
82    ///
83    /// # Errors
84    ///
85    /// Returns an error if the payload is not tagged as JSON or the bytes do not contain valid
86    /// JSON.
87    pub fn to_json(&self) -> Result<serde_json::Value, PayloadError> {
88        match self.content_type {
89            ContentType::Json => Ok(serde_json::from_slice(&self.bytes)?),
90        }
91    }
92
93    /// Returns the payload content type tag.
94    #[must_use]
95    pub const fn content_type(&self) -> &ContentType {
96        &self.content_type
97    }
98
99    /// Returns the opaque serialized bytes.
100    #[must_use]
101    pub fn bytes(&self) -> &[u8] {
102        &self.bytes
103    }
104}
105
106/// Stable tag describing the encoding used for a payload's bytes.
107#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq, Hash)]
108pub enum ContentType {
109    /// A `serde_json::Value` serialized as UTF-8 JSON bytes.
110    Json,
111}
112
113/// Errors produced when converting payloads to or from typed values.
114#[derive(thiserror::Error, Debug)]
115pub enum PayloadError {
116    /// JSON serialization or deserialization failed.
117    #[error("json payload conversion failed: {0}")]
118    Json(#[from] serde_json::Error),
119}
120
121#[cfg(test)]
122mod tests {
123    use serde_json::json;
124
125    use super::{ContentType, Payload};
126
127    #[test]
128    fn json_values_round_trip_losslessly() -> Result<(), Box<dyn std::error::Error>> {
129        let values = [
130            serde_json::Value::Null,
131            json!(true),
132            json!(123.45),
133            json!("hello"),
134            json!([null, false, 7, "item"]),
135            json!({"nested": {"value": 1}, "array": [true, false]}),
136        ];
137
138        for value in values {
139            let payload = Payload::from_json(&value)?;
140            assert_eq!(payload.content_type(), &ContentType::Json);
141            assert_eq!(payload.to_json()?, value);
142        }
143
144        Ok(())
145    }
146
147    #[test]
148    fn json_null_is_the_serialized_null_document() -> Result<(), Box<dyn std::error::Error>> {
149        let payload = Payload::json_null();
150
151        assert_eq!(payload.content_type(), &ContentType::Json);
152        assert_eq!(payload.bytes(), b"null");
153        assert_eq!(payload.to_json()?, serde_json::Value::Null);
154        // The convenience constructor and the validated path agree, so the
155        // "nothing supplied" document has exactly one byte spelling.
156        assert_eq!(payload, Payload::from_json(&serde_json::Value::Null)?);
157        Ok(())
158    }
159}