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