Skip to main content

tmprl_core/
payload.rs

1//! Temporal payloads: opaque bytes plus metadata saying how to read them.
2//!
3//! Every input, result and failure detail on the wire is one of these. The encoding is a
4//! string in the metadata, and it decides everything: whether the bytes are text we can show,
5//! bytes we should not try to, or ciphertext that needs a codec server we have not called yet.
6//!
7//! Deciding that is pure, so it lives here and is tested without a server. What is *not* here
8//! is the codec round trip, which is network IO and belongs in `tmprl-client`.
9
10/// One payload, as it arrived.
11#[derive(Debug, Clone, PartialEq, Eq, Default)]
12pub struct Payload {
13    /// `metadata["encoding"]`, e.g. `json/plain`. Absent on a malformed payload, which is
14    /// treated as opaque rather than guessed at.
15    pub encoding: String,
16    /// `metadata["type"]`, when the producer set one. Search attributes set `Keyword`; most
17    /// SDK payloads set nothing.
18    pub type_hint: Option<String>,
19    pub data: Vec<u8>,
20}
21
22/// What a payload can be shown as.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Rendered {
25    /// Nothing was sent. Distinct from an empty string, which is a value.
26    Null,
27    /// Text, ready to display. JSON is pretty-printed.
28    Text(String),
29    /// Bytes we will not try to render. Guessing at an encoding produces mojibake, and a
30    /// terminal is an unforgiving place to paste control characters into.
31    Opaque { bytes: usize, encoding: String },
32    /// Not readable without the user's codec server. Renders as a badge until a decode
33    /// resolves, the value is not lost, it is just not readable yet. The encoding is carried
34    /// so the badge can name it: a codec names its output itself, and `binary/aes_comp` is
35    /// as legitimate as `binary/encrypted`.
36    Encrypted { bytes: usize, encoding: String },
37}
38
39impl Payload {
40    pub fn new(encoding: impl Into<String>, data: Vec<u8>) -> Self {
41        Self {
42            encoding: encoding.into(),
43            type_hint: None,
44            data: data.to_vec(),
45        }
46    }
47
48    /// Encodings a codec server has no part in: either we can read them, or they are
49    /// Temporal's own raw-bytes encodings, which no codec produced and none will decode.
50    const NOT_CODEC: [&'static str; 6] = [
51        "json/plain",
52        "json/protobuf",
53        "text/plain",
54        "binary/null",
55        // Raw bytes by convention, not ciphertext. Sending these to a codec would earn an
56        // error for a payload that is simply not text.
57        "binary/plain",
58        "binary/protobuf",
59    ];
60
61    /// Whether reading this needs a round trip to the user's codec server.
62    ///
63    /// Any encoding outside [`Self::NOT_CODEC`], not just Temporal's sample
64    /// `binary/encrypted`. A codec names its own output and real ones do: LORA's writes
65    /// `binary/aes_comp`, Temporal's own compression sample writes `binary/deflate`.
66    /// Matching one sample's name means never calling the codec for the others, which is
67    /// indistinguishable from a codec server that is not working.
68    ///
69    /// The cost of being wrong is asymmetric. Offering a custom converter's output to a
70    /// codec earns one error; refusing to offer a codec's output leaves the value unread
71    /// with nothing on screen to explain why.
72    ///
73    /// An empty encoding is excluded: that is a malformed payload, not ciphertext.
74    pub fn needs_codec(&self) -> bool {
75        !self.encoding.is_empty() && !Self::NOT_CODEC.contains(&self.encoding.as_str())
76    }
77
78    /// How to show it.
79    ///
80    /// The encodings are Temporal's own. Anything unrecognised is opaque rather than
81    /// optimistically decoded as UTF-8: a payload from a custom converter can be arbitrary
82    /// bytes, and printing those into a terminal is how you end up with a corrupted screen.
83    pub fn render(&self) -> Rendered {
84        match self.encoding.as_str() {
85            "binary/null" => Rendered::Null,
86            // `json/protobuf` is proto3-JSON, still JSON text on the wire.
87            "json/plain" | "json/protobuf" => match std::str::from_utf8(&self.data) {
88                Ok(text) => Rendered::Text(pretty_json(text)),
89                // Declared JSON but not valid UTF-8: the declaration is wrong, so do not
90                // trust it enough to print the bytes.
91                Err(_) => self.opaque(),
92            },
93            "text/plain" => match std::str::from_utf8(&self.data) {
94                Ok(text) => Rendered::Text(text.to_string()),
95                Err(_) => self.opaque(),
96            },
97            _ if self.needs_codec() => Rendered::Encrypted {
98                bytes: self.data.len(),
99                encoding: self.encoding.clone(),
100            },
101            _ => self.opaque(),
102        }
103    }
104
105    fn opaque(&self) -> Rendered {
106        Rendered::Opaque {
107            bytes: self.data.len(),
108            encoding: if self.encoding.is_empty() {
109                "unknown".to_string()
110            } else {
111                self.encoding.clone()
112            },
113        }
114    }
115
116    /// A single line, for a row that has no space for the whole value.
117    pub fn summary(&self, width: usize) -> String {
118        match self.render() {
119            Rendered::Null => "null".into(),
120            Rendered::Encrypted { bytes, encoding } => {
121                format!("🔒 {encoding}, {bytes} bytes")
122            }
123            Rendered::Opaque { bytes, encoding } => format!("{encoding}, {bytes} bytes"),
124            Rendered::Text(t) => {
125                // Collapse to one line first: a pretty-printed value is mostly newlines, and
126                // truncating those leaves a row that is blank but not empty.
127                let flat = t.split_whitespace().collect::<Vec<_>>().join(" ");
128                if flat.chars().count() <= width {
129                    flat
130                } else {
131                    let keep: String = flat.chars().take(width.saturating_sub(1)).collect();
132                    format!("{keep}…")
133                }
134            }
135        }
136    }
137
138    /// The bytes to hand to an external command such as `jq`.
139    ///
140    /// `None` when there is nothing meaningful to pipe, piping ciphertext or an opaque blob
141    /// into `jq` produces a parse error that says nothing useful about why.
142    pub fn pipeable(&self) -> Option<&[u8]> {
143        match self.encoding.as_str() {
144            "json/plain" | "json/protobuf" | "text/plain" => Some(&self.data),
145            _ => None,
146        }
147    }
148}
149
150/// The payloads of one row, gathered into a single JSON object for piping.
151///
152/// Returns the JSON and the labels that could not be included.
153///
154/// A row usually carries more than one payload, an activity has both an `input` and a
155/// `result`, so "pipe the payload" is ambiguous. Piping an object keyed by label removes the
156/// ambiguity and makes the obvious `jq` expressions work: `.` shows everything, `.result`
157/// picks one, `.input[1]` picks an argument.
158///
159/// A `json/plain` payload is embedded as the value it already is rather than as a string, so
160/// `.result.total` works without a second parse. One that claims JSON but does not parse is
161/// embedded as a string, it is still worth seeing, and a broken value should not make the
162/// whole object unpipeable. Anything not textual is left out and reported, because piping
163/// ciphertext into `jq` produces a parse error that explains nothing.
164pub fn payloads_as_json(payloads: &[(String, Payload)]) -> (Option<String>, Vec<String>) {
165    let mut obj = serde_json::Map::new();
166    let mut skipped = Vec::new();
167
168    for (label, p) in payloads {
169        match p.pipeable() {
170            None => skipped.push(label.clone()),
171            Some(bytes) => {
172                let Ok(text) = std::str::from_utf8(bytes) else {
173                    skipped.push(label.clone());
174                    continue;
175                };
176                let value = match p.encoding.as_str() {
177                    "json/plain" | "json/protobuf" => serde_json::from_str(text)
178                        .unwrap_or_else(|_| serde_json::Value::String(text.to_string())),
179                    _ => serde_json::Value::String(text.to_string()),
180                };
181                obj.insert(label.clone(), value);
182            }
183        }
184    }
185
186    let json = if obj.is_empty() {
187        None
188    } else {
189        serde_json::to_string_pretty(&serde_json::Value::Object(obj)).ok()
190    };
191    (json, skipped)
192}
193
194/// The sole value of a one-key JSON object, re-rendered without the wrapper.
195///
196/// `payloads_as_json` always builds an object; for a single payload the key is noise the
197/// reader has to strip before pasting. A string is handed back raw rather than re-quoted,
198/// since pasting `"abc"` where `abc` was meant is the same mistake one level down.
199pub fn unwrap_single(json: &str) -> Option<String> {
200    let value: serde_json::Value = serde_json::from_str(json).ok()?;
201    let obj = value.as_object()?;
202    if obj.len() != 1 {
203        return None;
204    }
205    match obj.values().next()? {
206        serde_json::Value::String(s) => Some(s.clone()),
207        other => serde_json::to_string_pretty(other).ok(),
208    }
209}
210
211/// Pretty-print JSON, or hand back the input unchanged when it is not JSON.
212///
213/// Payloads claim `json/plain` and are usually right, but a workflow can put anything in one.
214/// A value that does not parse is shown as it arrived rather than rejected, seeing the raw
215/// bytes is more useful than being told they were unparseable.
216pub fn pretty_json(text: &str) -> String {
217    match serde_json::from_str::<serde_json::Value>(text) {
218        Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_else(|_| text.to_string()),
219        Err(_) => text.to_string(),
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn json(body: &str) -> Payload {
228        Payload::new("json/plain", body.as_bytes().to_vec())
229    }
230
231    #[test]
232    fn a_json_payload_is_pretty_printed() {
233        let p = json(r#"{"amount":100,"currency":"GBP"}"#);
234        let Rendered::Text(t) = p.render() else {
235            panic!("expected text, got {:?}", p.render())
236        };
237        assert!(t.contains("\n"), "should be pretty-printed:\n{t}");
238        assert!(t.contains("\"amount\": 100"), "got:\n{t}");
239    }
240
241    #[test]
242    fn a_scalar_json_payload_survives_intact() {
243        // The common case from a real worker: an activity argument of `100`, or `"Sleep"`.
244        assert_eq!(json("100").render(), Rendered::Text("100".into()));
245        assert_eq!(
246            json("\"Sleep\"").render(),
247            Rendered::Text("\"Sleep\"".into())
248        );
249    }
250
251    #[test]
252    fn json_that_does_not_parse_is_shown_raw_rather_than_rejected() {
253        // A workflow can put anything in a payload it labelled json/plain. Showing the bytes
254        // beats telling the reader they were unparseable.
255        let p = json("{not json");
256        assert_eq!(p.render(), Rendered::Text("{not json".into()));
257    }
258
259    #[test]
260    fn a_null_payload_is_not_an_empty_string() {
261        let p = Payload::new("binary/null", Vec::new());
262        assert_eq!(p.render(), Rendered::Null);
263        assert_eq!(p.summary(40), "null");
264        // An empty *string* is a value, and must not be confused with nothing being sent.
265        assert_eq!(json("\"\"").render(), Rendered::Text("\"\"".into()));
266    }
267
268    #[test]
269    fn encrypted_payloads_announce_themselves_rather_than_showing_ciphertext() {
270        let p = Payload::new("binary/encrypted", vec![0u8; 64]);
271        assert!(p.needs_codec());
272        assert_eq!(
273            p.render(),
274            Rendered::Encrypted {
275                bytes: 64,
276                encoding: "binary/encrypted".into()
277            }
278        );
279        assert!(p.summary(40).contains("encrypted"));
280        assert_eq!(p.pipeable(), None, "ciphertext is not worth piping to jq");
281    }
282
283    #[test]
284    fn unknown_and_binary_encodings_stay_opaque() {
285        // Optimistically decoding arbitrary bytes as UTF-8 is how a terminal ends up full of
286        // control characters. `binary/plain` is raw bytes by convention and `""` is
287        // malformed; neither is a codec's output, so neither becomes a decode request.
288        for enc in ["binary/plain", "binary/protobuf", ""] {
289            let p = Payload::new(enc, vec![0xff, 0xfe, 0x00, 0x01]);
290            match p.render() {
291                Rendered::Opaque { bytes, .. } => assert_eq!(bytes, 4),
292                other => panic!("{enc} should be opaque, got {other:?}"),
293            }
294            assert_eq!(p.pipeable(), None);
295            assert!(!p.needs_codec(), "{enc} must not be sent to a codec");
296        }
297        // An encoding we do not recognise may well be a codec's; offering it is the only way
298        // to find out, and costs one error if it is not.
299        for enc in ["binary/deflate", "application/x-thrift"] {
300            let p = Payload::new(enc, vec![0xff, 0xfe]);
301            assert!(p.needs_codec(), "{enc} should be offered to a codec");
302            assert_eq!(p.pipeable(), None);
303        }
304        assert!(
305            Payload::new("", vec![1]).summary(40).contains("unknown"),
306            "a missing encoding should say so"
307        );
308    }
309
310    #[test]
311    fn json_that_is_not_valid_utf8_is_not_trusted() {
312        // The payload says json/plain but the bytes are not text. The declaration is wrong,
313        // so it is treated as opaque rather than printed.
314        let p = Payload::new("json/plain", vec![0xff, 0xff]);
315        assert!(matches!(p.render(), Rendered::Opaque { .. }));
316    }
317
318    #[test]
319    fn a_summary_is_one_line_and_fits() {
320        let p = json(r#"{"a":1,"b":2,"c":"a rather long string value here"}"#);
321        let s = p.summary(30);
322        assert!(!s.contains('\n'), "a summary must be one line: {s:?}");
323        assert!(
324            s.chars().count() <= 30,
325            "{} chars: {s:?}",
326            s.chars().count()
327        );
328        assert!(s.ends_with('…'));
329
330        // Short values are shown whole, without an ellipsis.
331        assert_eq!(json("42").summary(30), "42");
332    }
333
334    #[test]
335    fn payloads_pipe_as_one_object_keyed_by_label() {
336        // "Pipe the payload" is ambiguous when a row carries two. An object makes the
337        // obvious jq expressions work.
338        let payloads = vec![
339            ("input".to_string(), json(r#"{"amount":100}"#)),
340            ("result".to_string(), json(r#""charged""#)),
341        ];
342        let (out, skipped) = payloads_as_json(&payloads);
343        let out = out.expect("something to pipe");
344        assert!(skipped.is_empty());
345
346        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
347        // Embedded as values, not as strings: `.input.amount` must work without a re-parse.
348        assert_eq!(v["input"]["amount"], 100);
349        assert_eq!(v["result"], "charged");
350    }
351
352    #[test]
353    fn indexed_arguments_keep_their_labels() {
354        let payloads = vec![
355            ("input[0]".to_string(), json("1")),
356            ("input[1]".to_string(), json(r#""two""#)),
357        ];
358        let (out, _) = payloads_as_json(&payloads);
359        let v: serde_json::Value = serde_json::from_str(&out.unwrap()).unwrap();
360        assert_eq!(v["input[0]"], 1);
361        assert_eq!(v["input[1]"], "two");
362    }
363
364    #[test]
365    fn unpipeable_payloads_are_reported_rather_than_breaking_the_object() {
366        let payloads = vec![
367            ("input".to_string(), json("42")),
368            (
369                "result".to_string(),
370                Payload::new("binary/encrypted", vec![0u8; 8]),
371            ),
372        ];
373        let (out, skipped) = payloads_as_json(&payloads);
374        let v: serde_json::Value = serde_json::from_str(&out.unwrap()).unwrap();
375        assert_eq!(v["input"], 42);
376        assert!(v.get("result").is_none(), "ciphertext must not be embedded");
377        assert_eq!(skipped, ["result"], "and the reader is told which");
378    }
379
380    #[test]
381    fn a_row_with_nothing_pipeable_yields_no_json() {
382        let payloads = vec![(
383            "input".to_string(),
384            Payload::new("binary/encrypted", vec![0u8; 8]),
385        )];
386        let (out, skipped) = payloads_as_json(&payloads);
387        assert_eq!(out, None, "an empty object is not worth piping");
388        assert_eq!(skipped, ["input"]);
389        assert_eq!(payloads_as_json(&[]), (None, Vec::new()));
390    }
391
392    #[test]
393    fn a_broken_json_payload_is_embedded_as_a_string_rather_than_lost() {
394        // One malformed value must not make the whole row unpipeable.
395        let payloads = vec![
396            ("input".to_string(), json("{not json")),
397            ("result".to_string(), json("1")),
398        ];
399        let (out, skipped) = payloads_as_json(&payloads);
400        let v: serde_json::Value = serde_json::from_str(&out.unwrap()).unwrap();
401        assert_eq!(v["input"], "{not json");
402        assert_eq!(v["result"], 1);
403        assert!(skipped.is_empty());
404    }
405
406    #[test]
407    fn only_textual_payloads_are_pipeable() {
408        assert_eq!(json("{}").pipeable(), Some(&b"{}"[..]));
409        assert_eq!(
410            Payload::new("text/plain", b"hello".to_vec()).pipeable(),
411            Some(&b"hello"[..])
412        );
413        assert_eq!(Payload::new("binary/null", vec![]).pipeable(), None);
414    }
415
416    #[test]
417    fn a_single_payload_is_unwrapped_for_pasting() {
418        // `<leader>yr` on an ordinary activity should give the result itself.
419        let (json, _) = payloads_as_json(&[(
420            "result".into(),
421            Payload::new("json/plain", br#"{"total":42}"#.to_vec()),
422        )]);
423        let out = unwrap_single(&json.unwrap()).unwrap();
424        assert!(out.contains(r#""total": 42"#), "{out}");
425        assert!(
426            !out.contains("result"),
427            "the wrapper key should be gone: {out}"
428        );
429    }
430
431    #[test]
432    fn a_single_string_payload_is_not_requoted() {
433        let (json, _) = payloads_as_json(&[(
434            "result".into(),
435            Payload::new("text/plain", b"already text".to_vec()),
436        )]);
437        assert_eq!(unwrap_single(&json.unwrap()).unwrap(), "already text");
438    }
439
440    #[test]
441    fn several_payloads_keep_their_keys() {
442        // Two arguments are only distinguishable by label, so the object stays.
443        let (json, _) = payloads_as_json(&[
444            ("input[0]".into(), Payload::new("json/plain", b"1".to_vec())),
445            ("input[1]".into(), Payload::new("json/plain", b"2".to_vec())),
446        ]);
447        assert_eq!(unwrap_single(&json.unwrap()), None, "must not unwrap");
448    }
449
450    #[test]
451    fn a_codec_may_name_its_own_encoding() {
452        // LORA's data converter writes `binary/aes_comp`. Matching only Temporal's sample
453        // name meant never calling the codec for these, which is indistinguishable from a
454        // codec server that is not working.
455        let p = Payload::new("binary/aes_comp", vec![0; 10232]);
456        assert!(
457            p.needs_codec(),
458            "a custom codec encoding still needs a codec"
459        );
460        assert_eq!(
461            p.render(),
462            Rendered::Encrypted {
463                bytes: 10232,
464                encoding: "binary/aes_comp".into()
465            },
466            "and the badge names the encoding rather than guessing at `encrypted`"
467        );
468    }
469
470    #[test]
471    fn readable_encodings_never_ask_for_a_codec() {
472        for e in ["json/plain", "json/protobuf", "text/plain", "binary/null"] {
473            assert!(
474                !Payload::new(e, b"{}".to_vec()).needs_codec(),
475                "{e} is readable as it stands"
476            );
477        }
478    }
479
480    #[test]
481    fn a_malformed_payload_is_not_sent_to_a_codec() {
482        // No encoding at all is a broken payload, not ciphertext; a codec has nothing to do
483        // with it and the round trip would only fail slowly.
484        assert!(!Payload::new("", vec![1, 2, 3]).needs_codec());
485    }
486}