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}