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