1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3
4use crate::CamelError;
5use crate::declarative::LanguageExpressionDef;
6use crate::splitter::StreamSplitConfig;
7
8pub const CANONICAL_CONTRACT_NAME: &str = "canonical-v1";
9pub const CANONICAL_CONTRACT_VERSION: u32 = 2;
10pub const CANONICAL_CONTRACT_SUPPORTED_STEPS: &[&str] = &[
11 "to",
12 "log",
13 "wire_tap",
14 "script",
15 "filter",
16 "choice",
17 "split",
18 "aggregate",
19 "stop",
20 "delay",
21 "cache",
22 "cache_invalidate",
23 "cache_peek_stale",
24];
25pub const CANONICAL_CONTRACT_DECLARATIVE_ONLY_STEPS: &[&str] =
26 &["script", "filter", "choice", "split"];
27pub const CANONICAL_CONTRACT_EXCLUDED_DECLARATIVE_STEPS: &[&str] = &[
28 "set_header",
29 "set_property",
30 "set_body",
31 "multicast",
32 "convert_body_to",
33 "bean",
34 "marshal",
35 "unmarshal",
36];
37pub const CANONICAL_CONTRACT_RUST_ONLY_STEPS: &[&str] = &[
38 "processor",
39 "process",
40 "process_fn",
41 "map_body",
42 "set_body_fn",
43 "set_header_fn",
44];
45
46pub fn canonical_contract_supports_step(step: &str) -> bool {
47 CANONICAL_CONTRACT_SUPPORTED_STEPS.contains(&step)
48}
49
50pub fn canonical_contract_rejection_reason(step: &str) -> Option<&'static str> {
51 if CANONICAL_CONTRACT_EXCLUDED_DECLARATIVE_STEPS.contains(&step) {
52 return Some(
53 "declared out-of-scope for canonical v2; use declarative route compilation path outside CQRS canonical commands",
54 );
55 }
56
57 if CANONICAL_CONTRACT_RUST_ONLY_STEPS.contains(&step) {
58 return Some("rust-only programmable step; not representable in canonical v2 contract");
59 }
60
61 if canonical_contract_supports_step(step)
62 && CANONICAL_CONTRACT_DECLARATIVE_ONLY_STEPS.contains(&step)
63 {
64 return Some(
65 "supported only as declarative/serializable expression form; closure/processor variants are outside canonical v2",
66 );
67 }
68
69 None
70}
71
72#[derive(
73 Debug,
74 Clone,
75 PartialEq,
76 Eq,
77 serde::Serialize,
78 serde::Deserialize,
79 schemars::JsonSchema,
80 ts_rs::TS,
81)]
82#[serde(rename_all = "snake_case")]
83#[ts(rename_all = "snake_case")]
84pub struct CanonicalRouteSpec {
85 pub route_id: String,
95 pub from: String,
96 pub steps: Vec<CanonicalStepSpec>,
97 pub circuit_breaker: Option<CanonicalCircuitBreakerSpec>,
98 pub auto_startup: Option<bool>,
99 pub startup_order: Option<i32>,
100 pub concurrency: Option<CanonicalConcurrencySpec>,
101 pub version: u32,
102}
103
104#[derive(
105 Debug,
106 Clone,
107 PartialEq,
108 Eq,
109 serde::Serialize,
110 serde::Deserialize,
111 schemars::JsonSchema,
112 ts_rs::TS,
113)]
114#[serde(tag = "step", content = "config", rename_all = "snake_case")]
115#[ts(rename_all = "snake_case")]
116#[non_exhaustive]
117pub enum CanonicalStepSpec {
118 To {
119 uri: String,
120 },
121 Log {
122 message: String,
123 },
124 WireTap {
125 uri: String,
126 },
127 Script {
128 expression: LanguageExpressionDef,
129 },
130 Filter {
131 predicate: LanguageExpressionDef,
132 steps: Vec<CanonicalStepSpec>,
133 },
134 Choice {
135 whens: Vec<CanonicalWhenSpec>,
136 otherwise: Option<Vec<CanonicalStepSpec>>,
137 },
138 Split {
139 expression: CanonicalSplitExpressionSpec,
140 aggregation: CanonicalSplitAggregationSpec,
141 parallel: bool,
142 parallel_limit: Option<usize>,
143 trace_item_threshold: Option<usize>,
146 stop_on_exception: bool,
147 steps: Vec<CanonicalStepSpec>,
148 },
149 Aggregate(CanonicalAggregateSpec),
150 Stop,
151 Delay {
152 #[ts(type = "number")]
153 delay_ms: u64,
154 dynamic_header: Option<String>,
155 },
156 Cache {
157 repository: Option<String>,
158 key: String,
159 ttl: Option<String>,
160 max_entry_bytes: Option<usize>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
164 coalesce_misses: Option<bool>,
165 on_miss: Vec<CanonicalStepSpec>,
166 },
167 CacheInvalidate {
168 repository: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 key: Option<String>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 key_prefix: Option<String>,
175 },
176 CacheClear {
177 repository: Option<String>,
178 },
179 CacheStats {
180 repository: Option<String>,
181 },
182 CachePeekStale {
183 repository: Option<String>,
184 key: String,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 on_miss: Option<String>,
188 },
189}
190
191#[derive(
192 Debug,
193 Clone,
194 PartialEq,
195 Eq,
196 serde::Serialize,
197 serde::Deserialize,
198 schemars::JsonSchema,
199 ts_rs::TS,
200)]
201#[serde(rename_all = "snake_case")]
202#[ts(rename_all = "snake_case")]
203pub struct CanonicalWhenSpec {
204 pub predicate: LanguageExpressionDef,
205 pub steps: Vec<CanonicalStepSpec>,
206}
207
208#[derive(
209 Debug,
210 Clone,
211 PartialEq,
212 Eq,
213 serde::Serialize,
214 serde::Deserialize,
215 schemars::JsonSchema,
216 ts_rs::TS,
217)]
218#[serde(rename_all = "snake_case")]
219#[ts(rename_all = "snake_case")]
220#[non_exhaustive]
221pub enum CanonicalSplitExpressionSpec {
222 BodyLines,
223 BodyJsonArray,
224 Language(LanguageExpressionDef),
225 Stream(StreamSplitConfig),
226}
227
228#[derive(
229 Debug,
230 Clone,
231 PartialEq,
232 Eq,
233 serde::Serialize,
234 serde::Deserialize,
235 schemars::JsonSchema,
236 ts_rs::TS,
237)]
238#[serde(rename_all = "snake_case")]
239#[ts(rename_all = "snake_case")]
240#[non_exhaustive]
241pub enum CanonicalSplitAggregationSpec {
242 LastWins,
243 CollectAll,
244 Original,
245}
246
247#[derive(
248 Debug,
249 Clone,
250 PartialEq,
251 Eq,
252 serde::Serialize,
253 serde::Deserialize,
254 schemars::JsonSchema,
255 ts_rs::TS,
256)]
257#[serde(rename_all = "snake_case")]
258#[ts(rename_all = "snake_case")]
259#[non_exhaustive]
260pub enum CanonicalAggregateStrategySpec {
261 CollectAll,
262}
263
264#[derive(
265 Debug,
266 Clone,
267 PartialEq,
268 Eq,
269 serde::Serialize,
270 serde::Deserialize,
271 schemars::JsonSchema,
272 ts_rs::TS,
273)]
274#[serde(rename_all = "snake_case")]
275#[ts(rename_all = "snake_case")]
276pub struct CanonicalAggregateSpec {
277 pub header: String,
278 pub completion_size: Option<usize>,
279 #[ts(type = "number")]
280 pub completion_timeout_ms: Option<u64>,
281 pub correlation_key: Option<String>,
282 pub force_completion_on_stop: Option<bool>,
283 pub discard_on_timeout: Option<bool>,
284 pub strategy: CanonicalAggregateStrategySpec,
285 pub max_buckets: Option<usize>,
286 #[serde(default)]
289 pub max_bucket_size: Option<usize>,
290 #[ts(type = "number")]
291 pub bucket_ttl_ms: Option<u64>,
292 #[serde(default)]
296 pub completion_predicate: Option<LanguageExpressionDef>,
297}
298
299#[derive(
300 Debug,
301 Clone,
302 PartialEq,
303 Eq,
304 serde::Serialize,
305 serde::Deserialize,
306 schemars::JsonSchema,
307 ts_rs::TS,
308)]
309#[serde(rename_all = "snake_case")]
310#[ts(rename_all = "snake_case")]
311pub struct CanonicalCircuitBreakerSpec {
312 pub failure_threshold: u32,
313 #[ts(type = "number")]
314 pub open_duration_ms: u64,
315 #[serde(default, skip_serializing_if = "Vec::is_empty")]
316 pub fallback: Vec<CanonicalStepSpec>,
317}
318
319#[derive(
320 Debug,
321 Clone,
322 PartialEq,
323 Eq,
324 serde::Serialize,
325 serde::Deserialize,
326 schemars::JsonSchema,
327 ts_rs::TS,
328)]
329#[serde(tag = "mode", rename_all = "snake_case")]
330#[non_exhaustive]
331pub enum CanonicalConcurrencySpec {
332 Sequential,
333 Concurrent { max: usize },
334}
335
336impl CanonicalRouteSpec {
337 pub fn new(route_id: impl Into<String>, from: impl Into<String>) -> Self {
338 Self {
339 route_id: route_id.into(),
340 from: from.into(),
341 steps: Vec::new(),
342 circuit_breaker: None,
343 auto_startup: None,
344 startup_order: None,
345 concurrency: None,
346 version: CANONICAL_CONTRACT_VERSION,
347 }
348 }
349
350 pub fn with_auto_startup(mut self, auto: bool) -> Self {
351 self.auto_startup = Some(auto);
352 self
353 }
354
355 pub fn with_startup_order(mut self, order: i32) -> Self {
356 self.startup_order = Some(order);
357 self
358 }
359
360 pub fn with_concurrency(mut self, concurrency: CanonicalConcurrencySpec) -> Self {
361 self.concurrency = Some(concurrency);
362 self
363 }
364
365 pub fn validate_contract(&self) -> Result<(), CamelError> {
366 if self.route_id.trim().is_empty() {
367 return Err(CamelError::RouteError(
368 "canonical contract violation: route_id cannot be empty".to_string(),
369 ));
370 }
371 if self.from.trim().is_empty() {
372 return Err(CamelError::RouteError(
373 "canonical contract violation: from cannot be empty".to_string(),
374 ));
375 }
376 if self.version == 0 || self.version > CANONICAL_CONTRACT_VERSION {
377 return Err(CamelError::RouteError(format!(
378 "canonical contract violation: expected version {}, got {}",
379 CANONICAL_CONTRACT_VERSION, self.version
380 )));
381 }
382 validate_steps(&self.steps)?;
383 if let Some(cb) = &self.circuit_breaker {
384 if cb.failure_threshold == 0 {
385 return Err(CamelError::RouteError(
386 "canonical contract violation: circuit_breaker.failure_threshold must be > 0"
387 .to_string(),
388 ));
389 }
390 if cb.open_duration_ms == 0 {
391 return Err(CamelError::RouteError(
392 "canonical contract violation: circuit_breaker.open_duration_ms must be > 0"
393 .to_string(),
394 ));
395 }
396 validate_steps(&cb.fallback)?;
397 }
398 if let Some(CanonicalConcurrencySpec::Concurrent { max: 0 }) = &self.concurrency {
399 return Err(CamelError::RouteError(
400 "canonical contract violation: concurrency max must be > 0".to_string(),
401 ));
402 }
403 Ok(())
404 }
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
408pub struct CanonicalFieldLoss {
409 pub field: &'static str,
410 pub reason: String,
411 pub target_version: u32,
412}
413
414#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
415pub struct CanonicalLossReport {
416 pub dropped_fields: Vec<CanonicalFieldLoss>,
417}
418
419impl CanonicalLossReport {
420 pub fn from_field(field: &'static str, reason: &str, target_version: u32) -> Self {
421 Self {
422 dropped_fields: vec![CanonicalFieldLoss {
423 field,
424 reason: reason.to_string(),
425 target_version,
426 }],
427 }
428 }
429
430 pub fn is_empty(&self) -> bool {
431 self.dropped_fields.is_empty()
432 }
433}
434
435fn validate_steps(steps: &[CanonicalStepSpec]) -> Result<(), CamelError> {
436 for step in steps {
437 match step {
438 CanonicalStepSpec::To { uri } | CanonicalStepSpec::WireTap { uri } => {
439 if uri.trim().is_empty() {
440 return Err(CamelError::RouteError(
441 "canonical contract violation: endpoint uri cannot be empty".to_string(),
442 ));
443 }
444 }
445 CanonicalStepSpec::Filter { steps, .. } => validate_steps(steps)?,
446 CanonicalStepSpec::Choice { whens, otherwise } => {
447 for when in whens {
448 validate_steps(&when.steps)?;
449 }
450 if let Some(otherwise) = otherwise {
451 validate_steps(otherwise)?;
452 }
453 }
454 CanonicalStepSpec::Split {
455 parallel_limit,
456 steps,
457 ..
458 } => {
459 if let Some(limit) = parallel_limit
460 && *limit == 0
461 {
462 return Err(CamelError::RouteError(
463 "canonical contract violation: split.parallel_limit must be > 0"
464 .to_string(),
465 ));
466 }
467 validate_steps(steps)?;
468 }
469 CanonicalStepSpec::Aggregate(config) => {
470 let header_present = !config.header.trim().is_empty();
471 let key_present = config
472 .correlation_key
473 .as_deref()
474 .is_some_and(|k| !k.trim().is_empty());
475 if !key_present && config.correlation_key.is_some() {
476 return Err(CamelError::RouteError(
477 "canonical contract violation: aggregate.correlation_key cannot be empty"
478 .to_string(),
479 ));
480 }
481 if !header_present && !key_present {
482 return Err(CamelError::RouteError(
483 "canonical contract violation: aggregate requires a correlation source: header or correlation_key"
484 .to_string(),
485 ));
486 }
487 if let Some(size) = config.completion_size
488 && size == 0
489 {
490 return Err(CamelError::RouteError(
491 "canonical contract violation: aggregate.completion_size must be > 0"
492 .to_string(),
493 ));
494 }
495 }
496 CanonicalStepSpec::Cache { on_miss, .. } => {
497 validate_steps(on_miss)?;
498 }
499 CanonicalStepSpec::Log { .. }
500 | CanonicalStepSpec::Script { .. }
501 | CanonicalStepSpec::Stop
502 | CanonicalStepSpec::Delay { .. }
503 | CanonicalStepSpec::CacheInvalidate { .. }
504 | CanonicalStepSpec::CacheClear { .. }
505 | CanonicalStepSpec::CacheStats { .. }
506 | CanonicalStepSpec::CachePeekStale { .. } => {}
507 }
508 }
509 Ok(())
510}
511
512#[derive(Debug, Clone, PartialEq, Eq)]
513#[non_exhaustive]
514pub enum RuntimeCommand {
515 RegisterRoute {
516 spec: CanonicalRouteSpec,
517 command_id: String,
518 causation_id: Option<String>,
519 },
520 StartRoute {
521 route_id: String,
522 command_id: String,
523 causation_id: Option<String>,
524 },
525 StopRoute {
526 route_id: String,
527 command_id: String,
528 causation_id: Option<String>,
529 stop_budget: Option<std::time::Duration>,
537 },
538 SuspendRoute {
539 route_id: String,
540 command_id: String,
541 causation_id: Option<String>,
542 },
543 ResumeRoute {
544 route_id: String,
545 command_id: String,
546 causation_id: Option<String>,
547 },
548 ReloadRoute {
549 route_id: String,
550 command_id: String,
551 causation_id: Option<String>,
552 },
553 FailRoute {
557 route_id: String,
558 error: String,
559 command_id: String,
560 causation_id: Option<String>,
561 },
562 RemoveRoute {
563 route_id: String,
564 command_id: String,
565 causation_id: Option<String>,
566 },
567 ReloadTlsCerts {
568 scheme: String,
569 host: String,
570 port: u16,
571 command_id: String,
572 causation_id: Option<String>,
573 },
574 ReloadTemplates {
581 route_id: String,
582 command_id: String,
583 causation_id: Option<String>,
584 },
585}
586
587impl RuntimeCommand {
588 pub fn command_id(&self) -> &str {
589 match self {
590 RuntimeCommand::RegisterRoute { command_id, .. }
591 | RuntimeCommand::StartRoute { command_id, .. }
592 | RuntimeCommand::StopRoute { command_id, .. }
593 | RuntimeCommand::SuspendRoute { command_id, .. }
594 | RuntimeCommand::ResumeRoute { command_id, .. }
595 | RuntimeCommand::ReloadRoute { command_id, .. }
596 | RuntimeCommand::FailRoute { command_id, .. }
597 | RuntimeCommand::RemoveRoute { command_id, .. }
598 | RuntimeCommand::ReloadTlsCerts { command_id, .. }
599 | RuntimeCommand::ReloadTemplates { command_id, .. } => command_id,
600 }
601 }
602
603 pub fn causation_id(&self) -> Option<&str> {
604 match self {
605 RuntimeCommand::RegisterRoute { causation_id, .. }
606 | RuntimeCommand::StartRoute { causation_id, .. }
607 | RuntimeCommand::StopRoute { causation_id, .. }
608 | RuntimeCommand::SuspendRoute { causation_id, .. }
609 | RuntimeCommand::ResumeRoute { causation_id, .. }
610 | RuntimeCommand::ReloadRoute { causation_id, .. }
611 | RuntimeCommand::FailRoute { causation_id, .. }
612 | RuntimeCommand::RemoveRoute { causation_id, .. }
613 | RuntimeCommand::ReloadTlsCerts { causation_id, .. }
614 | RuntimeCommand::ReloadTemplates { causation_id, .. } => causation_id.as_deref(),
615 }
616 }
617}
618
619#[derive(Debug, Clone, PartialEq, Eq)]
620#[non_exhaustive]
621pub enum RuntimeCommandResult {
622 Accepted,
623 Duplicate {
624 command_id: String,
625 },
626 RouteRegistered {
627 route_id: String,
628 },
629 RouteStateChanged {
630 route_id: String,
631 status: String,
632 },
633 TlsCertsReloaded {
634 scheme: String,
635 host: String,
636 port: u16,
637 },
638 TemplatesReloaded {
639 route_id: String,
640 },
641}
642
643#[derive(Debug, Clone, PartialEq, Eq)]
644#[non_exhaustive]
645pub enum RuntimeQuery {
646 GetRouteStatus {
647 route_id: String,
648 },
649 InFlightCount {
653 route_id: String,
654 },
655 ListRoutes,
656}
657
658#[derive(Debug, Clone, PartialEq, Eq)]
659#[non_exhaustive]
660pub enum RuntimeQueryResult {
661 InFlightCount { route_id: String, count: u64 },
662 RouteNotFound { route_id: String },
663 RouteStatus { route_id: String, status: String },
664 Routes { route_ids: Vec<String> },
665}
666
667#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
668#[non_exhaustive]
669pub enum RuntimeEvent {
670 RouteRegistered { route_id: String },
671 RouteStartRequested { route_id: String },
672 RouteStarted { route_id: String },
673 RouteFailed { route_id: String, error: String },
674 RouteStopped { route_id: String },
675 RouteSuspended { route_id: String },
676 RouteResumed { route_id: String },
677 RouteReloaded { route_id: String },
678 RouteRemoved { route_id: String },
679}
680
681#[async_trait]
682pub trait RuntimeCommandBus: Send + Sync {
683 async fn execute(&self, cmd: RuntimeCommand) -> Result<RuntimeCommandResult, CamelError>;
684}
685
686#[async_trait]
687pub trait RuntimeQueryBus: Send + Sync {
688 async fn ask(&self, query: RuntimeQuery) -> Result<RuntimeQueryResult, CamelError>;
689}
690
691pub trait RuntimeHandle: RuntimeCommandBus + RuntimeQueryBus {}
692
693impl<T> RuntimeHandle for T where T: RuntimeCommandBus + RuntimeQueryBus {}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698 use async_trait::async_trait;
699 use futures::executor::block_on;
700
701 struct NoopRuntime;
702
703 #[async_trait]
704 impl RuntimeCommandBus for NoopRuntime {
705 async fn execute(&self, cmd: RuntimeCommand) -> Result<RuntimeCommandResult, CamelError> {
706 Ok(match cmd {
707 RuntimeCommand::RegisterRoute { spec, .. } => {
708 RuntimeCommandResult::RouteRegistered {
709 route_id: spec.route_id,
710 }
711 }
712 RuntimeCommand::StartRoute { route_id, .. }
713 | RuntimeCommand::StopRoute { route_id, .. }
714 | RuntimeCommand::SuspendRoute { route_id, .. }
715 | RuntimeCommand::ResumeRoute { route_id, .. }
716 | RuntimeCommand::ReloadRoute { route_id, .. }
717 | RuntimeCommand::FailRoute { route_id, .. }
718 | RuntimeCommand::RemoveRoute { route_id, .. } => {
719 RuntimeCommandResult::RouteStateChanged {
720 route_id,
721 status: "ok".to_string(),
722 }
723 }
724 RuntimeCommand::ReloadTlsCerts {
725 scheme, host, port, ..
726 } => RuntimeCommandResult::TlsCertsReloaded { scheme, host, port },
727 RuntimeCommand::ReloadTemplates { route_id, .. } => {
728 RuntimeCommandResult::TemplatesReloaded { route_id }
729 }
730 })
731 }
732 }
733
734 #[async_trait]
735 impl RuntimeQueryBus for NoopRuntime {
736 async fn ask(&self, query: RuntimeQuery) -> Result<RuntimeQueryResult, CamelError> {
737 Ok(match query {
738 RuntimeQuery::GetRouteStatus { route_id } => RuntimeQueryResult::RouteStatus {
739 route_id,
740 status: "Started".to_string(),
741 },
742 RuntimeQuery::InFlightCount { route_id } => {
743 RuntimeQueryResult::InFlightCount { route_id, count: 0 }
744 }
745 RuntimeQuery::ListRoutes => RuntimeQueryResult::Routes {
746 route_ids: vec!["r1".to_string()],
747 },
748 })
749 }
750 }
751
752 #[test]
753 fn command_and_query_ids_are_exposed() {
754 let cmd = RuntimeCommand::StartRoute {
755 route_id: "r1".into(),
756 command_id: "c1".into(),
757 causation_id: None,
758 };
759 assert_eq!(cmd.command_id(), "c1");
760 }
761
762 #[test]
763 fn canonical_spec_requires_route_id_and_from() {
764 let spec = CanonicalRouteSpec::new("r1", "timer:tick");
765 assert_eq!(spec.route_id, "r1");
766 assert_eq!(spec.from, "timer:tick");
767 assert_eq!(spec.version, CANONICAL_CONTRACT_VERSION);
768 assert!(spec.steps.is_empty());
769 assert!(spec.circuit_breaker.is_none());
770 }
771
772 #[test]
773 fn canonical_contract_rejects_invalid_version() {
774 let mut spec = CanonicalRouteSpec::new("r1", "timer:tick");
775 spec.version = 3;
776 let err = spec.validate_contract().unwrap_err().to_string();
777 assert!(err.contains("expected version"));
778 }
779
780 #[test]
781 fn canonical_contract_declares_subset_scope() {
782 assert!(canonical_contract_supports_step("to"));
783 assert!(canonical_contract_supports_step("split"));
784 assert!(!canonical_contract_supports_step("set_header"));
785 assert!(!canonical_contract_supports_step("set_property"));
786
787 assert!(CANONICAL_CONTRACT_DECLARATIVE_ONLY_STEPS.contains(&"split"));
788 assert!(CANONICAL_CONTRACT_EXCLUDED_DECLARATIVE_STEPS.contains(&"set_header"));
789 assert!(CANONICAL_CONTRACT_EXCLUDED_DECLARATIVE_STEPS.contains(&"set_property"));
790 assert!(CANONICAL_CONTRACT_RUST_ONLY_STEPS.contains(&"processor"));
791 }
792
793 #[test]
794 fn canonical_contract_rejection_reason_is_explicit() {
795 let set_header_reason = canonical_contract_rejection_reason("set_header")
796 .expect("set_header should have explicit reason");
797 assert!(set_header_reason.contains("out-of-scope"));
798
799 let set_property_reason = canonical_contract_rejection_reason("set_property")
800 .expect("set_property should have explicit reason");
801 assert!(set_property_reason.contains("out-of-scope"));
802
803 let processor_reason = canonical_contract_rejection_reason("processor")
804 .expect("processor should be rust-only");
805 assert!(processor_reason.contains("rust-only"));
806
807 let split_reason = canonical_contract_rejection_reason("split")
808 .expect("split should require declarative form");
809 assert!(split_reason.contains("declarative"));
810 }
811
812 #[test]
813 fn command_causation_id_is_exposed() {
814 let cmd = RuntimeCommand::StopRoute {
815 route_id: "r1".into(),
816 command_id: "c2".into(),
817 causation_id: Some("c1".into()),
818 stop_budget: None,
819 };
820 assert_eq!(cmd.command_id(), "c2");
821 assert_eq!(cmd.causation_id(), Some("c1"));
822 }
823
824 #[test]
825 fn stop_route_command_carries_budget() {
826 let cmd = RuntimeCommand::StopRoute {
827 route_id: "r1".into(),
828 command_id: "c2".into(),
829 causation_id: None,
830 stop_budget: Some(std::time::Duration::from_secs(10)),
831 };
832 match cmd {
833 RuntimeCommand::StopRoute { stop_budget, .. } => {
834 assert_eq!(stop_budget, Some(std::time::Duration::from_secs(10)));
835 }
836 other => panic!("expected StopRoute, got {other:?}"),
837 }
838 }
839
840 #[test]
841 fn canonical_contract_rejects_empty_route_id_and_from() {
842 let spec = CanonicalRouteSpec::new(" ", "timer:tick");
843 let err = spec.validate_contract().unwrap_err().to_string();
844 assert!(err.contains("route_id cannot be empty"));
845
846 let spec = CanonicalRouteSpec::new("r1", " ");
847 let err = spec.validate_contract().unwrap_err().to_string();
848 assert!(err.contains("from cannot be empty"));
849 }
850
851 #[test]
852 fn canonical_contract_rejects_invalid_nested_steps() {
853 let mut spec = CanonicalRouteSpec::new("r1", "timer:tick");
854 spec.steps = vec![CanonicalStepSpec::Split {
855 expression: CanonicalSplitExpressionSpec::BodyLines,
856 aggregation: CanonicalSplitAggregationSpec::CollectAll,
857 parallel: true,
858 parallel_limit: Some(0),
859 trace_item_threshold: None,
860 stop_on_exception: false,
861 steps: vec![CanonicalStepSpec::To {
862 uri: "log:ok".to_string(),
863 }],
864 }];
865 let err = spec.validate_contract().unwrap_err().to_string();
866 assert!(err.contains("split.parallel_limit must be > 0"));
867
868 spec.steps = vec![CanonicalStepSpec::To {
869 uri: " ".to_string(),
870 }];
871 let err = spec.validate_contract().unwrap_err().to_string();
872 assert!(err.contains("endpoint uri cannot be empty"));
873 }
874
875 #[test]
876 fn canonical_contract_rejects_invalid_aggregate_and_circuit_breaker() {
877 let mut spec = CanonicalRouteSpec::new("r1", "timer:tick");
878 spec.steps = vec![CanonicalStepSpec::Aggregate(CanonicalAggregateSpec {
879 header: " ".to_string(),
880 completion_size: Some(1),
881 completion_timeout_ms: None,
882 correlation_key: None,
883 force_completion_on_stop: None,
884 discard_on_timeout: None,
885 strategy: CanonicalAggregateStrategySpec::CollectAll,
886 max_buckets: None,
887 max_bucket_size: None,
888 bucket_ttl_ms: None,
889 completion_predicate: None,
890 })];
891 let err = spec.validate_contract().unwrap_err().to_string();
892 assert!(err.contains("correlation source"));
893
894 spec.steps = vec![CanonicalStepSpec::Aggregate(CanonicalAggregateSpec {
895 header: "k".to_string(),
896 completion_size: Some(0),
897 completion_timeout_ms: None,
898 correlation_key: None,
899 force_completion_on_stop: None,
900 discard_on_timeout: None,
901 strategy: CanonicalAggregateStrategySpec::CollectAll,
902 max_buckets: None,
903 max_bucket_size: None,
904 bucket_ttl_ms: None,
905 completion_predicate: None,
906 })];
907 let err = spec.validate_contract().unwrap_err().to_string();
908 assert!(err.contains("aggregate.completion_size must be > 0"));
909
910 spec.steps = vec![];
911 spec.circuit_breaker = Some(CanonicalCircuitBreakerSpec {
912 failure_threshold: 0,
913 open_duration_ms: 10,
914 fallback: vec![],
915 });
916 let err = spec.validate_contract().unwrap_err().to_string();
917 assert!(err.contains("failure_threshold must be > 0"));
918
919 spec.circuit_breaker = Some(CanonicalCircuitBreakerSpec {
920 failure_threshold: 1,
921 open_duration_ms: 0,
922 fallback: vec![],
923 });
924 let err = spec.validate_contract().unwrap_err().to_string();
925 assert!(err.contains("open_duration_ms must be > 0"));
926 }
927
928 #[test]
929 fn contract_accepts_expression_only_aggregate() {
930 let mut spec = CanonicalRouteSpec::new("r1", "timer:tick");
931 spec.steps = vec![CanonicalStepSpec::Aggregate(CanonicalAggregateSpec {
932 header: String::new(),
933 completion_size: None,
934 completion_timeout_ms: None,
935 correlation_key: Some("${header.orderId}".to_string()),
936 force_completion_on_stop: None,
937 discard_on_timeout: None,
938 strategy: CanonicalAggregateStrategySpec::CollectAll,
939 max_buckets: None,
940 max_bucket_size: None,
941 bucket_ttl_ms: None,
942 completion_predicate: None,
943 })];
944 assert!(spec.validate_contract().is_ok());
945 }
946
947 #[test]
948 fn contract_rejects_aggregate_missing_both_sources() {
949 let mut spec = CanonicalRouteSpec::new("r1", "timer:tick");
950 spec.steps = vec![CanonicalStepSpec::Aggregate(CanonicalAggregateSpec {
951 header: String::new(),
952 completion_size: None,
953 completion_timeout_ms: None,
954 correlation_key: None,
955 force_completion_on_stop: None,
956 discard_on_timeout: None,
957 strategy: CanonicalAggregateStrategySpec::CollectAll,
958 max_buckets: None,
959 max_bucket_size: None,
960 bucket_ttl_ms: None,
961 completion_predicate: None,
962 })];
963 let err = spec.validate_contract().unwrap_err().to_string();
964 assert!(err.contains("correlation source"));
965 }
966
967 #[test]
968 fn contract_rejects_empty_correlation_key() {
969 let mut spec = CanonicalRouteSpec::new("r1", "timer:tick");
970 spec.steps = vec![CanonicalStepSpec::Aggregate(CanonicalAggregateSpec {
971 header: "region".to_string(),
972 completion_size: None,
973 completion_timeout_ms: None,
974 correlation_key: Some(String::new()),
975 force_completion_on_stop: None,
976 discard_on_timeout: None,
977 strategy: CanonicalAggregateStrategySpec::CollectAll,
978 max_buckets: None,
979 max_bucket_size: None,
980 bucket_ttl_ms: None,
981 completion_predicate: None,
982 })];
983 let err = spec.validate_contract().unwrap_err().to_string();
984 assert!(err.contains("correlation_key cannot be empty"));
985 }
986
987 #[test]
988 fn canonical_contract_rejects_invalid_fallback_step() {
989 let mut spec = CanonicalRouteSpec::new("r1", "timer:tick");
990 spec.circuit_breaker = Some(CanonicalCircuitBreakerSpec {
991 failure_threshold: 1,
992 open_duration_ms: 10,
993 fallback: vec![CanonicalStepSpec::To {
994 uri: " ".to_string(),
995 }],
996 });
997 let err = spec.validate_contract().unwrap_err().to_string();
998 assert!(err.contains("endpoint uri cannot be empty"));
999 }
1000
1001 #[test]
1002 fn canonical_contract_rejection_reason_none_for_regular_steps() {
1003 assert!(canonical_contract_rejection_reason("to").is_none());
1004 assert!(canonical_contract_rejection_reason("unknown-step").is_none());
1005 }
1006
1007 #[test]
1008 fn command_helpers_cover_all_variants() {
1009 let spec = CanonicalRouteSpec::new("r1", "timer:tick");
1010 let cmds = [
1011 RuntimeCommand::RegisterRoute {
1012 spec,
1013 command_id: "c1".into(),
1014 causation_id: Some("root".into()),
1015 },
1016 RuntimeCommand::StartRoute {
1017 route_id: "r1".into(),
1018 command_id: "c2".into(),
1019 causation_id: None,
1020 },
1021 RuntimeCommand::StopRoute {
1022 route_id: "r1".into(),
1023 command_id: "c3".into(),
1024 causation_id: None,
1025 stop_budget: None,
1026 },
1027 RuntimeCommand::SuspendRoute {
1028 route_id: "r1".into(),
1029 command_id: "c4".into(),
1030 causation_id: None,
1031 },
1032 RuntimeCommand::ResumeRoute {
1033 route_id: "r1".into(),
1034 command_id: "c5".into(),
1035 causation_id: None,
1036 },
1037 RuntimeCommand::ReloadRoute {
1038 route_id: "r1".into(),
1039 command_id: "c6".into(),
1040 causation_id: None,
1041 },
1042 RuntimeCommand::FailRoute {
1043 route_id: "r1".into(),
1044 error: "boom".into(),
1045 command_id: "c7".into(),
1046 causation_id: None,
1047 },
1048 RuntimeCommand::RemoveRoute {
1049 route_id: "r1".into(),
1050 command_id: "c8".into(),
1051 causation_id: None,
1052 },
1053 RuntimeCommand::ReloadTlsCerts {
1054 scheme: "https".into(),
1055 host: "example.com".into(),
1056 port: 8443,
1057 command_id: "c9".into(),
1058 causation_id: None,
1059 },
1060 ];
1061
1062 let ids: Vec<&str> = cmds.iter().map(RuntimeCommand::command_id).collect();
1063 assert_eq!(
1064 ids,
1065 vec!["c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8", "c9"]
1066 );
1067 assert_eq!(cmds[0].causation_id(), Some("root"));
1068 assert_eq!(cmds[1].causation_id(), None);
1069 }
1070
1071 #[test]
1072 fn canonical_route_spec_serde_roundtrip() {
1073 let mut spec = CanonicalRouteSpec::new("test-route", "timer:tick?period=1000");
1074 spec.steps.push(CanonicalStepSpec::Log {
1075 message: "Hello".into(),
1076 });
1077 spec.steps.push(CanonicalStepSpec::To {
1078 uri: "log:info".into(),
1079 });
1080 spec.steps.push(CanonicalStepSpec::Stop);
1081
1082 let json = serde_json::to_string(&spec).unwrap();
1083 let deserialized: CanonicalRouteSpec = serde_json::from_str(&json).unwrap();
1084 assert_eq!(spec, deserialized);
1085 }
1086
1087 #[test]
1088 fn canonical_step_spec_serde_variants() {
1089 let steps = vec![
1090 CanonicalStepSpec::To {
1091 uri: "direct:a".into(),
1092 },
1093 CanonicalStepSpec::Log {
1094 message: "msg".into(),
1095 },
1096 CanonicalStepSpec::WireTap {
1097 uri: "direct:audit".into(),
1098 },
1099 CanonicalStepSpec::Stop,
1100 CanonicalStepSpec::Delay {
1101 delay_ms: 100,
1102 dynamic_header: None,
1103 },
1104 ];
1105 let json = serde_json::to_string_pretty(&steps).unwrap();
1106 let back: Vec<CanonicalStepSpec> = serde_json::from_str(&json).unwrap();
1107 assert_eq!(steps, back);
1108 }
1109
1110 #[test]
1111 fn canonical_cache_peek_stale_on_miss_round_trip() {
1112 let some = CanonicalStepSpec::CachePeekStale {
1113 repository: None,
1114 key: "k".into(),
1115 on_miss: Some("continue".into()),
1116 };
1117 let json = serde_json::to_string(&some).unwrap();
1118 assert!(json.contains("continue"));
1119 let back: CanonicalStepSpec = serde_json::from_str(&json).unwrap();
1120 assert_eq!(some, back);
1121
1122 let none = CanonicalStepSpec::CachePeekStale {
1123 repository: None,
1124 key: "k".into(),
1125 on_miss: None,
1126 };
1127 let json = serde_json::to_string(&none).unwrap();
1128 assert!(
1129 !json.contains("on_miss"),
1130 "on_miss: None must omit the key from JSON, got: {json}"
1131 );
1132 let back: CanonicalStepSpec = serde_json::from_str(&json).unwrap();
1133 assert_eq!(none, back);
1134 }
1135
1136 #[test]
1137 fn canonical_cache_invalidate_prefix_round_trip() {
1138 let prefixed = CanonicalStepSpec::CacheInvalidate {
1139 repository: Some("persistent".into()),
1140 key: None,
1141 key_prefix: Some("ns:".into()),
1142 };
1143 let json = serde_json::to_string(&prefixed).unwrap();
1144 assert!(json.contains("key_prefix"), "must emit key_prefix: {json}");
1145 let back: CanonicalStepSpec = serde_json::from_str(&json).unwrap();
1146 assert_eq!(prefixed, back);
1147
1148 let legacy = r#"{"step":"cache_invalidate","config":{"repository":null,"key":"k"}}"#;
1151 let parsed: CanonicalStepSpec = serde_json::from_str(legacy).unwrap();
1152 assert_eq!(
1153 parsed,
1154 CanonicalStepSpec::CacheInvalidate {
1155 repository: None,
1156 key: Some("k".into()),
1157 key_prefix: None,
1158 }
1159 );
1160 }
1161
1162 #[test]
1163 fn canonical_cache_clear_stats_round_trip() {
1164 let clear = CanonicalStepSpec::CacheClear {
1165 repository: Some("persistent".into()),
1166 };
1167 let json = serde_json::to_string(&clear).unwrap();
1168 assert!(json.contains("cache_clear"));
1169 let back: CanonicalStepSpec = serde_json::from_str(&json).unwrap();
1170 assert_eq!(clear, back);
1171
1172 let stats = CanonicalStepSpec::CacheStats { repository: None };
1173 let json = serde_json::to_string(&stats).unwrap();
1174 assert!(json.contains("cache_stats"));
1175 let back: CanonicalStepSpec = serde_json::from_str(&json).unwrap();
1176 assert_eq!(stats, back);
1177 }
1178
1179 #[test]
1180 fn canonical_circuit_breaker_fallback_roundtrip() {
1181 let mut spec = CanonicalRouteSpec::new("cb-fallback", "direct:start");
1182 spec.circuit_breaker = Some(CanonicalCircuitBreakerSpec {
1183 failure_threshold: 1,
1184 open_duration_ms: 60000,
1185 fallback: vec![CanonicalStepSpec::CachePeekStale {
1186 repository: Some("persistent".into()),
1187 key: "tile-xyz".into(),
1188 on_miss: None,
1189 }],
1190 });
1191
1192 let json = serde_json::to_string(&spec).unwrap();
1193 let back: CanonicalRouteSpec = serde_json::from_str(&json).unwrap();
1194 assert_eq!(spec, back);
1195 assert_eq!(back.circuit_breaker.as_ref().unwrap().fallback.len(), 1);
1196
1197 let without = CanonicalCircuitBreakerSpec {
1199 failure_threshold: 1,
1200 open_duration_ms: 60000,
1201 fallback: vec![],
1202 };
1203 let json = serde_json::to_string(&without).unwrap();
1204 assert!(
1205 !json.contains("fallback"),
1206 "empty fallback must omit the key from JSON, got: {json}"
1207 );
1208 let back: CanonicalCircuitBreakerSpec = serde_json::from_str(&json).unwrap();
1209 assert!(back.fallback.is_empty());
1210 }
1211
1212 #[test]
1213 fn canonical_route_spec_json_schema_generates() {
1214 let schema = schemars::schema_for!(CanonicalRouteSpec);
1215 let json = serde_json::to_string(&schema).unwrap();
1216 assert!(json.contains("CanonicalRouteSpec"));
1217 assert!(json.contains("route_id"));
1218 }
1219
1220 #[test]
1221 fn canonical_json_schema_has_no_function_step() {
1222 let schema = schemars::schema_for!(CanonicalRouteSpec);
1223 let json = serde_json::to_string(&schema).unwrap();
1224 assert!(
1225 !json.contains("\"function\""),
1226 "canonical JSON schema must not contain 'function' step"
1227 );
1228 }
1229
1230 #[test]
1231 fn canonical_contract_does_not_support_function() {
1232 assert!(
1233 !canonical_contract_supports_step("function"),
1234 "function must not be in CANONICAL_CONTRACT_SUPPORTED_STEPS"
1235 );
1236 }
1237
1238 #[test]
1239 fn runtime_command_result_all_variants_are_distinct() {
1240 let accepted = RuntimeCommandResult::Accepted;
1241 let dup = RuntimeCommandResult::Duplicate {
1242 command_id: "c1".into(),
1243 };
1244 let registered = RuntimeCommandResult::RouteRegistered {
1245 route_id: "r1".into(),
1246 };
1247 let changed = RuntimeCommandResult::RouteStateChanged {
1248 route_id: "r1".into(),
1249 status: "Started".into(),
1250 };
1251
1252 assert_ne!(accepted, dup);
1253 assert_ne!(dup, registered);
1254 assert_ne!(registered, changed);
1255
1256 let dup2 = RuntimeCommandResult::Duplicate {
1257 command_id: "c1".into(),
1258 };
1259 assert_eq!(dup, dup2);
1260 }
1261
1262 #[test]
1263 fn runtime_event_serialization_round_trip() {
1264 let event = RuntimeEvent::RouteFailed {
1265 route_id: "route-a".to_string(),
1266 error: "boom".to_string(),
1267 };
1268 let json = serde_json::to_string(&event).unwrap();
1269 let back: RuntimeEvent = serde_json::from_str(&json).unwrap();
1270 assert_eq!(event, back);
1271 }
1272
1273 #[test]
1274 fn noop_runtime_execute_and_ask_return_expected_shapes() {
1275 let rt = NoopRuntime;
1276 let cmd = RuntimeCommand::RegisterRoute {
1277 spec: CanonicalRouteSpec::new("r2", "timer:tick"),
1278 command_id: "c1".into(),
1279 causation_id: None,
1280 };
1281 let cmd_result = block_on(rt.execute(cmd)).unwrap();
1282 assert_eq!(
1283 cmd_result,
1284 RuntimeCommandResult::RouteRegistered {
1285 route_id: "r2".into()
1286 }
1287 );
1288
1289 let query_result = block_on(rt.ask(RuntimeQuery::GetRouteStatus {
1290 route_id: "r2".into(),
1291 }))
1292 .unwrap();
1293 assert_eq!(
1294 query_result,
1295 RuntimeQueryResult::RouteStatus {
1296 route_id: "r2".into(),
1297 status: "Started".into()
1298 }
1299 );
1300 }
1301
1302 #[test]
1303 fn canonical_contract_name_and_version_constants_match() {
1304 assert_eq!(CANONICAL_CONTRACT_NAME, "canonical-v1");
1305 assert_eq!(CANONICAL_CONTRACT_VERSION, 2);
1306 }
1307
1308 #[test]
1309 fn canonical_concurrency_spec_rejects_zero_max() {
1310 let spec = CanonicalRouteSpec::new("r1", "timer:tick")
1311 .with_concurrency(CanonicalConcurrencySpec::Concurrent { max: 0 });
1312 let err = spec.validate_contract().unwrap_err().to_string();
1313 assert!(err.contains("concurrency max must be > 0"), "{err}");
1314 }
1315
1316 #[test]
1317 fn canonical_v2_round_trip() {
1318 let spec = CanonicalRouteSpec::new("r1", "timer:tick")
1319 .with_auto_startup(false)
1320 .with_startup_order(42)
1321 .with_concurrency(CanonicalConcurrencySpec::Concurrent { max: 8 });
1322 spec.validate_contract().unwrap();
1323 }
1324
1325 #[test]
1326 fn canonical_v2_version_is_2() {
1327 assert_eq!(CANONICAL_CONTRACT_VERSION, 2);
1328 }
1329
1330 #[test]
1331 fn canonical_loss_report_builder() {
1332 let report =
1333 CanonicalLossReport::from_field("error_handler", "not supported by canonical path", 2);
1334 assert_eq!(report.dropped_fields.len(), 1);
1335 assert_eq!(report.dropped_fields[0].field, "error_handler");
1336 }
1337
1338 #[test]
1339 fn canonical_v1_json_deserializes_in_v2() {
1340 let json = r#"{"route_id":"r1","from":"timer:tick","steps":[],"version":1}"#;
1341 let spec: CanonicalRouteSpec = serde_json::from_str(json).unwrap();
1342 assert_eq!(spec.route_id, "r1");
1343 assert!(spec.auto_startup.is_none());
1344 assert!(spec.startup_order.is_none());
1345 assert!(spec.concurrency.is_none());
1346 spec.validate_contract().unwrap();
1348 }
1349}