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}