1use std::future::Future;
13use std::pin::Pin;
14use std::sync::Arc;
15
16use axum::Router;
17use axum::body::Bytes;
18use axum::extract::{DefaultBodyLimit, State};
19use axum::http::{HeaderMap, StatusCode, header};
20use axum::middleware;
21use axum::routing::{get, post};
22use serde_json::{Value, from_slice};
23use tokio::sync::{Mutex, Semaphore};
24use tokio::task::JoinSet;
25use tokio_cron_scheduler::{Job, JobScheduler};
26use tracing::{error, info, warn};
27
28use tokio_util::sync::CancellationToken;
29
30use crate::cron::CronJob;
31use crate::error::RuntimeError;
32use crate::trigger::{Trigger, TriggerEvent, TriggerSink};
33use crate::webhook::{WebhookAuth, extract_delivery_id};
34
35const DEFAULT_TRIGGER_CHANNEL_SIZE: usize = 256;
37
38type TriggerHandler =
40 Arc<dyn Fn(TriggerEvent) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
41
42const DEFAULT_MAX_BODY_SIZE: usize = 2 * 1024 * 1024;
44
45const DEFAULT_MAX_CONCURRENT_HANDLERS: usize = 64;
47
48type WebhookHandler =
49 Arc<dyn Fn(WebhookContext) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
50type ShutdownSignal = Pin<Box<dyn Future<Output = ()> + Send>>;
51
52#[derive(Debug, Clone)]
68pub struct WebhookContext {
69 pub payload: Value,
71 pub delivery_id: Option<String>,
76}
77
78#[cfg(feature = "prometheus")]
80mod metric_names {
81 pub const WEBHOOK_RECEIVED_TOTAL: &str = "ironflow_webhook_received_total";
82 pub const CRON_RUNS_TOTAL: &str = "ironflow_cron_runs_total";
83
84 pub const AUTH_REJECTED: &str = "rejected";
85 pub const AUTH_ACCEPTED: &str = "accepted";
86 pub const AUTH_INVALID_BODY: &str = "invalid_body";
87}
88
89struct WebhookRoute {
90 path: String,
91 auth: WebhookAuth,
92 handler: WebhookHandler,
93}
94
95pub struct Runtime {
131 webhooks: Vec<WebhookRoute>,
132 crons: Vec<CronJob>,
133 triggers: Vec<Box<dyn Trigger>>,
134 trigger_handler: Option<TriggerHandler>,
135 max_body_size: usize,
136 max_concurrent_handlers: usize,
137 custom_shutdown: Option<ShutdownSignal>,
138}
139
140impl Runtime {
141 pub fn new() -> Self {
151 Self {
152 webhooks: Vec::new(),
153 crons: Vec::new(),
154 triggers: Vec::new(),
155 trigger_handler: None,
156 max_body_size: DEFAULT_MAX_BODY_SIZE,
157 max_concurrent_handlers: DEFAULT_MAX_CONCURRENT_HANDLERS,
158 custom_shutdown: None,
159 }
160 }
161
162 pub fn max_body_size(mut self, bytes: usize) -> Self {
175 self.max_body_size = bytes;
176 self
177 }
178
179 pub fn max_concurrent_handlers(mut self, limit: usize) -> Self {
197 assert!(limit > 0, "max_concurrent_handlers must be greater than 0");
198 self.max_concurrent_handlers = limit;
199 self
200 }
201
202 pub fn with_shutdown<F>(mut self, signal: F) -> Self
230 where
231 F: Future<Output = ()> + Send + 'static,
232 {
233 self.custom_shutdown = Some(Box::pin(signal));
234 self
235 }
236
237 pub fn webhook<F, Fut>(self, path: &str, auth: WebhookAuth, handler: F) -> Self
261 where
262 F: Fn(Value) -> Fut + Send + Sync + Clone + 'static,
263 Fut: Future<Output = ()> + Send + 'static,
264 {
265 self.webhook_with_context(path, auth, move |ctx| handler(ctx.payload))
266 }
267
268 pub fn webhook_with_context<F, Fut>(mut self, path: &str, auth: WebhookAuth, handler: F) -> Self
301 where
302 F: Fn(WebhookContext) -> Fut + Send + Sync + Clone + 'static,
303 Fut: Future<Output = ()> + Send + 'static,
304 {
305 assert!(
306 path.starts_with('/'),
307 "webhook path must start with '/', got: {path}"
308 );
309 if matches!(auth, WebhookAuth::None) {
310 warn!(path = %path, "webhook registered with WebhookAuth::None - all requests will be accepted without authentication");
311 }
312 let handler: WebhookHandler = Arc::new(move |ctx| {
313 let handler = handler.clone();
314 Box::pin(async move { handler(ctx).await })
315 });
316 self.webhooks.push(WebhookRoute {
317 path: path.to_string(),
318 auth,
319 handler,
320 });
321 self
322 }
323
324 pub fn cron<F, Fut>(mut self, schedule: &str, name: &str, handler: F) -> Self
346 where
347 F: Fn() -> Fut + Send + Sync + 'static,
348 Fut: Future<Output = ()> + Send + 'static,
349 {
350 let handler_fn: Box<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync> =
351 Box::new(move || Box::pin(handler()));
352 self.crons.push(CronJob {
353 schedule: schedule.to_string(),
354 name: name.to_string(),
355 handler: handler_fn,
356 });
357 self
358 }
359
360 pub fn trigger(mut self, trigger: impl Trigger + 'static) -> Self {
387 self.triggers.push(Box::new(trigger));
388 self
389 }
390
391 pub fn on_trigger<F, Fut>(mut self, handler: F) -> Self
407 where
408 F: Fn(TriggerEvent) -> Fut + Send + Sync + 'static,
409 Fut: Future<Output = ()> + Send + 'static,
410 {
411 self.trigger_handler = Some(Arc::new(move |event| Box::pin(handler(event))));
412 self
413 }
414
415 fn build_router(
421 webhooks: Vec<WebhookRoute>,
422 handler_tracker: Arc<HandlerTracker>,
423 max_body_size: usize,
424 #[cfg(feature = "prometheus")] prom_handle: Option<
425 metrics_exporter_prometheus::PrometheusHandle,
426 >,
427 ) -> Router {
428 let mut router = Router::new();
429
430 for webhook in webhooks {
431 let auth = Arc::new(webhook.auth);
432 let handler = webhook.handler;
433 let path = webhook.path.clone();
434
435 let name: Arc<str> = Arc::from(path.as_str());
436 let route_state = WebhookState {
437 auth,
438 handler,
439 name,
440 tracker: handler_tracker.clone(),
441 };
442
443 router = router.route(&path, post(webhook_handler).with_state(route_state));
444 info!(path = %path, "registered webhook");
445 }
446
447 router = router.route("/health", get(|| async { "ok" }));
448
449 #[cfg(feature = "prometheus")]
450 if let Some(handle) = prom_handle {
451 router = router.route(
452 "/metrics",
453 get(move || {
454 let h = handle.clone();
455 async move { h.render() }
456 }),
457 );
458 info!("registered /metrics endpoint");
459 }
460
461 router
462 .layer(middleware::from_fn(security_headers))
463 .layer(DefaultBodyLimit::max(max_body_size))
464 }
465
466 pub fn into_router(self) -> Router {
482 if !self.crons.is_empty() {
483 warn!(
484 cron_count = self.crons.len(),
485 "into_router() drops registered cron jobs - use serve() or run_crons() to start them"
486 );
487 }
488 let tracker = Arc::new(HandlerTracker::new(self.max_concurrent_handlers));
489 Self::build_router(
490 self.webhooks,
491 tracker,
492 self.max_body_size,
493 #[cfg(feature = "prometheus")]
494 None,
495 )
496 }
497
498 async fn start_scheduler(crons: Vec<CronJob>) -> Result<JobScheduler, RuntimeError> {
503 let scheduler = JobScheduler::new().await?;
504
505 for cron_job in crons {
506 let handler = Arc::new(cron_job.handler);
507 let name = cron_job.name.clone();
508 let running = Arc::new(std::sync::atomic::AtomicBool::new(false));
509 let job = Job::new_async(cron_job.schedule.as_str(), move |_uuid, _lock| {
510 let handler = handler.clone();
511 let name = name.clone();
512 let running = running.clone();
513 Box::pin(async move {
514 if running.swap(true, std::sync::atomic::Ordering::AcqRel) {
515 warn!(cron = %name, "cron job still running, skipping this tick");
516 return;
517 }
518 info!(cron = %name, "cron job triggered");
519 #[cfg(feature = "prometheus")]
520 metrics::counter!(metric_names::CRON_RUNS_TOTAL, "job" => name.clone())
521 .increment(1);
522 (handler)().await;
523 running.store(false, std::sync::atomic::Ordering::Release);
524 })
525 })?;
526 info!(cron = %cron_job.name, schedule = %cron_job.schedule, "registered cron job");
527 scheduler.add(job).await?;
528 }
529
530 scheduler.start().await?;
531 Ok(scheduler)
532 }
533
534 pub async fn run_crons(self) -> Result<(), RuntimeError> {
565 let _ = dotenvy::dotenv();
566
567 if !self.webhooks.is_empty() {
568 warn!(
569 webhook_count = self.webhooks.len(),
570 "run_crons() ignores registered webhooks - use serve() to start both webhooks and crons"
571 );
572 }
573
574 #[cfg(feature = "prometheus")]
575 {
576 match metrics_exporter_prometheus::PrometheusBuilder::new().install_recorder() {
577 Ok(_) => info!("prometheus metrics recorder installed"),
578 Err(_) => {
579 info!("prometheus metrics recorder already installed, reusing existing")
580 }
581 }
582 }
583
584 let mut scheduler = Self::start_scheduler(self.crons).await?;
585
586 info!("ironflow cron scheduler running (no HTTP server)");
587 match self.custom_shutdown {
588 Some(signal) => signal.await,
589 None => shutdown_signal().await,
590 }
591
592 info!("shutting down scheduler");
593 scheduler.shutdown().await.map_err(RuntimeError::Shutdown)?;
594 info!("ironflow cron scheduler stopped");
595
596 Ok(())
597 }
598
599 pub async fn serve(self, addr: &str) -> Result<(), RuntimeError> {
635 let _ = dotenvy::dotenv();
636
637 #[cfg(feature = "prometheus")]
638 let prom_handle = {
639 match metrics_exporter_prometheus::PrometheusBuilder::new().install_recorder() {
640 Ok(handle) => {
641 info!("prometheus metrics recorder installed");
642 Some(handle)
643 }
644 Err(_) => {
645 info!("prometheus metrics recorder already installed, reusing existing");
646 None
647 }
648 }
649 };
650
651 let mut scheduler = Self::start_scheduler(self.crons).await?;
652
653 let trigger_token = CancellationToken::new();
655 let trigger_handles =
656 Self::start_triggers(self.triggers, self.trigger_handler, trigger_token.clone());
657
658 let tracker = Arc::new(HandlerTracker::new(self.max_concurrent_handlers));
659 let router = Self::build_router(
660 self.webhooks,
661 tracker.clone(),
662 self.max_body_size,
663 #[cfg(feature = "prometheus")]
664 prom_handle,
665 );
666
667 let listener = tokio::net::TcpListener::bind(addr)
668 .await
669 .map_err(RuntimeError::Bind)?;
670 info!(addr = %addr, "ironflow runtime listening");
671
672 let graceful_shutdown = match self.custom_shutdown {
673 Some(signal) => signal,
674 None => Box::pin(shutdown_signal()),
675 };
676 axum::serve(listener, router)
677 .with_graceful_shutdown(graceful_shutdown)
678 .await
679 .map_err(RuntimeError::Serve)?;
680
681 info!("waiting for in-flight webhook handlers to complete");
683 tracker.wait().await;
684
685 info!("stopping triggers");
687 trigger_token.cancel();
688 for handle in trigger_handles {
689 if let Err(e) = handle.await {
690 error!(error = %e, "trigger task panicked");
691 }
692 }
693
694 info!("shutting down scheduler");
695 scheduler.shutdown().await.map_err(RuntimeError::Shutdown)?;
696 info!("ironflow runtime stopped");
697
698 Ok(())
699 }
700
701 fn start_triggers(
705 triggers: Vec<Box<dyn Trigger>>,
706 handler: Option<TriggerHandler>,
707 token: CancellationToken,
708 ) -> Vec<tokio::task::JoinHandle<()>> {
709 if triggers.is_empty() {
710 return Vec::new();
711 }
712
713 let (sink, mut rx) = TriggerSink::channel(DEFAULT_TRIGGER_CHANNEL_SIZE);
714 let mut handles = Vec::new();
715
716 for trigger in triggers {
717 let sink = sink.clone();
718 let token = token.clone();
719 let name = trigger.name().to_string();
720 let handle = tokio::spawn(async move {
721 info!(trigger = %name, "starting trigger");
722 if let Err(e) = trigger.start(sink, &token).await {
723 error!(trigger = %name, error = %e, "trigger failed");
724 }
725 info!(trigger = %name, "trigger stopped");
726 });
727 handles.push(handle);
728 }
729
730 if let Some(handler) = handler {
732 let dispatch_token = token.clone();
733 let dispatch_handle = tokio::spawn(async move {
734 loop {
735 tokio::select! {
736 _ = dispatch_token.cancelled() => break,
737 event = rx.recv() => {
738 let Some(event) = event else { break };
739 info!(
740 workflow = %event.workflow_name,
741 "trigger event received, dispatching"
742 );
743 handler(event).await;
744 }
745 }
746 }
747 });
748 handles.push(dispatch_handle);
749 } else if !handles.is_empty() {
750 warn!(
751 "triggers registered but no on_trigger handler set - trigger events will be dropped"
752 );
753 }
754
755 handles
756 }
757}
758
759impl Default for Runtime {
760 fn default() -> Self {
761 Self::new()
762 }
763}
764
765struct HandlerTracker {
770 semaphore: Arc<Semaphore>,
771 join_set: Mutex<JoinSet<()>>,
772}
773
774impl HandlerTracker {
775 fn new(max_concurrent: usize) -> Self {
776 Self {
777 semaphore: Arc::new(Semaphore::new(max_concurrent)),
778 join_set: Mutex::new(JoinSet::new()),
779 }
780 }
781
782 async fn spawn(&self, name: String, handler: WebhookHandler, ctx: WebhookContext) {
784 let semaphore = self.semaphore.clone();
785 let mut js = self.join_set.lock().await;
786 while let Some(result) = js.try_join_next() {
788 if let Err(e) = result {
789 error!(error = %e, "webhook handler panicked");
790 }
791 }
792 use tracing::Instrument;
793 let span = tracing::info_span!("webhook", path = %name);
794 js.spawn(
795 async move {
796 let _permit = semaphore
797 .acquire()
798 .await
799 .expect("semaphore closed unexpectedly");
800 info!("webhook workflow started");
801 handler(ctx).await;
802 info!("webhook workflow completed");
803 }
804 .instrument(span),
805 );
806 }
807
808 async fn wait(&self) {
810 let mut js = self.join_set.lock().await;
811 while let Some(result) = js.join_next().await {
812 if let Err(e) = result {
813 error!(error = %e, "webhook handler panicked");
814 }
815 }
816 }
817}
818
819#[derive(Clone)]
820struct WebhookState {
821 auth: Arc<WebhookAuth>,
822 handler: WebhookHandler,
823 name: Arc<str>,
824 tracker: Arc<HandlerTracker>,
825}
826
827async fn webhook_handler(
828 State(state): State<WebhookState>,
829 headers: HeaderMap,
830 body: Bytes,
831) -> StatusCode {
832 let name = &state.name;
833 if !state.auth.verify(&headers, &body) {
834 warn!(webhook = %name, "webhook auth failed");
835 #[cfg(feature = "prometheus")]
836 {
837 let label: String = name.to_string();
838 metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_REJECTED).increment(1);
839 }
840 return StatusCode::UNAUTHORIZED;
841 }
842
843 let payload: Value = match from_slice(&body) {
844 Ok(v) => v,
845 Err(e) => {
846 warn!(webhook = %name, error = %e, "invalid JSON body");
847 #[cfg(feature = "prometheus")]
848 {
849 let label: String = name.to_string();
850 metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_INVALID_BODY).increment(1);
851 }
852 return StatusCode::BAD_REQUEST;
853 }
854 };
855
856 #[cfg(feature = "prometheus")]
857 {
858 let label: String = name.to_string();
859 metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_ACCEPTED).increment(1);
860 }
861
862 let ctx = WebhookContext {
863 payload,
864 delivery_id: extract_delivery_id(&headers),
865 };
866
867 state
868 .tracker
869 .spawn(name.to_string(), state.handler.clone(), ctx)
870 .await;
871
872 StatusCode::ACCEPTED
873}
874
875async fn security_headers(
876 request: axum::http::Request<axum::body::Body>,
877 next: axum::middleware::Next,
878) -> axum::response::Response {
879 let mut response = next.run(request).await;
880 let headers = response.headers_mut();
881 headers.insert(
882 header::X_CONTENT_TYPE_OPTIONS,
883 "nosniff".parse().expect("valid header value"),
884 );
885 headers.insert(
886 header::X_FRAME_OPTIONS,
887 "DENY".parse().expect("valid header value"),
888 );
889 headers.insert(
890 "x-xss-protection",
891 "1; mode=block".parse().expect("valid header value"),
892 );
893 headers.insert(
894 header::STRICT_TRANSPORT_SECURITY,
895 "max-age=31536000; includeSubDomains"
896 .parse()
897 .expect("valid header value"),
898 );
899 headers.insert(
900 header::CONTENT_SECURITY_POLICY,
901 "default-src 'none'".parse().expect("valid header value"),
902 );
903 response
904}
905
906async fn shutdown_signal() {
907 let ctrl_c = async {
908 if let Err(e) = tokio::signal::ctrl_c().await {
909 warn!("failed to install ctrl+c handler: {e}");
910 }
911 };
912
913 #[cfg(unix)]
914 {
915 use tokio::signal::unix::{SignalKind, signal};
916 let mut sigterm =
917 signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
918 tokio::select! {
919 () = ctrl_c => info!("received SIGINT, shutting down"),
920 _ = sigterm.recv() => info!("received SIGTERM, shutting down"),
921 }
922 }
923
924 #[cfg(not(unix))]
925 {
926 ctrl_c.await;
927 info!("received ctrl+c, shutting down");
928 }
929}
930
931#[cfg(test)]
932mod tests {
933 use super::*;
934
935 #[test]
937 fn runtime_new_creates_with_defaults() {
938 let rt = Runtime::new();
939 assert_eq!(rt.webhooks.len(), 0);
940 assert_eq!(rt.crons.len(), 0);
941 assert_eq!(rt.max_body_size, DEFAULT_MAX_BODY_SIZE);
942 assert_eq!(rt.max_concurrent_handlers, DEFAULT_MAX_CONCURRENT_HANDLERS);
943 assert!(rt.custom_shutdown.is_none());
944 }
945
946 #[test]
948 fn runtime_default_equals_new() {
949 let rt_new = Runtime::new();
950 let rt_default = Runtime::default();
951 assert_eq!(rt_new.webhooks.len(), rt_default.webhooks.len());
952 assert_eq!(rt_new.crons.len(), rt_default.crons.len());
953 assert_eq!(rt_new.max_body_size, rt_default.max_body_size);
954 assert_eq!(
955 rt_new.max_concurrent_handlers,
956 rt_default.max_concurrent_handlers
957 );
958 }
959
960 #[test]
962 fn max_body_size_sets_value_and_returns_self() {
963 let rt = Runtime::new().max_body_size(512 * 1024);
964 assert_eq!(rt.max_body_size, 512 * 1024);
965 }
966
967 #[test]
969 fn max_body_size_chainable() {
970 let rt =
971 Runtime::new()
972 .max_body_size(1024)
973 .webhook("/test", WebhookAuth::none(), |_| async {});
974 assert_eq!(rt.max_body_size, 1024);
975 assert_eq!(rt.webhooks.len(), 1);
976 }
977
978 #[test]
980 fn max_body_size_can_be_zero() {
981 let rt = Runtime::new().max_body_size(0);
982 assert_eq!(rt.max_body_size, 0);
983 }
984
985 #[test]
987 fn max_body_size_can_be_large() {
988 let large_size = 1024 * 1024 * 1024; let rt = Runtime::new().max_body_size(large_size);
990 assert_eq!(rt.max_body_size, large_size);
991 }
992
993 #[test]
995 #[should_panic(expected = "max_concurrent_handlers must be greater than 0")]
996 fn max_concurrent_handlers_zero_panics() {
997 let _ = Runtime::new().max_concurrent_handlers(0);
998 }
999
1000 #[test]
1002 fn max_concurrent_handlers_sets_valid_values() {
1003 let rt = Runtime::new().max_concurrent_handlers(16);
1004 assert_eq!(rt.max_concurrent_handlers, 16);
1005 }
1006
1007 #[test]
1009 fn max_concurrent_handlers_one_is_valid() {
1010 let rt = Runtime::new().max_concurrent_handlers(1);
1011 assert_eq!(rt.max_concurrent_handlers, 1);
1012 }
1013
1014 #[test]
1016 fn max_concurrent_handlers_large_value_is_valid() {
1017 let large_limit = 10000;
1018 let rt = Runtime::new().max_concurrent_handlers(large_limit);
1019 assert_eq!(rt.max_concurrent_handlers, large_limit);
1020 }
1021
1022 #[test]
1024 fn max_concurrent_handlers_chainable() {
1025 let rt = Runtime::new().max_concurrent_handlers(32).webhook(
1026 "/test",
1027 WebhookAuth::none(),
1028 |_| async {},
1029 );
1030 assert_eq!(rt.max_concurrent_handlers, 32);
1031 assert_eq!(rt.webhooks.len(), 1);
1032 }
1033
1034 #[tokio::test]
1036 async fn with_shutdown_sets_signal_and_returns_self() {
1037 let (tx, rx) = tokio::sync::oneshot::channel();
1038 let rt = Runtime::new().with_shutdown(async move {
1039 let _ = rx.await;
1040 });
1041 assert!(rt.custom_shutdown.is_some());
1042
1043 let _ = tx.send(());
1045 }
1046
1047 #[tokio::test]
1049 async fn with_shutdown_chainable() {
1050 let (tx, rx) = tokio::sync::oneshot::channel();
1051 let rt = Runtime::new()
1052 .with_shutdown(async move {
1053 let _ = rx.await;
1054 })
1055 .webhook("/test", WebhookAuth::none(), |_| async {});
1056 assert!(rt.custom_shutdown.is_some());
1057 assert_eq!(rt.webhooks.len(), 1);
1058
1059 let _ = tx.send(());
1060 }
1061
1062 #[test]
1064 fn webhook_registers_route_and_returns_self() {
1065 let rt = Runtime::new().webhook("/hooks/test", WebhookAuth::none(), |_| async {});
1066 assert_eq!(rt.webhooks.len(), 1);
1067 assert_eq!(rt.webhooks[0].path, "/hooks/test");
1068 }
1069
1070 #[test]
1072 #[should_panic(expected = "webhook path must start with '/'")]
1073 fn webhook_path_without_slash_panics() {
1074 let _ = Runtime::new().webhook("no-slash", WebhookAuth::none(), |_| async {});
1075 }
1076
1077 #[test]
1079 fn webhook_accepts_valid_paths() {
1080 let rt = Runtime::new()
1081 .webhook("/", WebhookAuth::none(), |_| async {})
1082 .webhook("/simple", WebhookAuth::none(), |_| async {})
1083 .webhook("/nested/path", WebhookAuth::none(), |_| async {})
1084 .webhook("/with-dashes", WebhookAuth::none(), |_| async {})
1085 .webhook("/with_underscores", WebhookAuth::none(), |_| async {})
1086 .webhook("/with/numbers/123", WebhookAuth::none(), |_| async {});
1087 assert_eq!(rt.webhooks.len(), 6);
1088 }
1089
1090 #[test]
1092 fn webhook_chainable() {
1093 let rt = Runtime::new()
1094 .webhook("/hook-a", WebhookAuth::none(), |_| async {})
1095 .webhook("/hook-b", WebhookAuth::none(), |_| async {})
1096 .webhook("/hook-c", WebhookAuth::none(), |_| async {});
1097 assert_eq!(rt.webhooks.len(), 3);
1098 assert_eq!(rt.webhooks[0].path, "/hook-a");
1099 assert_eq!(rt.webhooks[1].path, "/hook-b");
1100 assert_eq!(rt.webhooks[2].path, "/hook-c");
1101 }
1102
1103 #[test]
1105 fn webhook_with_various_auth_types() {
1106 let rt = Runtime::new()
1107 .webhook("/none", WebhookAuth::none(), |_| async {})
1108 .webhook(
1109 "/header",
1110 WebhookAuth::header("x-api-key", "secret"),
1111 |_| async {},
1112 )
1113 .webhook("/github", WebhookAuth::github("secret"), |_| async {})
1114 .webhook("/gitlab", WebhookAuth::gitlab("token"), |_| async {});
1115 assert_eq!(rt.webhooks.len(), 4);
1116 }
1117
1118 #[test]
1120 fn cron_registers_job_and_returns_self() {
1121 let rt = Runtime::new().cron("0 0 * * * *", "daily-task", || async {});
1122 assert_eq!(rt.crons.len(), 1);
1123 assert_eq!(rt.crons[0].name, "daily-task");
1124 assert_eq!(rt.crons[0].schedule, "0 0 * * * *");
1125 }
1126
1127 #[test]
1129 fn cron_chainable() {
1130 let rt = Runtime::new()
1131 .cron("0 0 * * * *", "midnight", || async {})
1132 .cron("0 */5 * * * *", "every-5-minutes", || async {});
1133 assert_eq!(rt.crons.len(), 2);
1134 }
1135
1136 #[test]
1138 fn cron_preserves_schedule_and_name() {
1139 let rt = Runtime::new()
1140 .cron("0 12 * * * MON", "noon-mondays", || async {})
1141 .cron("0 0 1 * * *", "first-of-month", || async {});
1142 assert_eq!(rt.crons[0].name, "noon-mondays");
1143 assert_eq!(rt.crons[0].schedule, "0 12 * * * MON");
1144 assert_eq!(rt.crons[1].name, "first-of-month");
1145 assert_eq!(rt.crons[1].schedule, "0 0 1 * * *");
1146 }
1147
1148 #[test]
1150 fn into_router_returns_router() {
1151 let rt = Runtime::new();
1152 let _router = rt.into_router();
1153 }
1155
1156 #[test]
1158 fn into_router_with_webhooks_returns_router() {
1159 let rt = Runtime::new()
1160 .webhook("/hook-a", WebhookAuth::none(), |_| async {})
1161 .webhook("/hook-b", WebhookAuth::github("secret"), |_| async {});
1162 let _router = rt.into_router();
1163 }
1165
1166 #[test]
1168 fn into_router_with_crons_returns_router() {
1169 let rt = Runtime::new()
1170 .cron("0 0 * * * *", "daily", || async {})
1171 .cron("0 */5 * * * *", "every-5-min", || async {});
1172 let _router = rt.into_router();
1173 }
1175
1176 #[test]
1178 fn into_router_respects_max_body_size_config() {
1179 let rt =
1180 Runtime::new()
1181 .max_body_size(100)
1182 .webhook("/hook", WebhookAuth::none(), |_| async {});
1183 let _router = rt.into_router();
1184 }
1186
1187 #[test]
1189 fn into_router_respects_max_concurrent_handlers_config() {
1190 let rt = Runtime::new().max_concurrent_handlers(16).webhook(
1191 "/hook",
1192 WebhookAuth::none(),
1193 |_| async {},
1194 );
1195 let _router = rt.into_router();
1196 }
1198
1199 #[test]
1201 fn builder_chain_multiple_methods() {
1202 let rt = Runtime::new()
1203 .max_body_size(512 * 1024)
1204 .max_concurrent_handlers(32)
1205 .webhook("/hook-a", WebhookAuth::none(), |_| async {})
1206 .webhook("/hook-b", WebhookAuth::github("secret"), |_| async {})
1207 .cron("0 0 * * * *", "daily", || async {});
1208
1209 assert_eq!(rt.max_body_size, 512 * 1024);
1210 assert_eq!(rt.max_concurrent_handlers, 32);
1211 assert_eq!(rt.webhooks.len(), 2);
1212 assert_eq!(rt.crons.len(), 1);
1213 }
1214
1215 #[test]
1217 fn into_router_with_crons_doesnt_start_them() {
1218 let rt = Runtime::new().cron("0 0 * * * *", "test-cron", || async {});
1219 let _router = rt.into_router();
1221 }
1222}