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
25use serde::{Deserialize, Serialize};
26
27/// What the evaluator hands a connector.
28#[derive(Debug, Clone, Serialize)]
29pub struct PollRequest<'a> {
30    pub trigger: &'a str,
31    pub connector: &'a str,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub scope: Option<&'a str>,
34    /// Opaque and connector-defined. Absent on the very first poll.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub cursor: Option<&'a str>,
37    pub max_items: usize,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub config: Option<&'a serde_json::Value>,
40}
41
42/// One thing the connector found.
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44#[serde(default)]
45pub struct PollItem {
46    /// The connector's own identity for this item. Used only when the
47    /// declaration names no `dedup_key`; a declared pointer wins, because the
48    /// memory's idea of identity should not depend on a connector's field
49    /// naming.
50    pub id: String,
51    pub payload: serde_json::Value,
52}
53
54/// What the connector answered.
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56#[serde(default)]
57pub struct PollResponse {
58    pub items: Vec<PollItem>,
59    /// The position to resume from. Absent leaves the stored cursor alone,
60    /// which is the correct reading of "I have nothing new to tell you" — the
61    /// alternative, treating absent as null, would silently rewind the source.
62    pub cursor: Option<String>,
63    /// There is more beyond this page.
64    pub more: bool,
65}
66
67/// Resolve an item's dedup value.
68///
69/// A declared `dedup_key` is a list of JSON pointers joined in order, which is
70/// how a "changed" semantic is expressed over a source that only reports
71/// "created": `/id` alone identifies the entity, `/id` + `/updated_at`
72/// identifies the *occurrence*. Zapier's `id + "-" + updatedAt` idiom, made
73/// declarative.
74pub fn dedup_value(item: &PollItem, pointers: &[String]) -> Option<String> {
75    if pointers.is_empty() {
76        return if item.id.trim().is_empty() { None } else { Some(item.id.clone()) };
77    }
78    let mut parts = Vec::with_capacity(pointers.len());
79    for p in pointers {
80        let v = item.payload.pointer(p)?;
81        parts.push(match v {
82            serde_json::Value::String(s) => s.clone(),
83            other => other.to_string(),
84        });
85    }
86    Some(parts.join("\u{1f}"))
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    fn item(id: &str, payload: serde_json::Value) -> PollItem {
94        PollItem { id: id.into(), payload }
95    }
96
97    #[test]
98    fn falls_back_to_the_connectors_own_id() {
99        let i = item("abc", serde_json::json!({}));
100        assert_eq!(dedup_value(&i, &[]), Some("abc".into()));
101    }
102
103    #[test]
104    fn an_item_with_no_identity_at_all_yields_none() {
105        // Better to report the item as unidentifiable than to invent an
106        // identity, which would make every poll look like new work.
107        let i = item("", serde_json::json!({}));
108        assert_eq!(dedup_value(&i, &[]), None);
109        assert_eq!(dedup_value(&i, &["/missing".into()]), None);
110    }
111
112    #[test]
113    fn a_declared_pointer_beats_the_connectors_id() {
114        let i = item("connector-chosen", serde_json::json!({ "message_id": "m1" }));
115        assert_eq!(dedup_value(&i, &["/message_id".into()]), Some("m1".into()));
116    }
117
118    #[test]
119    fn composite_pointers_identify_the_occurrence_not_the_entity() {
120        let pointers = vec!["/id".to_string(), "/updated_at".to_string()];
121        let first = item("x", serde_json::json!({ "id": "42", "updated_at": 100 }));
122        let edited = item("x", serde_json::json!({ "id": "42", "updated_at": 200 }));
123        let a = dedup_value(&first, &pointers).unwrap();
124        let b = dedup_value(&edited, &pointers).unwrap();
125        assert_ne!(a, b, "an edit is a new occurrence of the same entity");
126    }
127
128    #[test]
129    fn the_joiner_cannot_be_forged_from_field_contents() {
130        // Joining on a printable character would let {"a":"1-2","b":"3"} and
131        // {"a":"1","b":"2-3"} collide, so one item could mask another.
132        let pointers = vec!["/a".to_string(), "/b".to_string()];
133        let x = item("", serde_json::json!({ "a": "1-2", "b": "3" }));
134        let y = item("", serde_json::json!({ "a": "1", "b": "2-3" }));
135        assert_ne!(dedup_value(&x, &pointers), dedup_value(&y, &pointers));
136    }
137
138    #[test]
139    fn absent_cursor_in_a_response_means_leave_it_alone() {
140        let r: PollResponse = serde_json::from_str(r#"{"items":[]}"#).unwrap();
141        assert!(r.cursor.is_none(), "not Some(\"null\") and not an error");
142        assert!(!r.more);
143    }
144
145    #[test]
146    fn unknown_response_fields_are_ignored() {
147        // A connector written against a later version must not break this one.
148        let r: PollResponse =
149            serde_json::from_str(r#"{"items":[],"cursor":"c","more":true,"future":1}"#).unwrap();
150        assert_eq!(r.cursor.as_deref(), Some("c"));
151        assert!(r.more);
152    }
153}