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}