Skip to main content

cufflink_types/host/
request.rs

1//! The request JSON a guest handler receives, whatever delivered it.
2
3use crate::RetryOn;
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6use std::collections::BTreeMap;
7
8pub const METHOD_SUBSCRIPTION: &str = "SUBSCRIPTION";
9pub const METHOD_SCHEDULE: &str = "SCHEDULE";
10/// The `method` of a CRUD lifecycle hook and of `on_migrate`.
11pub const METHOD_HOOK: &str = "HOOK";
12
13/// Keys a delivery does not carry are absent from the JSON, so each kind of
14/// request keeps exactly the keys it has always had.
15#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
16pub struct GuestRequest {
17    pub method: String,
18    pub handler: String,
19    /// Lowercase names.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub headers: Option<Map<String, Value>>,
22    #[serde(default)]
23    pub body: Value,
24    /// The exact bytes the caller sent, as base64; empty without a body.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub body_raw_b64: Option<String>,
27    pub tenant: String,
28    pub service: String,
29    /// `Some(Value::Null)` is an anonymous caller.
30    #[serde(
31        default,
32        deserialize_with = "crate::serde_util::present",
33        skip_serializing_if = "Option::is_none"
34    )]
35    pub auth: Option<Value>,
36    /// `Some(Value::Null)` is a route that declares no posture.
37    #[serde(
38        default,
39        deserialize_with = "crate::serde_util::present",
40        skip_serializing_if = "Option::is_none"
41    )]
42    pub posture: Option<Value>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub caller: Option<Value>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub api_key: Option<Value>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub scope: Option<Value>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub path: Option<String>,
51    /// In path order.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub path_params: Option<Vec<(String, String)>>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub query_string: Option<String>,
56    /// Every value of every key, in order.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub query: Option<BTreeMap<String, Vec<String>>>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub client_ip: Option<String>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub multipart: Option<Vec<MultipartField>>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub subscription: Option<SubscriptionMeta>,
65    #[serde(default, rename = "_job", skip_serializing_if = "Option::is_none")]
66    pub job: Option<JobMeta>,
67}
68
69impl GuestRequest {
70    /// The JSON the guest parses.
71    pub fn to_value(&self) -> serde_json::Result<Value> {
72        serde_json::to_value(self)
73    }
74}
75
76#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
77pub struct MultipartField {
78    pub name: Option<String>,
79    pub filename: Option<String>,
80    pub content_type: Option<String>,
81    #[serde(rename = "data_b64", with = "crate::serde_util::base64_bytes")]
82    pub data: Vec<u8>,
83}
84
85/// The JetStream delivery behind a `SUBSCRIPTION` or `SCHEDULE` request.
86#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
87pub struct SubscriptionMeta {
88    pub subject: String,
89    pub stream: String,
90    pub consumer: String,
91    pub stream_sequence: u64,
92    pub consumer_sequence: u64,
93    /// 1 on the first delivery.
94    pub num_delivered: u64,
95    pub num_pending: u64,
96    /// RFC 3339.
97    pub published_at: String,
98    /// The message's headers without the platform's own (`Cufflink-*`).
99    #[serde(default)]
100    pub headers: BTreeMap<String, Vec<String>>,
101}
102
103/// The body of a `SCHEDULE` request.
104#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
105pub struct ScheduleFire {
106    pub schedule: String,
107    /// The instant the schedule was due, RFC 3339.
108    pub fire_at: String,
109    /// The fire is delivered late, after an outage.
110    pub catch_up: bool,
111}
112
113/// The `_job` key of a request a worker runs as a job.
114#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
115pub struct JobMeta {
116    pub id: String,
117    pub attempt: u32,
118    pub max_attempts: u32,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub last_error: Option<String>,
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub retry_on: Vec<RetryOn>,
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::host::tests::round_trip;
129    use serde_json::json;
130
131    fn subscription() -> GuestRequest {
132        GuestRequest {
133            method: METHOD_SUBSCRIPTION.into(),
134            handler: "on_created".into(),
135            headers: Some(Map::new()),
136            body: json!({"id": 7}),
137            tenant: "default".into(),
138            service: "stock-service".into(),
139            auth: Some(Value::Null),
140            ..Default::default()
141        }
142    }
143
144    #[test]
145    fn a_subscription_request_keeps_its_legacy_keys() {
146        let wire = json!({
147            "method": "SUBSCRIPTION",
148            "handler": "on_created",
149            "headers": {},
150            "body": {"id": 7},
151            "tenant": "default",
152            "service": "stock-service",
153            "auth": null
154        });
155        round_trip(&subscription(), wire.clone());
156        assert_eq!(
157            serde_json::to_string(&subscription().to_value().expect("value")).expect("text"),
158            serde_json::to_string(&wire).expect("text")
159        );
160    }
161
162    #[test]
163    fn delivery_metadata_is_an_added_key() {
164        let meta = SubscriptionMeta {
165            subject: "books.due_date.set".into(),
166            stream: "BOOKS".into(),
167            consumer: "loans-service-book-consumer".into(),
168            stream_sequence: 81234,
169            consumer_sequence: 17,
170            num_delivered: 2,
171            num_pending: 3,
172            published_at: "2026-09-28T10:00:00Z".into(),
173            headers: BTreeMap::from([("Nats-Msg-Id".to_string(), vec!["book-7".to_string()])]),
174        };
175        let request = GuestRequest {
176            subscription: Some(meta),
177            ..subscription()
178        };
179        let wire = request.to_value().expect("value");
180        assert_eq!(
181            wire["subscription"],
182            json!({
183                "subject": "books.due_date.set",
184                "stream": "BOOKS",
185                "consumer": "loans-service-book-consumer",
186                "stream_sequence": 81234,
187                "consumer_sequence": 17,
188                "num_delivered": 2,
189                "num_pending": 3,
190                "published_at": "2026-09-28T10:00:00Z",
191                "headers": {"Nats-Msg-Id": ["book-7"]}
192            })
193        );
194        round_trip(&request, wire);
195    }
196
197    #[test]
198    fn a_route_request_carries_its_posture_even_when_null() {
199        let request = GuestRequest {
200            method: "POST".into(),
201            handler: "checkout".into(),
202            headers: Some(Map::from_iter([(
203                "stripe-signature".to_string(),
204                json!("t=1"),
205            )])),
206            body: json!({"params": {"a": "1"}}),
207            body_raw_b64: Some(String::new()),
208            tenant: "acme".into(),
209            service: "payments".into(),
210            auth: Some(json!({"sub": "u-1"})),
211            posture: Some(Value::Null),
212            path: Some("/books/7".into()),
213            path_params: Some(vec![("id".into(), "7".into())]),
214            query_string: Some("a=1&a=2".into()),
215            query: Some(BTreeMap::from([(
216                "a".to_string(),
217                vec!["1".to_string(), "2".to_string()],
218            )])),
219            client_ip: Some("203.0.113.9".into()),
220            multipart: Some(vec![MultipartField {
221                name: Some("file".into()),
222                filename: None,
223                content_type: Some("text/plain".into()),
224                data: b"hi".to_vec(),
225            }]),
226            ..Default::default()
227        };
228        round_trip(
229            &request,
230            json!({
231                "method": "POST",
232                "handler": "checkout",
233                "headers": {"stripe-signature": "t=1"},
234                "body": {"params": {"a": "1"}},
235                "body_raw_b64": "",
236                "tenant": "acme",
237                "service": "payments",
238                "auth": {"sub": "u-1"},
239                "posture": null,
240                "path": "/books/7",
241                "path_params": [["id", "7"]],
242                "query_string": "a=1&a=2",
243                "query": {"a": ["1", "2"]},
244                "client_ip": "203.0.113.9",
245                "multipart": [
246                    {"name": "file", "filename": null, "content_type": "text/plain", "data_b64": "aGk="}
247                ]
248            }),
249        );
250    }
251
252    #[test]
253    fn a_job_request_names_its_attempt() {
254        let first = JobMeta {
255            id: "j-1".into(),
256            attempt: 1,
257            max_attempts: 3,
258            ..Default::default()
259        };
260        round_trip(
261            &first,
262            json!({"id": "j-1", "attempt": 1, "max_attempts": 3}),
263        );
264        round_trip(
265            &JobMeta {
266                attempt: 2,
267                last_error: Some("upstream 503".into()),
268                retry_on: vec![RetryOn::Error, RetryOn::Timeout],
269                ..first.clone()
270            },
271            json!({
272                "id": "j-1",
273                "attempt": 2,
274                "max_attempts": 3,
275                "last_error": "upstream 503",
276                "retry_on": ["error", "timeout"]
277            }),
278        );
279        let request = GuestRequest {
280            method: "POST".into(),
281            handler: "run".into(),
282            tenant: "t".into(),
283            service: "s".into(),
284            auth: Some(Value::Null),
285            job: Some(first),
286            ..Default::default()
287        };
288        assert_eq!(
289            request.to_value().expect("value"),
290            json!({
291                "method": "POST",
292                "handler": "run",
293                "body": null,
294                "tenant": "t",
295                "service": "s",
296                "auth": null,
297                "_job": {"id": "j-1", "attempt": 1, "max_attempts": 3}
298            })
299        );
300    }
301
302    #[test]
303    fn a_schedule_fire_is_the_request_body() {
304        round_trip(
305            &ScheduleFire {
306                schedule: "order_retry".into(),
307                fire_at: "2026-09-28T10:00:30Z".into(),
308                catch_up: false,
309            },
310            json!({"schedule": "order_retry", "fire_at": "2026-09-28T10:00:30Z", "catch_up": false}),
311        );
312    }
313}