1use crate::features;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6pub struct SubscriptionDefinition {
7 pub subject: String,
8 pub handler: String,
9 pub handler_type: HandlerType,
10 #[serde(default, serialize_with = "crate::serde_util::sorted")]
11 pub config: HashMap<String, String>,
12 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub stream: Option<String>,
16 #[serde(default, skip_serializing_if = "AckMode::is_always")]
18 pub ack: AckMode,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub consumer: Option<ConsumerSpec>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub timeout_secs: Option<u32>,
25}
26
27impl SubscriptionDefinition {
28 pub const DEFAULT_TIMEOUT_SECS: u32 = 60;
29 pub const TIMEOUT_SECS: std::ops::RangeInclusive<u32> = 1..=300;
31
32 pub fn new(
34 subject: impl Into<String>,
35 handler: impl Into<String>,
36 handler_type: HandlerType,
37 ) -> Self {
38 Self {
39 subject: subject.into(),
40 handler: handler.into(),
41 handler_type,
42 ..Self::default()
43 }
44 }
45
46 pub fn derived_features(&self) -> impl Iterator<Item = &'static str> {
48 (self.consumer.is_some() || !self.ack.is_always() || self.timeout_secs.is_some())
49 .then_some(features::NATS_CONSUMER_CONFIG)
50 .into_iter()
51 }
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum HandlerType {
57 DeleteCascade,
58 UpdateField,
59 Webhook,
60 #[default]
61 Wasm,
62}
63
64impl HandlerType {
65 pub fn as_str(&self) -> &'static str {
67 match self {
68 HandlerType::DeleteCascade => "delete_cascade",
69 HandlerType::UpdateField => "update_field",
70 HandlerType::Webhook => "webhook",
71 HandlerType::Wasm => "wasm",
72 }
73 }
74}
75
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum AckMode {
79 #[default]
81 Always,
82 OnSuccess,
85}
86
87impl AckMode {
88 pub fn is_always(&self) -> bool {
89 *self == AckMode::Always
90 }
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
94pub struct ConsumerSpec {
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub durable_name: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub ack_wait_secs: Option<u32>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub max_deliver: Option<i64>,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub backoff_secs: Vec<u32>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub max_ack_pending: Option<i64>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub batch: Option<u32>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub deliver_policy: Option<DeliverPolicySpec>,
112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
114 pub filter_subjects: Vec<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub concurrency: Option<u32>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub dead_letter: Option<bool>,
122}
123
124impl ConsumerSpec {
125 pub const ACK_WAIT_SECS: std::ops::RangeInclusive<u32> = 5..=3600;
126 pub const MAX_DELIVER: std::ops::RangeInclusive<i64> = 1..=100;
127 pub const UNLIMITED_DELIVERIES: i64 = -1;
128 pub const MAX_BACKOFF_STEPS: usize = 10;
129 pub const BACKOFF_SECS: std::ops::RangeInclusive<u32> = 1..=86_400;
130 pub const MAX_ACK_PENDING: std::ops::RangeInclusive<i64> = 1..=100_000;
131 pub const BATCH: std::ops::RangeInclusive<u32> = 1..=256;
132 pub const CONCURRENCY: std::ops::RangeInclusive<u32> = 1..=64;
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum DeliverPolicySpec {
138 All,
139 New,
140 Last,
141 LastPerSubject,
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn a_handler_type_is_named_as_a_manifest_declares_it() {
150 for handler_type in [
151 HandlerType::DeleteCascade,
152 HandlerType::UpdateField,
153 HandlerType::Webhook,
154 HandlerType::Wasm,
155 ] {
156 assert_eq!(
157 serde_json::to_value(&handler_type).expect("serialize"),
158 serde_json::json!(handler_type.as_str())
159 );
160 }
161 }
162
163 #[test]
164 fn test_subscription_stream_defaults_to_none_in_legacy_json() {
165 let json = r#"{"subject":"x","handler":"h","handler_type":"wasm"}"#;
166 let sub: SubscriptionDefinition = serde_json::from_str(json).unwrap();
167 assert!(sub.stream.is_none());
168 }
169
170 #[test]
171 fn test_subscription_stream_roundtrip_when_set() {
172 let sub = SubscriptionDefinition {
173 stream: Some("CUFFLINK_TICKS".into()),
174 ..SubscriptionDefinition::new(
175 "cufflink.tick.5min",
176 "handle_tick_resync",
177 HandlerType::Wasm,
178 )
179 };
180 let json = serde_json::to_string(&sub).unwrap();
181 assert!(json.contains("\"stream\":\"CUFFLINK_TICKS\""));
182 let parsed: SubscriptionDefinition = serde_json::from_str(&json).unwrap();
183 assert_eq!(parsed.stream.as_deref(), Some("CUFFLINK_TICKS"));
184 }
185
186 #[test]
187 fn test_subscription_stream_skipped_when_none() {
188 let sub = SubscriptionDefinition::new("x", "h", HandlerType::Wasm);
189 let json = serde_json::to_string(&sub).unwrap();
190 assert!(!json.contains("stream"));
191 }
192
193 #[test]
194 fn delivery_fields_are_omitted_at_their_defaults() {
195 let sub = SubscriptionDefinition::new("dw.{tenant}.items.created", "h", HandlerType::Wasm);
196 assert_eq!(
197 serde_json::to_value(&sub).unwrap(),
198 serde_json::json!({
199 "subject": "dw.{tenant}.items.created",
200 "handler": "h",
201 "handler_type": "wasm",
202 "config": {}
203 })
204 );
205 assert_eq!(sub.derived_features().count(), 0);
206 }
207
208 #[test]
209 fn delivery_fields_round_trip_and_demand_consumer_config() {
210 let sub = SubscriptionDefinition {
211 stream: Some("BOOKS".into()),
212 ack: AckMode::OnSuccess,
213 consumer: Some(ConsumerSpec {
214 durable_name: Some("loans-service-book-consumer".into()),
215 ack_wait_secs: Some(30),
216 max_deliver: Some(3),
217 backoff_secs: vec![10, 60],
218 max_ack_pending: Some(1000),
219 batch: Some(32),
220 deliver_policy: Some(DeliverPolicySpec::LastPerSubject),
221 filter_subjects: vec!["books.deleted.loans".into()],
222 concurrency: Some(4),
223 dead_letter: Some(false),
224 }),
225 timeout_secs: Some(120),
226 ..SubscriptionDefinition::new("books.due_date.set", "on_book", HandlerType::Wasm)
227 };
228 let json = serde_json::to_value(&sub).unwrap();
229 assert_eq!(
230 json,
231 serde_json::json!({
232 "subject": "books.due_date.set",
233 "handler": "on_book",
234 "handler_type": "wasm",
235 "config": {},
236 "stream": "BOOKS",
237 "ack": "on_success",
238 "consumer": {
239 "durable_name": "loans-service-book-consumer",
240 "ack_wait_secs": 30,
241 "max_deliver": 3,
242 "backoff_secs": [10, 60],
243 "max_ack_pending": 1000,
244 "batch": 32,
245 "deliver_policy": "last_per_subject",
246 "filter_subjects": ["books.deleted.loans"],
247 "concurrency": 4,
248 "dead_letter": false
249 },
250 "timeout_secs": 120
251 })
252 );
253 let parsed: SubscriptionDefinition = serde_json::from_value(json).unwrap();
254 assert_eq!(parsed.ack, AckMode::OnSuccess);
255 assert_eq!(parsed.consumer, sub.consumer);
256 assert_eq!(parsed.timeout_secs, Some(120));
257 }
258
259 #[test]
260 fn a_config_is_written_in_the_order_of_its_keys() {
261 let sub = SubscriptionDefinition {
262 config: [
263 ("where_column", "customer_id"),
264 ("table", "order_stats"),
265 ("set_value", "lines + 1"),
266 ("set_column", "lines"),
267 ]
268 .into_iter()
269 .map(|(key, value)| (key.to_string(), value.to_string()))
270 .collect(),
271 ..SubscriptionDefinition::new("x", "count_lines", HandlerType::UpdateField)
272 };
273 assert_eq!(
274 serde_json::to_string(&sub).unwrap(),
275 r#"{"subject":"x","handler":"count_lines","handler_type":"update_field","config":{"set_column":"lines","set_value":"lines + 1","table":"order_stats","where_column":"customer_id"}}"#
276 );
277 }
278
279 #[test]
280 fn a_default_subscription_is_a_wasm_handler_with_the_platforms_delivery() {
281 let sub = SubscriptionDefinition::default();
282 assert!(matches!(sub.handler_type, HandlerType::Wasm));
283 assert_eq!(sub.ack, AckMode::Always);
284 assert_eq!((&sub.consumer, sub.timeout_secs), (&None, None));
285 assert_eq!(sub.derived_features().count(), 0);
286 }
287
288 #[test]
289 fn each_delivery_field_alone_demands_consumer_config() {
290 let plain = || SubscriptionDefinition::new("x", "h", HandlerType::Wasm);
291 for sub in [
292 SubscriptionDefinition {
293 ack: AckMode::OnSuccess,
294 ..plain()
295 },
296 SubscriptionDefinition {
297 consumer: Some(ConsumerSpec::default()),
298 ..plain()
299 },
300 SubscriptionDefinition {
301 timeout_secs: Some(60),
302 ..plain()
303 },
304 ] {
305 assert_eq!(
306 sub.derived_features().collect::<Vec<_>>(),
307 [features::NATS_CONSUMER_CONFIG]
308 );
309 }
310 }
311}