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 crate::cron::CronJob;
29use crate::error::RuntimeError;
30use crate::webhook::{WebhookAuth, extract_delivery_id};
31
32const DEFAULT_MAX_BODY_SIZE: usize = 2 * 1024 * 1024;
34
35const DEFAULT_MAX_CONCURRENT_HANDLERS: usize = 64;
37
38type WebhookHandler =
39 Arc<dyn Fn(WebhookContext) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
40type ShutdownSignal = Pin<Box<dyn Future<Output = ()> + Send>>;
41
42#[derive(Debug, Clone)]
58pub struct WebhookContext {
59 pub payload: Value,
61 pub delivery_id: Option<String>,
66}
67
68#[cfg(feature = "prometheus")]
70mod metric_names {
71 pub const WEBHOOK_RECEIVED_TOTAL: &str = "ironflow_webhook_received_total";
72 pub const CRON_RUNS_TOTAL: &str = "ironflow_cron_runs_total";
73
74 pub const AUTH_REJECTED: &str = "rejected";
75 pub const AUTH_ACCEPTED: &str = "accepted";
76 pub const AUTH_INVALID_BODY: &str = "invalid_body";
77}
78
79struct WebhookRoute {
80 path: String,
81 auth: WebhookAuth,
82 handler: WebhookHandler,
83}
84
85pub struct Runtime {
121 webhooks: Vec<WebhookRoute>,
122 crons: Vec<CronJob>,
123 max_body_size: usize,
124 max_concurrent_handlers: usize,
125 custom_shutdown: Option<ShutdownSignal>,
126}
127
128impl Runtime {
129 pub fn new() -> Self {
139 Self {
140 webhooks: Vec::new(),
141 crons: Vec::new(),
142 max_body_size: DEFAULT_MAX_BODY_SIZE,
143 max_concurrent_handlers: DEFAULT_MAX_CONCURRENT_HANDLERS,
144 custom_shutdown: None,
145 }
146 }
147
148 pub fn max_body_size(mut self, bytes: usize) -> Self {
161 self.max_body_size = bytes;
162 self
163 }
164
165 pub fn max_concurrent_handlers(mut self, limit: usize) -> Self {
183 assert!(limit > 0, "max_concurrent_handlers must be greater than 0");
184 self.max_concurrent_handlers = limit;
185 self
186 }
187
188 pub fn with_shutdown<F>(mut self, signal: F) -> Self
216 where
217 F: Future<Output = ()> + Send + 'static,
218 {
219 self.custom_shutdown = Some(Box::pin(signal));
220 self
221 }
222
223 pub fn webhook<F, Fut>(self, path: &str, auth: WebhookAuth, handler: F) -> Self
247 where
248 F: Fn(Value) -> Fut + Send + Sync + Clone + 'static,
249 Fut: Future<Output = ()> + Send + 'static,
250 {
251 self.webhook_with_context(path, auth, move |ctx| handler(ctx.payload))
252 }
253
254 pub fn webhook_with_context<F, Fut>(mut self, path: &str, auth: WebhookAuth, handler: F) -> Self
287 where
288 F: Fn(WebhookContext) -> Fut + Send + Sync + Clone + 'static,
289 Fut: Future<Output = ()> + Send + 'static,
290 {
291 assert!(
292 path.starts_with('/'),
293 "webhook path must start with '/', got: {path}"
294 );
295 if matches!(auth, WebhookAuth::None) {
296 warn!(path = %path, "webhook registered with WebhookAuth::None - all requests will be accepted without authentication");
297 }
298 let handler: WebhookHandler = Arc::new(move |ctx| {
299 let handler = handler.clone();
300 Box::pin(async move { handler(ctx).await })
301 });
302 self.webhooks.push(WebhookRoute {
303 path: path.to_string(),
304 auth,
305 handler,
306 });
307 self
308 }
309
310 pub fn cron<F, Fut>(mut self, schedule: &str, name: &str, handler: F) -> Self
332 where
333 F: Fn() -> Fut + Send + Sync + 'static,
334 Fut: Future<Output = ()> + Send + 'static,
335 {
336 let handler_fn: Box<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync> =
337 Box::new(move || Box::pin(handler()));
338 self.crons.push(CronJob {
339 schedule: schedule.to_string(),
340 name: name.to_string(),
341 handler: handler_fn,
342 });
343 self
344 }
345
346 fn build_router(
352 webhooks: Vec<WebhookRoute>,
353 handler_tracker: Arc<HandlerTracker>,
354 max_body_size: usize,
355 #[cfg(feature = "prometheus")] prom_handle: Option<
356 metrics_exporter_prometheus::PrometheusHandle,
357 >,
358 ) -> Router {
359 let mut router = Router::new();
360
361 for webhook in webhooks {
362 let auth = Arc::new(webhook.auth);
363 let handler = webhook.handler;
364 let path = webhook.path.clone();
365
366 let name: Arc<str> = Arc::from(path.as_str());
367 let route_state = WebhookState {
368 auth,
369 handler,
370 name,
371 tracker: handler_tracker.clone(),
372 };
373
374 router = router.route(&path, post(webhook_handler).with_state(route_state));
375 info!(path = %path, "registered webhook");
376 }
377
378 router = router.route("/health", get(|| async { "ok" }));
379
380 #[cfg(feature = "prometheus")]
381 if let Some(handle) = prom_handle {
382 router = router.route(
383 "/metrics",
384 get(move || {
385 let h = handle.clone();
386 async move { h.render() }
387 }),
388 );
389 info!("registered /metrics endpoint");
390 }
391
392 router
393 .layer(middleware::from_fn(security_headers))
394 .layer(DefaultBodyLimit::max(max_body_size))
395 }
396
397 pub fn into_router(self) -> Router {
413 if !self.crons.is_empty() {
414 warn!(
415 cron_count = self.crons.len(),
416 "into_router() drops registered cron jobs - use serve() or run_crons() to start them"
417 );
418 }
419 let tracker = Arc::new(HandlerTracker::new(self.max_concurrent_handlers));
420 Self::build_router(
421 self.webhooks,
422 tracker,
423 self.max_body_size,
424 #[cfg(feature = "prometheus")]
425 None,
426 )
427 }
428
429 async fn start_scheduler(crons: Vec<CronJob>) -> Result<JobScheduler, RuntimeError> {
434 let scheduler = JobScheduler::new().await?;
435
436 for cron_job in crons {
437 let handler = Arc::new(cron_job.handler);
438 let name = cron_job.name.clone();
439 let running = Arc::new(std::sync::atomic::AtomicBool::new(false));
440 let job = Job::new_async(cron_job.schedule.as_str(), move |_uuid, _lock| {
441 let handler = handler.clone();
442 let name = name.clone();
443 let running = running.clone();
444 Box::pin(async move {
445 if running.swap(true, std::sync::atomic::Ordering::AcqRel) {
446 warn!(cron = %name, "cron job still running, skipping this tick");
447 return;
448 }
449 info!(cron = %name, "cron job triggered");
450 #[cfg(feature = "prometheus")]
451 metrics::counter!(metric_names::CRON_RUNS_TOTAL, "job" => name.clone())
452 .increment(1);
453 (handler)().await;
454 running.store(false, std::sync::atomic::Ordering::Release);
455 })
456 })?;
457 info!(cron = %cron_job.name, schedule = %cron_job.schedule, "registered cron job");
458 scheduler.add(job).await?;
459 }
460
461 scheduler.start().await?;
462 Ok(scheduler)
463 }
464
465 pub async fn run_crons(self) -> Result<(), RuntimeError> {
496 let _ = dotenvy::dotenv();
497
498 if !self.webhooks.is_empty() {
499 warn!(
500 webhook_count = self.webhooks.len(),
501 "run_crons() ignores registered webhooks - use serve() to start both webhooks and crons"
502 );
503 }
504
505 #[cfg(feature = "prometheus")]
506 {
507 match metrics_exporter_prometheus::PrometheusBuilder::new().install_recorder() {
508 Ok(_) => info!("prometheus metrics recorder installed"),
509 Err(_) => {
510 info!("prometheus metrics recorder already installed, reusing existing")
511 }
512 }
513 }
514
515 let mut scheduler = Self::start_scheduler(self.crons).await?;
516
517 info!("ironflow cron scheduler running (no HTTP server)");
518 match self.custom_shutdown {
519 Some(signal) => signal.await,
520 None => shutdown_signal().await,
521 }
522
523 info!("shutting down scheduler");
524 scheduler.shutdown().await.map_err(RuntimeError::Shutdown)?;
525 info!("ironflow cron scheduler stopped");
526
527 Ok(())
528 }
529
530 pub async fn serve(self, addr: &str) -> Result<(), RuntimeError> {
566 let _ = dotenvy::dotenv();
567
568 #[cfg(feature = "prometheus")]
569 let prom_handle = {
570 match metrics_exporter_prometheus::PrometheusBuilder::new().install_recorder() {
571 Ok(handle) => {
572 info!("prometheus metrics recorder installed");
573 Some(handle)
574 }
575 Err(_) => {
576 info!("prometheus metrics recorder already installed, reusing existing");
577 None
578 }
579 }
580 };
581
582 let mut scheduler = Self::start_scheduler(self.crons).await?;
583
584 let tracker = Arc::new(HandlerTracker::new(self.max_concurrent_handlers));
585 let router = Self::build_router(
586 self.webhooks,
587 tracker.clone(),
588 self.max_body_size,
589 #[cfg(feature = "prometheus")]
590 prom_handle,
591 );
592
593 let listener = tokio::net::TcpListener::bind(addr)
594 .await
595 .map_err(RuntimeError::Bind)?;
596 info!(addr = %addr, "ironflow runtime listening");
597
598 let graceful_shutdown = match self.custom_shutdown {
599 Some(signal) => signal,
600 None => Box::pin(shutdown_signal()),
601 };
602 axum::serve(listener, router)
603 .with_graceful_shutdown(graceful_shutdown)
604 .await
605 .map_err(RuntimeError::Serve)?;
606
607 info!("waiting for in-flight webhook handlers to complete");
609 tracker.wait().await;
610
611 info!("shutting down scheduler");
612 scheduler.shutdown().await.map_err(RuntimeError::Shutdown)?;
613 info!("ironflow runtime stopped");
614
615 Ok(())
616 }
617}
618
619impl Default for Runtime {
620 fn default() -> Self {
621 Self::new()
622 }
623}
624
625struct HandlerTracker {
630 semaphore: Arc<Semaphore>,
631 join_set: Mutex<JoinSet<()>>,
632}
633
634impl HandlerTracker {
635 fn new(max_concurrent: usize) -> Self {
636 Self {
637 semaphore: Arc::new(Semaphore::new(max_concurrent)),
638 join_set: Mutex::new(JoinSet::new()),
639 }
640 }
641
642 async fn spawn(&self, name: String, handler: WebhookHandler, ctx: WebhookContext) {
644 let semaphore = self.semaphore.clone();
645 let mut js = self.join_set.lock().await;
646 while let Some(result) = js.try_join_next() {
648 if let Err(e) = result {
649 error!(error = %e, "webhook handler panicked");
650 }
651 }
652 use tracing::Instrument;
653 let span = tracing::info_span!("webhook", path = %name);
654 js.spawn(
655 async move {
656 let _permit = semaphore
657 .acquire()
658 .await
659 .expect("semaphore closed unexpectedly");
660 info!("webhook workflow started");
661 handler(ctx).await;
662 info!("webhook workflow completed");
663 }
664 .instrument(span),
665 );
666 }
667
668 async fn wait(&self) {
670 let mut js = self.join_set.lock().await;
671 while let Some(result) = js.join_next().await {
672 if let Err(e) = result {
673 error!(error = %e, "webhook handler panicked");
674 }
675 }
676 }
677}
678
679#[derive(Clone)]
680struct WebhookState {
681 auth: Arc<WebhookAuth>,
682 handler: WebhookHandler,
683 name: Arc<str>,
684 tracker: Arc<HandlerTracker>,
685}
686
687async fn webhook_handler(
688 State(state): State<WebhookState>,
689 headers: HeaderMap,
690 body: Bytes,
691) -> StatusCode {
692 let name = &state.name;
693 if !state.auth.verify(&headers, &body) {
694 warn!(webhook = %name, "webhook auth failed");
695 #[cfg(feature = "prometheus")]
696 {
697 let label: String = name.to_string();
698 metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_REJECTED).increment(1);
699 }
700 return StatusCode::UNAUTHORIZED;
701 }
702
703 let payload: Value = match from_slice(&body) {
704 Ok(v) => v,
705 Err(e) => {
706 warn!(webhook = %name, error = %e, "invalid JSON body");
707 #[cfg(feature = "prometheus")]
708 {
709 let label: String = name.to_string();
710 metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_INVALID_BODY).increment(1);
711 }
712 return StatusCode::BAD_REQUEST;
713 }
714 };
715
716 #[cfg(feature = "prometheus")]
717 {
718 let label: String = name.to_string();
719 metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_ACCEPTED).increment(1);
720 }
721
722 let ctx = WebhookContext {
723 payload,
724 delivery_id: extract_delivery_id(&headers),
725 };
726
727 state
728 .tracker
729 .spawn(name.to_string(), state.handler.clone(), ctx)
730 .await;
731
732 StatusCode::ACCEPTED
733}
734
735async fn security_headers(
736 request: axum::http::Request<axum::body::Body>,
737 next: axum::middleware::Next,
738) -> axum::response::Response {
739 let mut response = next.run(request).await;
740 let headers = response.headers_mut();
741 headers.insert(
742 header::X_CONTENT_TYPE_OPTIONS,
743 "nosniff".parse().expect("valid header value"),
744 );
745 headers.insert(
746 header::X_FRAME_OPTIONS,
747 "DENY".parse().expect("valid header value"),
748 );
749 headers.insert(
750 "x-xss-protection",
751 "1; mode=block".parse().expect("valid header value"),
752 );
753 headers.insert(
754 header::STRICT_TRANSPORT_SECURITY,
755 "max-age=31536000; includeSubDomains"
756 .parse()
757 .expect("valid header value"),
758 );
759 headers.insert(
760 header::CONTENT_SECURITY_POLICY,
761 "default-src 'none'".parse().expect("valid header value"),
762 );
763 response
764}
765
766async fn shutdown_signal() {
767 let ctrl_c = async {
768 if let Err(e) = tokio::signal::ctrl_c().await {
769 warn!("failed to install ctrl+c handler: {e}");
770 }
771 };
772
773 #[cfg(unix)]
774 {
775 use tokio::signal::unix::{SignalKind, signal};
776 let mut sigterm =
777 signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
778 tokio::select! {
779 () = ctrl_c => info!("received SIGINT, shutting down"),
780 _ = sigterm.recv() => info!("received SIGTERM, shutting down"),
781 }
782 }
783
784 #[cfg(not(unix))]
785 {
786 ctrl_c.await;
787 info!("received ctrl+c, shutting down");
788 }
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794
795 #[test]
797 fn runtime_new_creates_with_defaults() {
798 let rt = Runtime::new();
799 assert_eq!(rt.webhooks.len(), 0);
800 assert_eq!(rt.crons.len(), 0);
801 assert_eq!(rt.max_body_size, DEFAULT_MAX_BODY_SIZE);
802 assert_eq!(rt.max_concurrent_handlers, DEFAULT_MAX_CONCURRENT_HANDLERS);
803 assert!(rt.custom_shutdown.is_none());
804 }
805
806 #[test]
808 fn runtime_default_equals_new() {
809 let rt_new = Runtime::new();
810 let rt_default = Runtime::default();
811 assert_eq!(rt_new.webhooks.len(), rt_default.webhooks.len());
812 assert_eq!(rt_new.crons.len(), rt_default.crons.len());
813 assert_eq!(rt_new.max_body_size, rt_default.max_body_size);
814 assert_eq!(
815 rt_new.max_concurrent_handlers,
816 rt_default.max_concurrent_handlers
817 );
818 }
819
820 #[test]
822 fn max_body_size_sets_value_and_returns_self() {
823 let rt = Runtime::new().max_body_size(512 * 1024);
824 assert_eq!(rt.max_body_size, 512 * 1024);
825 }
826
827 #[test]
829 fn max_body_size_chainable() {
830 let rt =
831 Runtime::new()
832 .max_body_size(1024)
833 .webhook("/test", WebhookAuth::none(), |_| async {});
834 assert_eq!(rt.max_body_size, 1024);
835 assert_eq!(rt.webhooks.len(), 1);
836 }
837
838 #[test]
840 fn max_body_size_can_be_zero() {
841 let rt = Runtime::new().max_body_size(0);
842 assert_eq!(rt.max_body_size, 0);
843 }
844
845 #[test]
847 fn max_body_size_can_be_large() {
848 let large_size = 1024 * 1024 * 1024; let rt = Runtime::new().max_body_size(large_size);
850 assert_eq!(rt.max_body_size, large_size);
851 }
852
853 #[test]
855 #[should_panic(expected = "max_concurrent_handlers must be greater than 0")]
856 fn max_concurrent_handlers_zero_panics() {
857 let _ = Runtime::new().max_concurrent_handlers(0);
858 }
859
860 #[test]
862 fn max_concurrent_handlers_sets_valid_values() {
863 let rt = Runtime::new().max_concurrent_handlers(16);
864 assert_eq!(rt.max_concurrent_handlers, 16);
865 }
866
867 #[test]
869 fn max_concurrent_handlers_one_is_valid() {
870 let rt = Runtime::new().max_concurrent_handlers(1);
871 assert_eq!(rt.max_concurrent_handlers, 1);
872 }
873
874 #[test]
876 fn max_concurrent_handlers_large_value_is_valid() {
877 let large_limit = 10000;
878 let rt = Runtime::new().max_concurrent_handlers(large_limit);
879 assert_eq!(rt.max_concurrent_handlers, large_limit);
880 }
881
882 #[test]
884 fn max_concurrent_handlers_chainable() {
885 let rt = Runtime::new().max_concurrent_handlers(32).webhook(
886 "/test",
887 WebhookAuth::none(),
888 |_| async {},
889 );
890 assert_eq!(rt.max_concurrent_handlers, 32);
891 assert_eq!(rt.webhooks.len(), 1);
892 }
893
894 #[tokio::test]
896 async fn with_shutdown_sets_signal_and_returns_self() {
897 let (tx, rx) = tokio::sync::oneshot::channel();
898 let rt = Runtime::new().with_shutdown(async move {
899 let _ = rx.await;
900 });
901 assert!(rt.custom_shutdown.is_some());
902
903 let _ = tx.send(());
905 }
906
907 #[tokio::test]
909 async fn with_shutdown_chainable() {
910 let (tx, rx) = tokio::sync::oneshot::channel();
911 let rt = Runtime::new()
912 .with_shutdown(async move {
913 let _ = rx.await;
914 })
915 .webhook("/test", WebhookAuth::none(), |_| async {});
916 assert!(rt.custom_shutdown.is_some());
917 assert_eq!(rt.webhooks.len(), 1);
918
919 let _ = tx.send(());
920 }
921
922 #[test]
924 fn webhook_registers_route_and_returns_self() {
925 let rt = Runtime::new().webhook("/hooks/test", WebhookAuth::none(), |_| async {});
926 assert_eq!(rt.webhooks.len(), 1);
927 assert_eq!(rt.webhooks[0].path, "/hooks/test");
928 }
929
930 #[test]
932 #[should_panic(expected = "webhook path must start with '/'")]
933 fn webhook_path_without_slash_panics() {
934 let _ = Runtime::new().webhook("no-slash", WebhookAuth::none(), |_| async {});
935 }
936
937 #[test]
939 fn webhook_accepts_valid_paths() {
940 let rt = Runtime::new()
941 .webhook("/", WebhookAuth::none(), |_| async {})
942 .webhook("/simple", WebhookAuth::none(), |_| async {})
943 .webhook("/nested/path", WebhookAuth::none(), |_| async {})
944 .webhook("/with-dashes", WebhookAuth::none(), |_| async {})
945 .webhook("/with_underscores", WebhookAuth::none(), |_| async {})
946 .webhook("/with/numbers/123", WebhookAuth::none(), |_| async {});
947 assert_eq!(rt.webhooks.len(), 6);
948 }
949
950 #[test]
952 fn webhook_chainable() {
953 let rt = Runtime::new()
954 .webhook("/hook-a", WebhookAuth::none(), |_| async {})
955 .webhook("/hook-b", WebhookAuth::none(), |_| async {})
956 .webhook("/hook-c", WebhookAuth::none(), |_| async {});
957 assert_eq!(rt.webhooks.len(), 3);
958 assert_eq!(rt.webhooks[0].path, "/hook-a");
959 assert_eq!(rt.webhooks[1].path, "/hook-b");
960 assert_eq!(rt.webhooks[2].path, "/hook-c");
961 }
962
963 #[test]
965 fn webhook_with_various_auth_types() {
966 let rt = Runtime::new()
967 .webhook("/none", WebhookAuth::none(), |_| async {})
968 .webhook(
969 "/header",
970 WebhookAuth::header("x-api-key", "secret"),
971 |_| async {},
972 )
973 .webhook("/github", WebhookAuth::github("secret"), |_| async {})
974 .webhook("/gitlab", WebhookAuth::gitlab("token"), |_| async {});
975 assert_eq!(rt.webhooks.len(), 4);
976 }
977
978 #[test]
980 fn cron_registers_job_and_returns_self() {
981 let rt = Runtime::new().cron("0 0 * * * *", "daily-task", || async {});
982 assert_eq!(rt.crons.len(), 1);
983 assert_eq!(rt.crons[0].name, "daily-task");
984 assert_eq!(rt.crons[0].schedule, "0 0 * * * *");
985 }
986
987 #[test]
989 fn cron_chainable() {
990 let rt = Runtime::new()
991 .cron("0 0 * * * *", "midnight", || async {})
992 .cron("0 */5 * * * *", "every-5-minutes", || async {});
993 assert_eq!(rt.crons.len(), 2);
994 }
995
996 #[test]
998 fn cron_preserves_schedule_and_name() {
999 let rt = Runtime::new()
1000 .cron("0 12 * * * MON", "noon-mondays", || async {})
1001 .cron("0 0 1 * * *", "first-of-month", || async {});
1002 assert_eq!(rt.crons[0].name, "noon-mondays");
1003 assert_eq!(rt.crons[0].schedule, "0 12 * * * MON");
1004 assert_eq!(rt.crons[1].name, "first-of-month");
1005 assert_eq!(rt.crons[1].schedule, "0 0 1 * * *");
1006 }
1007
1008 #[test]
1010 fn into_router_returns_router() {
1011 let rt = Runtime::new();
1012 let _router = rt.into_router();
1013 }
1015
1016 #[test]
1018 fn into_router_with_webhooks_returns_router() {
1019 let rt = Runtime::new()
1020 .webhook("/hook-a", WebhookAuth::none(), |_| async {})
1021 .webhook("/hook-b", WebhookAuth::github("secret"), |_| async {});
1022 let _router = rt.into_router();
1023 }
1025
1026 #[test]
1028 fn into_router_with_crons_returns_router() {
1029 let rt = Runtime::new()
1030 .cron("0 0 * * * *", "daily", || async {})
1031 .cron("0 */5 * * * *", "every-5-min", || async {});
1032 let _router = rt.into_router();
1033 }
1035
1036 #[test]
1038 fn into_router_respects_max_body_size_config() {
1039 let rt =
1040 Runtime::new()
1041 .max_body_size(100)
1042 .webhook("/hook", WebhookAuth::none(), |_| async {});
1043 let _router = rt.into_router();
1044 }
1046
1047 #[test]
1049 fn into_router_respects_max_concurrent_handlers_config() {
1050 let rt = Runtime::new().max_concurrent_handlers(16).webhook(
1051 "/hook",
1052 WebhookAuth::none(),
1053 |_| async {},
1054 );
1055 let _router = rt.into_router();
1056 }
1058
1059 #[test]
1061 fn builder_chain_multiple_methods() {
1062 let rt = Runtime::new()
1063 .max_body_size(512 * 1024)
1064 .max_concurrent_handlers(32)
1065 .webhook("/hook-a", WebhookAuth::none(), |_| async {})
1066 .webhook("/hook-b", WebhookAuth::github("secret"), |_| async {})
1067 .cron("0 0 * * * *", "daily", || async {});
1068
1069 assert_eq!(rt.max_body_size, 512 * 1024);
1070 assert_eq!(rt.max_concurrent_handlers, 32);
1071 assert_eq!(rt.webhooks.len(), 2);
1072 assert_eq!(rt.crons.len(), 1);
1073 }
1074
1075 #[test]
1077 fn into_router_with_crons_doesnt_start_them() {
1078 let rt = Runtime::new().cron("0 0 * * * *", "test-cron", || async {});
1079 let _router = rt.into_router();
1081 }
1082}