1use 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";
10pub const METHOD_HOOK: &str = "HOOK";
12
13#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
16pub struct GuestRequest {
17 pub method: String,
18 pub handler: String,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub headers: Option<Map<String, Value>>,
22 #[serde(default)]
23 pub body: Value,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub body_raw_b64: Option<String>,
27 pub tenant: String,
28 pub service: String,
29 #[serde(
31 default,
32 deserialize_with = "crate::serde_util::present",
33 skip_serializing_if = "Option::is_none"
34 )]
35 pub auth: Option<Value>,
36 #[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 #[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 #[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 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#[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 pub num_delivered: u64,
95 pub num_pending: u64,
96 pub published_at: String,
98 #[serde(default)]
100 pub headers: BTreeMap<String, Vec<String>>,
101}
102
103#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
105pub struct ScheduleFire {
106 pub schedule: String,
107 pub fire_at: String,
109 pub catch_up: bool,
111}
112
113#[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}