use crate::RetryOn;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
pub const METHOD_SUBSCRIPTION: &str = "SUBSCRIPTION";
pub const METHOD_SCHEDULE: &str = "SCHEDULE";
pub const METHOD_HOOK: &str = "HOOK";
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct GuestRequest {
pub method: String,
pub handler: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers: Option<Map<String, Value>>,
#[serde(default)]
pub body: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body_raw_b64: Option<String>,
pub tenant: String,
pub service: String,
#[serde(
default,
deserialize_with = "crate::serde_util::present",
skip_serializing_if = "Option::is_none"
)]
pub auth: Option<Value>,
#[serde(
default,
deserialize_with = "crate::serde_util::present",
skip_serializing_if = "Option::is_none"
)]
pub posture: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caller: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_params: Option<Vec<(String, String)>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query_string: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<BTreeMap<String, Vec<String>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_ip: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multipart: Option<Vec<MultipartField>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subscription: Option<SubscriptionMeta>,
#[serde(default, rename = "_job", skip_serializing_if = "Option::is_none")]
pub job: Option<JobMeta>,
}
impl GuestRequest {
pub fn to_value(&self) -> serde_json::Result<Value> {
serde_json::to_value(self)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MultipartField {
pub name: Option<String>,
pub filename: Option<String>,
pub content_type: Option<String>,
#[serde(rename = "data_b64", with = "crate::serde_util::base64_bytes")]
pub data: Vec<u8>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscriptionMeta {
pub subject: String,
pub stream: String,
pub consumer: String,
pub stream_sequence: u64,
pub consumer_sequence: u64,
pub num_delivered: u64,
pub num_pending: u64,
pub published_at: String,
#[serde(default)]
pub headers: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScheduleFire {
pub schedule: String,
pub fire_at: String,
pub catch_up: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobMeta {
pub id: String,
pub attempt: u32,
pub max_attempts: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub retry_on: Vec<RetryOn>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host::tests::round_trip;
use serde_json::json;
fn subscription() -> GuestRequest {
GuestRequest {
method: METHOD_SUBSCRIPTION.into(),
handler: "on_created".into(),
headers: Some(Map::new()),
body: json!({"id": 7}),
tenant: "default".into(),
service: "asset-service".into(),
auth: Some(Value::Null),
..Default::default()
}
}
#[test]
fn a_subscription_request_keeps_its_legacy_keys() {
let wire = json!({
"method": "SUBSCRIPTION",
"handler": "on_created",
"headers": {},
"body": {"id": 7},
"tenant": "default",
"service": "asset-service",
"auth": null
});
round_trip(&subscription(), wire.clone());
assert_eq!(
serde_json::to_string(&subscription().to_value().expect("value")).expect("text"),
serde_json::to_string(&wire).expect("text")
);
}
#[test]
fn delivery_metadata_is_an_added_key() {
let meta = SubscriptionMeta {
subject: "leads.next_call_date.set".into(),
stream: "LEADS".into(),
consumer: "notes-service-lead-consumer".into(),
stream_sequence: 81234,
consumer_sequence: 17,
num_delivered: 2,
num_pending: 3,
published_at: "2026-09-28T10:00:00Z".into(),
headers: BTreeMap::from([("Nats-Msg-Id".to_string(), vec!["lead-7".to_string()])]),
};
let request = GuestRequest {
subscription: Some(meta),
..subscription()
};
let wire = request.to_value().expect("value");
assert_eq!(
wire["subscription"],
json!({
"subject": "leads.next_call_date.set",
"stream": "LEADS",
"consumer": "notes-service-lead-consumer",
"stream_sequence": 81234,
"consumer_sequence": 17,
"num_delivered": 2,
"num_pending": 3,
"published_at": "2026-09-28T10:00:00Z",
"headers": {"Nats-Msg-Id": ["lead-7"]}
})
);
round_trip(&request, wire);
}
#[test]
fn a_route_request_carries_its_posture_even_when_null() {
let request = GuestRequest {
method: "POST".into(),
handler: "checkout".into(),
headers: Some(Map::from_iter([(
"stripe-signature".to_string(),
json!("t=1"),
)])),
body: json!({"params": {"a": "1"}}),
body_raw_b64: Some(String::new()),
tenant: "acme".into(),
service: "payments".into(),
auth: Some(json!({"sub": "u-1"})),
posture: Some(Value::Null),
path: Some("/leads/7".into()),
path_params: Some(vec![("id".into(), "7".into())]),
query_string: Some("a=1&a=2".into()),
query: Some(BTreeMap::from([(
"a".to_string(),
vec!["1".to_string(), "2".to_string()],
)])),
client_ip: Some("203.0.113.9".into()),
multipart: Some(vec![MultipartField {
name: Some("file".into()),
filename: None,
content_type: Some("text/plain".into()),
data: b"hi".to_vec(),
}]),
..Default::default()
};
round_trip(
&request,
json!({
"method": "POST",
"handler": "checkout",
"headers": {"stripe-signature": "t=1"},
"body": {"params": {"a": "1"}},
"body_raw_b64": "",
"tenant": "acme",
"service": "payments",
"auth": {"sub": "u-1"},
"posture": null,
"path": "/leads/7",
"path_params": [["id", "7"]],
"query_string": "a=1&a=2",
"query": {"a": ["1", "2"]},
"client_ip": "203.0.113.9",
"multipart": [
{"name": "file", "filename": null, "content_type": "text/plain", "data_b64": "aGk="}
]
}),
);
}
#[test]
fn a_job_request_names_its_attempt() {
let first = JobMeta {
id: "j-1".into(),
attempt: 1,
max_attempts: 3,
..Default::default()
};
round_trip(
&first,
json!({"id": "j-1", "attempt": 1, "max_attempts": 3}),
);
round_trip(
&JobMeta {
attempt: 2,
last_error: Some("upstream 503".into()),
retry_on: vec![RetryOn::Error, RetryOn::Timeout],
..first.clone()
},
json!({
"id": "j-1",
"attempt": 2,
"max_attempts": 3,
"last_error": "upstream 503",
"retry_on": ["error", "timeout"]
}),
);
let request = GuestRequest {
method: "POST".into(),
handler: "run".into(),
tenant: "t".into(),
service: "s".into(),
auth: Some(Value::Null),
job: Some(first),
..Default::default()
};
assert_eq!(
request.to_value().expect("value"),
json!({
"method": "POST",
"handler": "run",
"body": null,
"tenant": "t",
"service": "s",
"auth": null,
"_job": {"id": "j-1", "attempt": 1, "max_attempts": 3}
})
);
}
#[test]
fn a_schedule_fire_is_the_request_body() {
round_trip(
&ScheduleFire {
schedule: "order_retry".into(),
fire_at: "2026-09-28T10:00:30Z".into(),
catch_up: false,
},
json!({"schedule": "order_retry", "fire_at": "2026-09-28T10:00:30Z", "catch_up": false}),
);
}
}