1mod tokio_impl;
14
15pub use tokio_impl::TokioCronService;
16
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, Ordering};
19
20use tracing::debug;
21
22use camel_component_api::{
23 BoxProcessor, CamelError, Component, ComponentContext, ComponentMetadata, Consumer,
24 ConsumerContext, CronCallback, CronFire, CronSchedule, CronService, Endpoint, ProducerContext,
25 RuntimeObservability, UriConfig,
26};
27use camel_component_api::{Exchange, Message};
28use chrono_tz::Tz;
29
30#[derive(Debug, Clone, UriConfig)]
38#[uri_scheme = "cron"]
39#[uri_config(
40 skip_impl,
41 metadata(
42 scheme = "cron",
43 description = "Schedule route execution via cron expressions",
44 consumer
45 ),
46 crate = "camel_component_api"
47)]
48pub struct CronConfig {
49 pub name: String,
51
52 #[uri_param(name = "schedule")]
54 pub schedule: String,
55
56 #[uri_param(name = "timeZone", default = "UTC")]
58 time_zone_str: String,
59
60 #[uri_param(name = "includeMetadata", default = "true")]
62 include_metadata: bool,
63}
64
65impl CronConfig {
66 pub fn time_zone(&self) -> Result<Tz, CamelError> {
68 self.time_zone_str.parse::<Tz>().map_err(|e| {
69 CamelError::EndpointCreationFailed(format!(
70 "invalid timeZone '{}': {}",
71 self.time_zone_str, e
72 ))
73 })
74 }
75
76 pub fn include_metadata(&self) -> bool {
78 self.include_metadata
79 }
80
81 fn normalized_cron_expression(&self) -> String {
86 format!("0 {}", self.schedule)
87 }
88
89 pub fn validate(&self) -> Result<(), CamelError> {
91 if self.name.is_empty() {
92 return Err(CamelError::EndpointCreationFailed(
93 "cron name must not be empty".to_string(),
94 ));
95 }
96 if self.schedule.is_empty() {
97 return Err(CamelError::EndpointCreationFailed(
98 "cron schedule must not be empty".to_string(),
99 ));
100 }
101 let field_count = self.schedule.split_whitespace().count();
103 if field_count != 5 {
104 return Err(CamelError::EndpointCreationFailed(format!(
105 "cron schedule must be 5-field Unix format (min hour dom month dow), got {} field(s): '{}'",
106 field_count, self.schedule
107 )));
108 }
109 self.normalized_cron_expression()
112 .parse::<cron::Schedule>()
113 .map_err(|e| {
114 CamelError::EndpointCreationFailed(format!(
115 "invalid cron expression '{}': {}",
116 self.schedule, e
117 ))
118 })?;
119 self.time_zone()?;
121 Ok(())
122 }
123}
124
125impl UriConfig for CronConfig {
126 fn scheme() -> &'static str {
127 "cron"
128 }
129
130 fn from_uri(uri: &str) -> Result<Self, CamelError> {
131 let parts = camel_component_api::parse_uri(uri)?;
132 Self::from_components(parts)
133 }
134
135 fn from_components(parts: camel_component_api::UriComponents) -> Result<Self, CamelError> {
136 let mut config = Self::parse_uri_components(parts)?;
137 config.schedule = config.schedule.replace('+', " ");
143 CronConfig::validate(&config)?;
144 Ok(config)
145 }
146
147 fn validate(self) -> Result<Self, CamelError> {
148 CronConfig::validate(&self)?;
149 Ok(self)
150 }
151}
152
153pub struct CronEndpoint {
159 uri: String,
160 config: CronConfig,
161 service: Arc<dyn CronService>,
162}
163
164impl CronEndpoint {
165 pub fn new(uri: String, config: CronConfig, service: Arc<dyn CronService>) -> Self {
166 Self {
167 uri,
168 config,
169 service,
170 }
171 }
172}
173
174impl Endpoint for CronEndpoint {
175 fn uri(&self) -> &str {
176 &self.uri
177 }
178
179 fn create_consumer(
180 &self,
181 _rt: Arc<dyn RuntimeObservability>,
182 ) -> Result<Box<dyn Consumer>, CamelError> {
183 Ok(Box::new(CronConsumer::new(
184 self.config.clone(),
185 self.service.clone(),
186 )))
187 }
188
189 fn create_producer(
190 &self,
191 _rt: Arc<dyn RuntimeObservability>,
192 _ctx: &ProducerContext,
193 ) -> Result<BoxProcessor, CamelError> {
194 Err(CamelError::EndpointCreationFailed(
195 "cron: component is consumer-only".to_string(),
196 ))
197 }
198}
199
200pub struct CronConsumer {
206 config: CronConfig,
207 service: Arc<dyn CronService>,
208 started: AtomicBool,
209}
210
211impl CronConsumer {
212 pub fn new(config: CronConfig, service: Arc<dyn CronService>) -> Self {
214 Self {
215 config,
216 service,
217 started: AtomicBool::new(false),
218 }
219 }
220
221 #[cfg(test)]
223 pub(crate) fn mark_started_for_test(&self) {
224 self.started.store(true, Ordering::SeqCst);
225 }
226}
227
228#[async_trait::async_trait]
229impl Consumer for CronConsumer {
230 async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
231 self.started
232 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
233 .map_err(|_| {
234 CamelError::EndpointCreationFailed("cron consumer already started".to_string())
235 })?;
236
237 CronConfig::validate(&self.config)?;
238 let config = self.config.clone();
239
240 let schedule = CronSchedule {
241 expression: config.normalized_cron_expression(),
242 time_zone: config.time_zone()?,
243 trigger_id: config.name.clone(),
244 route_id: Some(context.route_id().to_string()),
245 };
246
247 let include_metadata = config.include_metadata();
248 let trigger_name = config.name.clone();
249 let trigger_schedule = config.schedule.clone();
250 let time_zone_str = config.time_zone_str.clone();
251
252 let ctx = context.clone();
254 let callback: CronCallback = Arc::new(move |fire: CronFire| {
255 let ctx = ctx.clone();
256 let name = trigger_name.clone();
257 let sched = trigger_schedule.clone();
258 let tz = time_zone_str.clone();
259 Box::pin(async move {
260 let mut exchange = Exchange::new(Message::default());
261 if include_metadata {
262 exchange.input.set_header("CamelCronName", name);
263 exchange.input.set_header("CamelCronSchedule", sched);
264 exchange.input.set_header("CamelCronTimezone", tz);
265 exchange
266 .input
267 .set_header("CamelCronScheduledTime", fire.scheduled_at.to_rfc3339());
268 exchange
269 .input
270 .set_header("CamelCronFiredTime", fire.fired_at.to_rfc3339());
271 exchange
272 .input
273 .set_header("CamelCronCounter", fire.counter.to_string());
274 }
275 ctx.send(exchange).await
276 })
277 });
278
279 let cancel_token = context.cancel_token();
280 let trigger_id = schedule.trigger_id.clone();
281
282 let result = self.service.run(&schedule, callback, cancel_token).await;
284
285 self.started.store(false, Ordering::SeqCst);
286
287 debug!(trigger = trigger_id, "cron consumer stopped");
288 result
289 }
290
291 async fn stop(&mut self) -> Result<(), CamelError> {
292 Ok(())
295 }
296}
297
298pub struct CronComponent {
307 service: Arc<dyn CronService>,
308}
309
310impl Default for CronComponent {
311 fn default() -> Self {
312 Self::new()
313 }
314}
315
316impl CronComponent {
317 pub fn new() -> Self {
319 Self {
320 service: Arc::new(TokioCronService),
321 }
322 }
323
324 pub fn with_service(service: Arc<dyn CronService>) -> Self {
326 Self { service }
327 }
328}
329
330impl Component for CronComponent {
331 fn scheme(&self) -> &str {
332 "cron"
333 }
334
335 fn metadata(&self) -> ComponentMetadata {
336 CronConfig::metadata()
337 }
338
339 fn create_endpoint(
340 &self,
341 uri: &str,
342 _ctx: &dyn ComponentContext,
343 ) -> Result<Box<dyn Endpoint>, CamelError> {
344 let config = CronConfig::from_uri(uri)?;
345 Ok(Box::new(CronEndpoint::new(
346 uri.to_string(),
347 config,
348 self.service.clone(),
349 )))
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn test_cron_config_basic() {
359 let config = CronConfig::from_uri("cron:nightly?schedule=0%202%20*%20*%20*").unwrap();
360 assert_eq!(config.name, "nightly");
361 assert_eq!(config.schedule, "0 2 * * *");
362 assert_eq!(config.time_zone_str, "UTC");
363 assert!(config.include_metadata);
364 }
365
366 #[test]
367 fn test_cron_config_with_timezone() {
368 let config =
369 CronConfig::from_uri("cron:morning?schedule=0%209%20*%20*%201&timeZone=America/Lima")
370 .unwrap();
371 assert_eq!(config.name, "morning");
372 assert_eq!(config.time_zone().unwrap(), chrono_tz::Tz::America__Lima);
373 }
374
375 #[test]
376 fn test_cron_config_percent_encoded_spaces() {
377 let config = CronConfig::from_uri("cron:test?schedule=0%202%20*%20*%20*").unwrap();
379 assert_eq!(config.schedule, "0 2 * * *");
380 config.validate().unwrap();
381 }
382
383 #[test]
384 fn test_cron_config_plus_as_space() {
385 let config = CronConfig::from_uri("cron:test?schedule=0+2+*+*+*").unwrap();
387 assert_eq!(config.schedule, "0 2 * * *");
388 config.validate().unwrap();
389 }
390
391 #[test]
392 fn test_rejects_empty_name() {
393 let err = CronConfig::from_uri("cron:?schedule=0%202%20*%20*%20*").unwrap_err();
394 assert!(
395 err.to_string().contains("must not be empty"),
396 "unexpected error: {err}"
397 );
398 }
399
400 #[test]
401 fn test_rejects_missing_schedule() {
402 let result = CronConfig::from_uri("cron:test");
403 assert!(result.is_err() || result.unwrap().validate().is_err());
405 }
406
407 #[test]
408 fn test_rejects_six_field_cron() {
409 let err = CronConfig::from_uri("cron:test?schedule=0%200%202%20*%20*%20*").unwrap_err();
410 assert!(err.to_string().contains("5-field"));
411 }
412
413 #[test]
414 fn test_rejects_invalid_cron() {
415 let err = CronConfig::from_uri("cron:test?schedule=99%2099%2099%2099%2099").unwrap_err();
416 assert!(
417 err.to_string().contains("invalid cron"),
418 "unexpected error: {err}"
419 );
420 }
421
422 #[test]
423 fn test_rejects_invalid_timezone() {
424 let err = CronConfig::from_uri("cron:test?schedule=0%202%20*%20*%20*&timeZone=NotAZone")
425 .unwrap_err();
426 assert!(
427 err.to_string().contains("invalid timeZone"),
428 "unexpected error: {err}"
429 );
430 }
431
432 #[test]
433 fn test_valid_expressions() {
434 for expr in &[
439 "0%202%20*%20*%20*",
440 "*%2F5%20*%20*%20*%20*",
441 "0%209%20*%20*%201",
442 ] {
443 let config = CronConfig::from_uri(&format!("cron:t?schedule={}", expr)).unwrap();
444 config
445 .validate()
446 .unwrap_or_else(|e| panic!("'{}' should be valid: {}", expr, e));
447 }
448 }
449
450 struct FastCronService;
453 #[async_trait::async_trait]
454 impl CronService for FastCronService {
455 async fn run(
456 &self,
457 _schedule: &CronSchedule,
458 callback: CronCallback,
459 cancel: tokio_util::sync::CancellationToken,
460 ) -> Result<(), CamelError> {
461 let mut counter = 0u64;
462 loop {
463 tokio::select! {
464 _ = cancel.cancelled() => return Ok(()),
465 _ = tokio::time::sleep(tokio::time::Duration::from_millis(10)) => {
466 counter += 1;
467 let now = chrono::Utc::now();
468 let fire = CronFire {
469 scheduled_at: now,
470 fired_at: now,
471 counter,
472 };
473 callback(fire).await?;
474 }
475 }
476 }
477 }
478 }
479
480 #[tokio::test]
483 async fn test_double_start_returns_error() {
484 let config = CronConfig::from_uri("cron:test?schedule=0 2 * * *").unwrap();
485 let mut consumer = CronConsumer::new(config, Arc::new(FastCronService));
486 consumer.mark_started_for_test();
487
488 let (tx, _rx) = tokio::sync::mpsc::channel(16);
489 let cancel = tokio_util::sync::CancellationToken::new();
490 let ctx = ConsumerContext::new(tx, cancel, "test-route".to_string());
491 let result = consumer.start(ctx).await;
492 assert!(result.is_err(), "double-start should error");
493 }
494
495 #[tokio::test]
496 async fn test_consumer_fires_exchange() {
497 let config = CronConfig::from_uri("cron:test?schedule=* * * * *").unwrap();
498 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
499 let cancel = tokio_util::sync::CancellationToken::new();
500 let ctx = ConsumerContext::new(tx, cancel.clone(), "test-route".to_string());
501 let mut consumer = CronConsumer::new(config, Arc::new(FastCronService));
502
503 let cancel2 = cancel.clone();
504 let handle = tokio::spawn(async move { consumer.start(ctx).await });
505
506 let exchange = tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv()).await;
508 assert!(exchange.is_ok(), "should have received an exchange");
509
510 cancel2.cancel();
511 let _ = handle.await;
512 }
513
514 #[tokio::test]
515 async fn test_consumer_metadata_headers() {
516 let config = CronConfig::from_uri("cron:test?schedule=* * * * *").unwrap();
517 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
518 let cancel = tokio_util::sync::CancellationToken::new();
519 let ctx = ConsumerContext::new(tx, cancel.clone(), "test-route".to_string());
520 let mut consumer = CronConsumer::new(config, Arc::new(FastCronService));
521
522 let cancel2 = cancel.clone();
523 tokio::spawn(async move { consumer.start(ctx).await });
524
525 let envelope = tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv())
526 .await
527 .unwrap()
528 .unwrap();
529
530 let headers = &envelope.exchange.input.headers;
531 assert!(headers.contains_key("CamelCronName"));
532 assert!(headers.contains_key("CamelCronSchedule"));
533 assert!(headers.contains_key("CamelCronTimezone"));
534 assert!(headers.contains_key("CamelCronScheduledTime"));
535 assert!(headers.contains_key("CamelCronFiredTime"));
536 assert!(headers.contains_key("CamelCronCounter"));
537
538 cancel2.cancel();
539 }
540
541 #[tokio::test]
542 async fn test_include_metadata_false_omits_headers() {
543 let config =
544 CronConfig::from_uri("cron:test?schedule=* * * * *&includeMetadata=false").unwrap();
545 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
546 let cancel = tokio_util::sync::CancellationToken::new();
547 let ctx = ConsumerContext::new(tx, cancel.clone(), "test-route".to_string());
548 let mut consumer = CronConsumer::new(config, Arc::new(FastCronService));
549
550 let cancel2 = cancel.clone();
551 tokio::spawn(async move { consumer.start(ctx).await });
552
553 let envelope = tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv())
554 .await
555 .unwrap()
556 .unwrap();
557
558 assert!(
559 envelope.exchange.input.headers.is_empty(),
560 "no headers when includeMetadata=false"
561 );
562
563 cancel2.cancel();
564 }
565
566 #[tokio::test]
567 async fn test_callback_error_propagates_to_start() {
568 let config = CronConfig::from_uri("cron:test?schedule=* * * * *").unwrap();
570 let (tx, rx) = tokio::sync::mpsc::channel(16);
571 drop(rx);
573 let cancel = tokio_util::sync::CancellationToken::new();
574 let ctx = ConsumerContext::new(tx, cancel, "test-route".to_string());
575 let mut consumer = CronConsumer::new(config, Arc::new(FastCronService));
576
577 let result =
578 tokio::time::timeout(tokio::time::Duration::from_secs(2), consumer.start(ctx)).await;
579
580 let inner = result.expect("should not timeout");
582 assert!(
583 inner.is_err(),
584 "closed channel should propagate error to start()"
585 );
586 }
587
588 #[test]
591 fn test_cron_component_scheme() {
592 let comp = CronComponent::new();
593 assert_eq!(comp.scheme(), "cron");
594 }
595
596 #[test]
597 fn test_cron_component_creates_endpoint() {
598 let comp = CronComponent::new();
599 let ctx = camel_component_api::NoOpComponentContext;
600 let endpoint = comp
601 .create_endpoint("cron:nightly?schedule=0 2 * * *", &ctx)
602 .unwrap();
603 assert_eq!(endpoint.uri(), "cron:nightly?schedule=0 2 * * *");
604 }
605
606 #[test]
607 fn test_cron_component_rejects_invalid_schedule() {
608 let comp = CronComponent::new();
609 let ctx = camel_component_api::NoOpComponentContext;
610 let result = comp.create_endpoint("cron:test?schedule=invalid", &ctx);
611 assert!(result.is_err());
612 }
613
614 #[test]
615 fn test_cron_component_with_custom_service() {
616 let custom_service: Arc<dyn CronService> = Arc::new(TokioCronService);
617 let comp = CronComponent::with_service(custom_service);
618 assert_eq!(comp.scheme(), "cron");
619 }
620}