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