Skip to main content

areev_trigger/
connector.rs

1//! The connector contract.
2//!
3//! A connector is a Tool: JSON in, JSON out, one process per invocation. It
4//! reuses `areev_run::HostToolExecutor` rather than inventing a second
5//! subprocess contract, so a host configures one seam and learns one shape —
6//! and a connector inherits the spawn hardening (timeout, output cap, secret
7//! scrub) that every other host command already gets.
8//!
9//! **stdin**
10//! ```json
11//! { "trigger": "<hash>", "connector": "gmail", "scope": "mailbox:…",
12//!   "cursor": "1802529", "max_items": 100, "config": { "int:cursor_field": "since" } }
13//! ```
14//!
15//! **stdout**
16//! ```json
17//! { "items": [ { "id": "<dedup value>", "payload": { … } } ],
18//!   "cursor": "1802611", "more": false }
19//! ```
20//!
21//! `more: true` means "there is a backlog": the next invocation runs
22//! immediately instead of waiting out the interval, so a cold start drains
23//! without hammering.
24//!
25//! **Blobs (#93).** An item may carry binary attachments without inlining
26//! them into the Event grain: a per-item `blobs` array of
27//! `{ "filename", "mime", "b64" }`, referenced from the payload by index
28//! (`"blob": "@0"`). The EVALUATOR — the party that already holds the
29//! writer — stores each entry in the CAS (`put_blob`, idempotent on
30//! content), rewrites every `"blob": "@N"` reference to the resulting
31//! `cas://sha256:…` address, and attaches a matching `content_refs` entry
32//! to the Event it writes. The run's tools then use `blob get` exactly as
33//! on the host-driven path, and erasure's sole-reference reclamation covers
34//! these blobs with no special case. Budgets are enforced per item
35//! ([`MAX_BLOB_BYTES_PER_ITEM`]) and per response
36//! ([`MAX_BLOB_BYTES_PER_RESPONSE`]), decoded size, and a violation —
37//! oversize, undecodable `b64`, or a dangling `"@N"` — fails the WHOLE
38//! poll (`TRG-E011`) with the cursor unmoved: a silently dropped attachment
39//! is an invoice posting without evidence, and a lost item is worse.
40
41use serde::{Deserialize, Serialize};
42
43/// What the evaluator hands a connector.
44#[derive(Debug, Clone, Serialize)]
45pub struct PollRequest<'a> {
46    pub trigger: &'a str,
47    pub connector: &'a str,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub scope: Option<&'a str>,
50    /// Opaque and connector-defined. Absent on the very first poll.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub cursor: Option<&'a str>,
53    pub max_items: usize,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub config: Option<&'a serde_json::Value>,
56}
57
58/// One thing the connector found.
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60#[serde(default)]
61pub struct PollItem {
62    /// The connector's own identity for this item. Used only when the
63    /// declaration names no `dedup_key`; a declared pointer wins, because the
64    /// memory's idea of identity should not depend on a connector's field
65    /// naming.
66    pub id: String,
67    pub payload: serde_json::Value,
68    /// Binary attachments for this item (#93). The evaluator stores each in
69    /// the CAS and rewrites `"blob": "@<index>"` payload references to the
70    /// `cas://` address; the bytes themselves never enter the Event grain.
71    #[serde(skip_serializing_if = "Vec::is_empty")]
72    pub blobs: Vec<PollBlob>,
73}
74
75/// One attachment riding with a [`PollItem`].
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77#[serde(default)]
78pub struct PollBlob {
79    /// Original filename, carried into the Event's `content_refs` metadata.
80    pub filename: Option<String>,
81    /// MIME type, carried into `content_refs.mime_type`.
82    pub mime: Option<String>,
83    /// The bytes, standard base64 (RFC 4648). Wasteful over the pipe but
84    /// consistent with the one JSON-on-stdio contract; a temp-file side
85    /// channel would be faster and was rejected for breaking it.
86    pub b64: String,
87}
88
89/// Decoded-size budget for one item's blobs: 16 MiB, the maximum grain
90/// size. Refused loudly (`TRG-E011`), never truncated.
91pub const MAX_BLOB_BYTES_PER_ITEM: usize = 16 * 1024 * 1024;
92
93/// Decoded-size budget for one poll response: 48 MiB — what the 64 MiB
94/// stdout cap can carry once base64's 4/3 inflation is paid.
95pub const MAX_BLOB_BYTES_PER_RESPONSE: usize = 48 * 1024 * 1024;
96
97/// What the connector answered.
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99#[serde(default)]
100pub struct PollResponse {
101    pub items: Vec<PollItem>,
102    /// The position to resume from. Absent leaves the stored cursor alone,
103    /// which is the correct reading of "I have nothing new to tell you" — the
104    /// alternative, treating absent as null, would silently rewind the source.
105    pub cursor: Option<String>,
106    /// There is more beyond this page.
107    pub more: bool,
108}
109
110/// Resolve an item's dedup value.
111///
112/// A declared `dedup_key` is a list of JSON pointers joined in order, which is
113/// how a "changed" semantic is expressed over a source that only reports
114/// "created": `/id` alone identifies the entity, `/id` + `/updated_at`
115/// identifies the *occurrence*. Zapier's `id + "-" + updatedAt` idiom, made
116/// declarative.
117pub fn dedup_value(item: &PollItem, pointers: &[String]) -> Option<String> {
118    if pointers.is_empty() {
119        return if item.id.trim().is_empty() { None } else { Some(item.id.clone()) };
120    }
121    let mut parts = Vec::with_capacity(pointers.len());
122    for p in pointers {
123        let v = item.payload.pointer(p)?;
124        parts.push(match v {
125            serde_json::Value::String(s) => s.clone(),
126            other => other.to_string(),
127        });
128    }
129    Some(parts.join("\u{1f}"))
130}
131
132/// Rewrite `"blob": "@<index>"` payload references to the CAS addresses of
133/// the item's stored blobs (#93).
134///
135/// Only string values under a key named `blob` are eligible, and only when
136/// they start with `@` — anything else (a `cas://` address from a
137/// re-delivered payload, ordinary strings) passes through untouched. An
138/// `@`-value that is not a valid in-range index is an error: a dangling
139/// reference silently left in place would hand the run a payload that
140/// points at nothing.
141pub fn rewrite_blob_refs(payload: &mut serde_json::Value, uris: &[String]) -> Result<(), String> {
142    match payload {
143        serde_json::Value::Object(map) => {
144            for (k, v) in map.iter_mut() {
145                if k == "blob" {
146                    if let serde_json::Value::String(s) = v {
147                        if let Some(n) = s.strip_prefix('@') {
148                            let uri = n
149                                .parse::<usize>()
150                                .ok()
151                                .and_then(|i| uris.get(i))
152                                .ok_or_else(|| {
153                                    format!(
154                                        "payload references blob \"@{n}\" but the item \
155                                         carries {} blob(s)",
156                                        uris.len()
157                                    )
158                                })?;
159                            *v = serde_json::Value::String(uri.clone());
160                            continue;
161                        }
162                    }
163                }
164                rewrite_blob_refs(v, uris)?;
165            }
166            Ok(())
167        }
168        serde_json::Value::Array(arr) => {
169            for v in arr.iter_mut() {
170                rewrite_blob_refs(v, uris)?;
171            }
172            Ok(())
173        }
174        _ => Ok(()),
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn item(id: &str, payload: serde_json::Value) -> PollItem {
183        PollItem { id: id.into(), payload, blobs: Vec::new() }
184    }
185
186    #[test]
187    fn falls_back_to_the_connectors_own_id() {
188        let i = item("abc", serde_json::json!({}));
189        assert_eq!(dedup_value(&i, &[]), Some("abc".into()));
190    }
191
192    #[test]
193    fn an_item_with_no_identity_at_all_yields_none() {
194        // Better to report the item as unidentifiable than to invent an
195        // identity, which would make every poll look like new work.
196        let i = item("", serde_json::json!({}));
197        assert_eq!(dedup_value(&i, &[]), None);
198        assert_eq!(dedup_value(&i, &["/missing".into()]), None);
199    }
200
201    #[test]
202    fn a_declared_pointer_beats_the_connectors_id() {
203        let i = item("connector-chosen", serde_json::json!({ "message_id": "m1" }));
204        assert_eq!(dedup_value(&i, &["/message_id".into()]), Some("m1".into()));
205    }
206
207    #[test]
208    fn composite_pointers_identify_the_occurrence_not_the_entity() {
209        let pointers = vec!["/id".to_string(), "/updated_at".to_string()];
210        let first = item("x", serde_json::json!({ "id": "42", "updated_at": 100 }));
211        let edited = item("x", serde_json::json!({ "id": "42", "updated_at": 200 }));
212        let a = dedup_value(&first, &pointers).unwrap();
213        let b = dedup_value(&edited, &pointers).unwrap();
214        assert_ne!(a, b, "an edit is a new occurrence of the same entity");
215    }
216
217    #[test]
218    fn the_joiner_cannot_be_forged_from_field_contents() {
219        // Joining on a printable character would let {"a":"1-2","b":"3"} and
220        // {"a":"1","b":"2-3"} collide, so one item could mask another.
221        let pointers = vec!["/a".to_string(), "/b".to_string()];
222        let x = item("", serde_json::json!({ "a": "1-2", "b": "3" }));
223        let y = item("", serde_json::json!({ "a": "1", "b": "2-3" }));
224        assert_ne!(dedup_value(&x, &pointers), dedup_value(&y, &pointers));
225    }
226
227    #[test]
228    fn absent_cursor_in_a_response_means_leave_it_alone() {
229        let r: PollResponse = serde_json::from_str(r#"{"items":[]}"#).unwrap();
230        assert!(r.cursor.is_none(), "not Some(\"null\") and not an error");
231        assert!(!r.more);
232    }
233
234    #[test]
235    fn blob_refs_rewrite_to_cas_addresses() {
236        let uris = vec!["cas://sha256:aa".to_string(), "cas://sha256:bb".to_string()];
237        let mut payload = serde_json::json!({
238            "email": { "from": "a@b.c" },
239            "attachments": [
240                { "filename": "inv.pdf", "blob": "@0" },
241                { "filename": "po.pdf", "blob": "@1" },
242            ],
243            // Not under a `blob` key: untouched even though it looks like one.
244            "note": "@0",
245            // Already an address (re-delivery): untouched.
246            "prior": { "blob": "cas://sha256:cc" },
247        });
248        rewrite_blob_refs(&mut payload, &uris).unwrap();
249        assert_eq!(payload["attachments"][0]["blob"], "cas://sha256:aa");
250        assert_eq!(payload["attachments"][1]["blob"], "cas://sha256:bb");
251        assert_eq!(payload["note"], "@0");
252        assert_eq!(payload["prior"]["blob"], "cas://sha256:cc");
253    }
254
255    #[test]
256    fn a_dangling_blob_ref_is_refused_not_left_in_place() {
257        let uris = vec!["cas://sha256:aa".to_string()];
258        let mut payload = serde_json::json!({ "a": { "blob": "@7" } });
259        assert!(rewrite_blob_refs(&mut payload, &uris).is_err());
260        let mut payload = serde_json::json!({ "a": { "blob": "@zero" } });
261        assert!(rewrite_blob_refs(&mut payload, &uris).is_err());
262    }
263
264    #[test]
265    fn blobs_parse_and_default_to_empty() {
266        let r: PollResponse = serde_json::from_str(
267            r#"{"items":[{"id":"m1","payload":{"a":{"blob":"@0"}},
268                 "blobs":[{"filename":"inv.pdf","mime":"application/pdf","b64":"Zm9v"}]}]}"#,
269        )
270        .unwrap();
271        assert_eq!(r.items[0].blobs.len(), 1);
272        assert_eq!(r.items[0].blobs[0].filename.as_deref(), Some("inv.pdf"));
273        // An item without the field stays valid — the 1.5.0 contract.
274        let r: PollResponse =
275            serde_json::from_str(r#"{"items":[{"id":"m1","payload":{}}]}"#).unwrap();
276        assert!(r.items[0].blobs.is_empty());
277    }
278
279    #[test]
280    fn unknown_response_fields_are_ignored() {
281        // A connector written against a later version must not break this one.
282        let r: PollResponse =
283            serde_json::from_str(r#"{"items":[],"cursor":"c","more":true,"future":1}"#).unwrap();
284        assert_eq!(r.cursor.as_deref(), Some("c"));
285        assert!(r.more);
286    }
287}