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 {
386 self.triggers.push(Box::new(trigger));
387 self
388 }
389
390 pub fn on_trigger<F, Fut>(mut self, handler: F) -> Self
406 where
407 F: Fn(TriggerEvent) -> Fut + Send + Sync + 'static,
408 Fut: Future<Output = ()> + Send + 'static,
409 {
410 self.trigger_handler = Some(Arc::new(move |event| Box::pin(handler(event))));
411 self
412 }
413
414 fn build_router(
420 webhooks: Vec<WebhookRoute>,
421 handler_tracker: Arc<HandlerTracker>,
422 max_body_size: usize,
423 #[cfg(feature = "prometheus")] prom_handle: Option<
424 metrics_exporter_prometheus::PrometheusHandle,
425 >,
426 ) -> Router {
427 let mut router = Router::new();
428
429 for webhook in webhooks {
430 let auth = Arc::new(webhook.auth);
431 let handler = webhook.handler;
432 let path = webhook.path.clone();
433
434 let name: Arc<str> = Arc::from(path.as_str());
435 let route_state = WebhookState {
436 auth,
437 handler,
438 name,
439 tracker: handler_tracker.clone(),
440 };
441
442 router = router.route(&path, post(webhook_handler).with_state(route_state));
443 info!(path = %path, "registered webhook");
444 }
445
446 router = router.route("/health", get(|| async { "ok" }));
447
448 #[cfg(feature = "prometheus")]
449 if let Some(handle) = prom_handle {
450 router = router.route(
451 "/metrics",
452 get(move || {
453 let h = handle.clone();
454 async move { h.render() }
455 }),
456 );
457 info!("registered /metrics endpoint");
458 }
459
460 router
461 .layer(middleware::from_fn(security_headers))
462 .layer(DefaultBodyLimit::max(max_body_size))
463 }
464
465 pub fn into_router(self) -> Router {
481 if !self.crons.is_empty() {
482 warn!(
483 cron_count = self.crons.len(),
484 "into_router() drops registered cron jobs - use serve() or run_crons() to start them"
485 );
486 }
487 let tracker = Arc::new(HandlerTracker::new(self.max_concurrent_handlers));
488 Self::build_router(
489 self.webhooks,
490 tracker,
491 self.max_body_size,
492 #[cfg(feature = "prometheus")]
493 None,
494 )
495 }
496
497 async fn start_scheduler(crons: Vec<CronJob>) -> Result<JobScheduler, RuntimeError> {
502 let scheduler = JobScheduler::new().await?;
503
504 for cron_job in crons {
505 let handler = Arc::new(cron_job.handler);
506 let name = cron_job.name.clone();
507 let running = Arc::new(std::sync::atomic::AtomicBool::new(false));
508 let job = Job::new_async(cron_job.schedule.as_str(), move |_uuid, _lock| {
509 let handler = handler.clone();
510 let name = name.clone();
511 let running = running.clone();
512 Box::pin(async move {
513 if running.swap(true, std::sync::atomic::Ordering::AcqRel) {
514 warn!(cron = %name, "cron job still running, skipping this tick");
515 return;
516 }
517 info!(cron = %name, "cron job triggered");
518 #[cfg(feature = "prometheus")]
519 metrics::counter!(metric_names::CRON_RUNS_TOTAL, "job" => name.clone())
520 .increment(1);
521 (handler)().await;
522 running.store(false, std::sync::atomic::Ordering::Release);
523 })
524 })?;
525 info!(cron = %cron_job.name, schedule = %cron_job.schedule, "registered cron job");
526 scheduler.add(job).await?;
527 }
528
529 scheduler.start().await?;
530 Ok(scheduler)
531 }
532
533 pub async fn run_crons(self) -> Result<(), RuntimeError> {
564 let _ = dotenvy::dotenv();
565
566 if !self.webhooks.is_empty() {
567 warn!(
568 webhook_count = self.webhooks.len(),
569 "run_crons() ignores registered webhooks - use serve() to start both webhooks and crons"
570 );
571 }
572
573 #[cfg(feature = "prometheus")]
574 {
575 match metrics_exporter_prometheus::PrometheusBuilder::new().install_recorder() {
576 Ok(_) => info!("prometheus metrics recorder installed"),
577 Err(_) => {
578 info!("prometheus metrics recorder already installed, reusing existing")
579 }
580 }
581 }
582
583 let mut scheduler = Self::start_scheduler(self.crons).await?;
584
585 info!("ironflow cron scheduler running (no HTTP server)");
586 match self.custom_shutdown {
587 Some(signal) => signal.await,
588 None => shutdown_signal().await,
589 }
590
591 info!("shutting down scheduler");
592 scheduler.shutdown().await.map_err(RuntimeError::Shutdown)?;
593 info!("ironflow cron scheduler stopped");
594
595 Ok(())
596 }
597
598 pub async fn serve(self, addr: &str) -> Result<(), RuntimeError> {
634 let _ = dotenvy::dotenv();
635
636 #[cfg(feature = "prometheus")]
637 let prom_handle = {
638 match metrics_exporter_prometheus::PrometheusBuilder::new().install_recorder() {
639 Ok(handle) => {
640 info!("prometheus metrics recorder installed");
641 Some(handle)
642 }
643 Err(_) => {
644 info!("prometheus metrics recorder already installed, reusing existing");
645 None
646 }
647 }
648 };
649
650 let mut scheduler = Self::start_scheduler(self.crons).await?;
651
652 let trigger_token = CancellationToken::new();
654 let trigger_handles =
655 Self::start_triggers(self.triggers, self.trigger_handler, trigger_token.clone());
656
657 let tracker = Arc::new(HandlerTracker::new(self.max_concurrent_handlers));
658 let router = Self::build_router(
659 self.webhooks,
660 tracker.clone(),
661 self.max_body_size,
662 #[cfg(feature = "prometheus")]
663 prom_handle,
664 );
665
666 let listener = tokio::net::TcpListener::bind(addr)
667 .await
668 .map_err(RuntimeError::Bind)?;
669 info!(addr = %addr, "ironflow runtime listening");
670
671 let graceful_shutdown = match self.custom_shutdown {
672 Some(signal) => signal,
673 None => Box::pin(shutdown_signal()),
674 };
675 axum::serve(listener, router)
676 .with_graceful_shutdown(graceful_shutdown)
677 .await
678 .map_err(RuntimeError::Serve)?;
679
680 info!("waiting for in-flight webhook handlers to complete");
682 tracker.wait().await;
683
684 info!("stopping triggers");
686 trigger_token.cancel();
687 for handle in trigger_handles {
688 if let Err(e) = handle.await {
689 error!(error = %e, "trigger task panicked");
690 }
691 }
692
693 info!("shutting down scheduler");
694 scheduler.shutdown().await.map_err(RuntimeError::Shutdown)?;
695 info!("ironflow runtime stopped");
696
697 Ok(())
698 }
699
700 fn start_triggers(
704 triggers: Vec<Box<dyn Trigger>>,
705 handler: Option<TriggerHandler>,
706 token: CancellationToken,
707 ) -> Vec<tokio::task::JoinHandle<()>> {
708 if triggers.is_empty() {
709 return Vec::new();
710 }
711
712 let (sink, mut rx) = TriggerSink::channel(DEFAULT_TRIGGER_CHANNEL_SIZE);
713 let mut handles = Vec::new();
714
715 for trigger in triggers {
716 let sink = sink.clone();
717 let token = token.clone();
718 let name = trigger.name().to_string();
719 let handle = tokio::spawn(async move {
720 info!(trigger = %name, "starting trigger");
721 if let Err(e) = trigger.start(sink, &token).await {
722 error!(trigger = %name, error = %e, "trigger failed");
723 }
724 info!(trigger = %name, "trigger stopped");
725 });
726 handles.push(handle);
727 }
728
729 if let Some(handler) = handler {
731 let dispatch_token = token.clone();
732 let dispatch_handle = tokio::spawn(async move {
733 loop {
734 tokio::select! {
735 _ = dispatch_token.cancelled() => break,
736 event = rx.recv() => {
737 let Some(event) = event else { break };
738 info!(
739 workflow = %event.workflow_name,
740 "trigger event received, dispatching"
741 );
742 handler(event).await;
743 }
744 }
745 }
746 });
747 handles.push(dispatch_handle);
748 } else if !handles.is_empty() {
749 warn!(
750 "triggers registered but no on_trigger handler set - trigger events will be dropped"
751 );
752 }
753
754 handles
755 }
756}
757
758impl Default for Runtime {
759 fn default() -> Self {
760 Self::new()
761 }
762}
763
764struct HandlerTracker {
769 semaphore: Arc<Semaphore>,
770 join_set: Mutex<JoinSet<()>>,
771}
772
773impl HandlerTracker {
774 fn new(max_concurrent: usize) -> Self {
775 Self {
776 semaphore: Arc::new(Semaphore::new(max_concurrent)),
777 join_set: Mutex::new(JoinSet::new()),
778 }
779 }
780
781 async fn spawn(&self, name: String, handler: WebhookHandler, ctx: WebhookContext) {
783 let semaphore = self.semaphore.clone();
784 let mut js = self.join_set.lock().await;
785 while let Some(result) = js.try_join_next() {
787 if let Err(e) = result {
788 error!(error = %e, "webhook handler panicked");
789 }
790 }
791 use tracing::Instrument;
792 let span = tracing::info_span!("webhook", path = %name);
793 js.spawn(
794 async move {
795 let _permit = semaphore
796 .acquire()
797 .await
798 .expect("semaphore closed unexpectedly");
799 info!("webhook workflow started");
800 handler(ctx).await;
801 info!("webhook workflow completed");
802 }
803 .instrument(span),
804 );
805 }
806
807 async fn wait(&self) {
809 let mut js = self.join_set.lock().await;
810 while let Some(result) = js.join_next().await {
811 if let Err(e) = result {
812 error!(error = %e, "webhook handler panicked");
813 }
814 }
815 }
816}
817
818#[derive(Clone)]
819struct WebhookState {
820 auth: Arc<WebhookAuth>,
821 handler: WebhookHandler,
822 name: Arc<str>,
823 tracker: Arc<HandlerTracker>,
824}
825
826async fn webhook_handler(
827 State(state): State<WebhookState>,
828 headers: HeaderMap,
829 body: Bytes,
830) -> StatusCode {
831 let name = &state.name;
832 if !state.auth.verify(&headers, &body) {
833 warn!(webhook = %name, "webhook auth failed");
834 #[cfg(feature = "prometheus")]
835 {
836 let label: String = name.to_string();
837 metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_REJECTED).increment(1);
838 }
839 return StatusCode::UNAUTHORIZED;
840 }
841
842 let payload: Value = match from_slice(&body) {
843 Ok(v) => v,
844 Err(e) => {
845 warn!(webhook = %name, error = %e, "invalid JSON body");
846 #[cfg(feature = "prometheus")]
847 {
848 let label: String = name.to_string();
849 metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_INVALID_BODY).increment(1);
850 }
851 return StatusCode::BAD_REQUEST;
852 }
853 };
854
855 #[cfg(feature = "prometheus")]
856 {
857 let label: String = name.to_string();
858 metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_ACCEPTED).increment(1);
859 }
860
861 let ctx = WebhookContext {
862 payload,
863 delivery_id: extract_delivery_id(&headers),
864 };
865
866 state
867 .tracker
868 .spawn(name.to_string(), state.handler.clone(), ctx)
869 .await;
870
871 StatusCode::ACCEPTED
872}
873
874async fn security_headers(
875 request: axum::http::Request<axum::body::Body>,
876 next: axum::middleware::Next,
877) -> axum::response::Response {
878 let mut response = next.run(request).await;
879 let headers = response.headers_mut();
880 headers.insert(
881 header::X_CONTENT_TYPE_OPTIONS,
882 "nosniff".parse().expect("valid header value"),
883 );
884 headers.insert(
885 header::X_FRAME_OPTIONS,
886 "DENY".parse().expect("valid header value"),
887 );
888 headers.insert(
889 "x-xss-protection",
890 "1; mode=block".parse().expect("valid header value"),
891 );
892 headers.insert(
893 header::STRICT_TRANSPORT_SECURITY,
894 "max-age=31536000; includeSubDomains"
895 .parse()
896 .expect("valid header value"),
897 );
898 headers.insert(
899 header::CONTENT_SECURITY_POLICY,
900 "default-src 'none'".parse().expect("valid header value"),
901 );
902 response
903}
904
905async fn shutdown_signal() {
906 let ctrl_c = async {
907 if let Err(e) = tokio::signal::ctrl_c().await {
908 warn!("failed to install ctrl+c handler: {e}");
909 }
910 };
911
912 #[cfg(unix)]
913 {
914 use tokio::signal::unix::{SignalKind, signal};
915 let mut sigterm =
916 signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
917 tokio::select! {
918 () = ctrl_c => info!("received SIGINT, shutting down"),
919 _ = sigterm.recv() => info!("received SIGTERM, shutting down"),
920 }
921 }
922
923 #[cfg(not(unix))]
924 {
925 ctrl_c.await;
926 info!("received ctrl+c, shutting down");
927 }
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933
934 #[test]
936 fn runtime_new_creates_with_defaults() {
937 let rt = Runtime::new();
938 assert_eq!(rt.webhooks.len(), 0);
939 assert_eq!(rt.crons.len(), 0);
940 assert_eq!(rt.max_body_size, DEFAULT_MAX_BODY_SIZE);
941 assert_eq!(rt.max_concurrent_handlers, DEFAULT_MAX_CONCURRENT_HANDLERS);
942 assert!(rt.custom_shutdown.is_none());
943 }
944
945 #[test]
947 fn runtime_default_equals_new() {
948 let rt_new = Runtime::new();
949 let rt_default = Runtime::default();
950 assert_eq!(rt_new.webhooks.len(), rt_default.webhooks.len());
951 assert_eq!(rt_new.crons.len(), rt_default.crons.len());
952 assert_eq!(rt_new.max_body_size, rt_default.max_body_size);
953 assert_eq!(
954 rt_new.max_concurrent_handlers,
955 rt_default.max_concurrent_handlers
956 );
957 }
958
959 #[test]
961 fn max_body_size_sets_value_and_returns_self() {
962 let rt = Runtime::new().max_body_size(512 * 1024);
963 assert_eq!(rt.max_body_size, 512 * 1024);
964 }
965
966 #[test]
968 fn max_body_size_chainable() {
969 let rt =
970 Runtime::new()
971 .max_body_size(1024)
972 .webhook("/test", WebhookAuth::none(), |_| async {});
973 assert_eq!(rt.max_body_size, 1024);
974 assert_eq!(rt.webhooks.len(), 1);
975 }
976
977 #[test]
979 fn max_body_size_can_be_zero() {
980 let rt = Runtime::new().max_body_size(0);
981 assert_eq!(rt.max_body_size, 0);
982 }
983
984 #[test]
986 fn max_body_size_can_be_large() {
987 let large_size = 1024 * 1024 * 1024; let rt = Runtime::new().max_body_size(large_size);
989 assert_eq!(rt.max_body_size, large_size);
990 }
991
992 #[test]
994 #[should_panic(expected = "max_concurrent_handlers must be greater than 0")]
995 fn max_concurrent_handlers_zero_panics() {
996 let _ = Runtime::new().max_concurrent_handlers(0);
997 }
998
999 #[test]
1001 fn max_concurrent_handlers_sets_valid_values() {
1002 let rt = Runtime::new().max_concurrent_handlers(16);
1003 assert_eq!(rt.max_concurrent_handlers, 16);
1004 }
1005
1006 #[test]
1008 fn max_concurrent_handlers_one_is_valid() {
1009 let rt = Runtime::new().max_concurrent_handlers(1);
1010 assert_eq!(rt.max_concurrent_handlers, 1);
1011 }
1012
1013 #[test]
1015 fn max_concurrent_handlers_large_value_is_valid() {
1016 let large_limit = 10000;
1017 let rt = Runtime::new().max_concurrent_handlers(large_limit);
1018 assert_eq!(rt.max_concurrent_handlers, large_limit);
1019 }
1020
1021 #[test]
1023 fn max_concurrent_handlers_chainable() {
1024 let rt = Runtime::new().max_concurrent_handlers(32).webhook(
1025 "/test",
1026 WebhookAuth::none(),
1027 |_| async {},
1028 );
1029 assert_eq!(rt.max_concurrent_handlers, 32);
1030 assert_eq!(rt.webhooks.len(), 1);
1031 }
1032
1033 #[tokio::test]
1035 async fn with_shutdown_sets_signal_and_returns_self() {
1036 let (tx, rx) = tokio::sync::oneshot::channel();
1037 let rt = Runtime::new().with_shutdown(async move {
1038 let _ = rx.await;
1039 });
1040 assert!(rt.custom_shutdown.is_some());
1041
1042 let _ = tx.send(());
1044 }
1045
1046 #[tokio::test]
1048 async fn with_shutdown_chainable() {
1049 let (tx, rx) = tokio::sync::oneshot::channel();
1050 let rt = Runtime::new()
1051 .with_shutdown(async move {
1052 let _ = rx.await;
1053 })
1054 .webhook("/test", WebhookAuth::none(), |_| async {});
1055 assert!(rt.custom_shutdown.is_some());
1056 assert_eq!(rt.webhooks.len(), 1);
1057
1058 let _ = tx.send(());
1059 }
1060
1061 #[test]
1063 fn webhook_registers_route_and_returns_self() {
1064 let rt = Runtime::new().webhook("/hooks/test", WebhookAuth::none(), |_| async {});
1065 assert_eq!(rt.webhooks.len(), 1);
1066 assert_eq!(rt.webhooks[0].path, "/hooks/test");
1067 }
1068
1069 #[test]
1071 #[should_panic(expected = "webhook path must start with '/'")]
1072 fn webhook_path_without_slash_panics() {
1073 let _ = Runtime::new().webhook("no-slash", WebhookAuth::none(), |_| async {});
1074 }
1075
1076 #[test]
1078 fn webhook_accepts_valid_paths() {
1079 let rt = Runtime::new()
1080 .webhook("/", WebhookAuth::none(), |_| async {})
1081 .webhook("/simple", WebhookAuth::none(), |_| async {})
1082 .webhook("/nested/path", WebhookAuth::none(), |_| async {})
1083 .webhook("/with-dashes", WebhookAuth::none(), |_| async {})
1084 .webhook("/with_underscores", WebhookAuth::none(), |_| async {})
1085 .webhook("/with/numbers/123", WebhookAuth::none(), |_| async {});
1086 assert_eq!(rt.webhooks.len(), 6);
1087 }
1088
1089 #[test]
1091 fn webhook_chainable() {
1092 let rt = Runtime::new()
1093 .webhook("/hook-a", WebhookAuth::none(), |_| async {})
1094 .webhook("/hook-b", WebhookAuth::none(), |_| async {})
1095 .webhook("/hook-c", WebhookAuth::none(), |_| async {});
1096 assert_eq!(rt.webhooks.len(), 3);
1097 assert_eq!(rt.webhooks[0].path, "/hook-a");
1098 assert_eq!(rt.webhooks[1].path, "/hook-b");
1099 assert_eq!(rt.webhooks[2].path, "/hook-c");
1100 }
1101
1102 #[test]
1104 fn webhook_with_various_auth_types() {
1105 let rt = Runtime::new()
1106 .webhook("/none", WebhookAuth::none(), |_| async {})
1107 .webhook(
1108 "/header",
1109 WebhookAuth::header("x-api-key", "secret"),
1110 |_| async {},
1111 )
1112 .webhook("/github", WebhookAuth::github("secret"), |_| async {})
1113 .webhook("/gitlab", WebhookAuth::gitlab("token"), |_| async {});
1114 assert_eq!(rt.webhooks.len(), 4);
1115 }
1116
1117 #[test]
1119 fn cron_registers_job_and_returns_self() {
1120 let rt = Runtime::new().cron("0 0 * * * *", "daily-task", || async {});
1121 assert_eq!(rt.crons.len(), 1);
1122 assert_eq!(rt.crons[0].name, "daily-task");
1123 assert_eq!(rt.crons[0].schedule, "0 0 * * * *");
1124 }
1125
1126 #[test]
1128 fn cron_chainable() {
1129 let rt = Runtime::new()
1130 .cron("0 0 * * * *", "midnight", || async {})
1131 .cron("0 */5 * * * *", "every-5-minutes", || async {});
1132 assert_eq!(rt.crons.len(), 2);
1133 }
1134
1135 #[test]
1137 fn cron_preserves_schedule_and_name() {
1138 let rt = Runtime::new()
1139 .cron("0 12 * * * MON", "noon-mondays", || async {})
1140 .cron("0 0 1 * * *", "first-of-month", || async {});
1141 assert_eq!(rt.crons[0].name, "noon-mondays");
1142 assert_eq!(rt.crons[0].schedule, "0 12 * * * MON");
1143 assert_eq!(rt.crons[1].name, "first-of-month");
1144 assert_eq!(rt.crons[1].schedule, "0 0 1 * * *");
1145 }
1146
1147 #[test]
1149 fn into_router_returns_router() {
1150 let rt = Runtime::new();
1151 let _router = rt.into_router();
1152 }
1154
1155 #[test]
1157 fn into_router_with_webhooks_returns_router() {
1158 let rt = Runtime::new()
1159 .webhook("/hook-a", WebhookAuth::none(), |_| async {})
1160 .webhook("/hook-b", WebhookAuth::github("secret"), |_| async {});
1161 let _router = rt.into_router();
1162 }
1164
1165 #[test]
1167 fn into_router_with_crons_returns_router() {
1168 let rt = Runtime::new()
1169 .cron("0 0 * * * *", "daily", || async {})
1170 .cron("0 */5 * * * *", "every-5-min", || async {});
1171 let _router = rt.into_router();
1172 }
1174
1175 #[test]
1177 fn into_router_respects_max_body_size_config() {
1178 let rt =
1179 Runtime::new()
1180 .max_body_size(100)
1181 .webhook("/hook", WebhookAuth::none(), |_| async {});
1182 let _router = rt.into_router();
1183 }
1185
1186 #[test]
1188 fn into_router_respects_max_concurrent_handlers_config() {
1189 let rt = Runtime::new().max_concurrent_handlers(16).webhook(
1190 "/hook",
1191 WebhookAuth::none(),
1192 |_| async {},
1193 );
1194 let _router = rt.into_router();
1195 }
1197
1198 #[test]
1200 fn builder_chain_multiple_methods() {
1201 let rt = Runtime::new()
1202 .max_body_size(512 * 1024)
1203 .max_concurrent_handlers(32)
1204 .webhook("/hook-a", WebhookAuth::none(), |_| async {})
1205 .webhook("/hook-b", WebhookAuth::github("secret"), |_| async {})
1206 .cron("0 0 * * * *", "daily", || async {});
1207
1208 assert_eq!(rt.max_body_size, 512 * 1024);
1209 assert_eq!(rt.max_concurrent_handlers, 32);
1210 assert_eq!(rt.webhooks.len(), 2);
1211 assert_eq!(rt.crons.len(), 1);
1212 }
1213
1214 #[test]
1216 fn into_router_with_crons_doesnt_start_them() {
1217 let rt = Runtime::new().cron("0 0 * * * *", "test-cron", || async {});
1218 let _router = rt.into_router();
1220 }
1221}