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 tracing::{error, info, warn};
26
27use tokio_util::sync::CancellationToken;
28
29use crate::error::RuntimeError;
30use crate::trigger::{Trigger, TriggerEvent, TriggerSink};
31use crate::webhook::{WebhookAuth, extract_delivery_id};
32
33const DEFAULT_TRIGGER_CHANNEL_SIZE: usize = 256;
35
36type TriggerHandler =
38 Arc<dyn Fn(TriggerEvent) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
39
40const DEFAULT_MAX_BODY_SIZE: usize = 2 * 1024 * 1024;
42
43const DEFAULT_MAX_CONCURRENT_HANDLERS: usize = 64;
45
46type WebhookHandler =
47 Arc<dyn Fn(WebhookContext) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
48type ShutdownSignal = Pin<Box<dyn Future<Output = ()> + Send>>;
49
50#[derive(Debug, Clone)]
66pub struct WebhookContext {
67 pub payload: Value,
69 pub delivery_id: Option<String>,
74}
75
76#[cfg(feature = "prometheus")]
78mod metric_names {
79 pub const WEBHOOK_RECEIVED_TOTAL: &str = "ironflow_webhook_received_total";
80
81 pub const AUTH_REJECTED: &str = "rejected";
82 pub const AUTH_ACCEPTED: &str = "accepted";
83 pub const AUTH_INVALID_BODY: &str = "invalid_body";
84}
85
86struct WebhookRoute {
87 path: String,
88 auth: WebhookAuth,
89 handler: WebhookHandler,
90}
91
92pub struct Runtime {
123 webhooks: Vec<WebhookRoute>,
124 triggers: Vec<Box<dyn Trigger>>,
125 trigger_handler: Option<TriggerHandler>,
126 max_body_size: usize,
127 max_concurrent_handlers: usize,
128 custom_shutdown: Option<ShutdownSignal>,
129}
130
131impl Runtime {
132 pub fn new() -> Self {
142 Self {
143 webhooks: Vec::new(),
144 triggers: Vec::new(),
145 trigger_handler: None,
146 max_body_size: DEFAULT_MAX_BODY_SIZE,
147 max_concurrent_handlers: DEFAULT_MAX_CONCURRENT_HANDLERS,
148 custom_shutdown: None,
149 }
150 }
151
152 pub fn max_body_size(mut self, bytes: usize) -> Self {
165 self.max_body_size = bytes;
166 self
167 }
168
169 pub fn max_concurrent_handlers(mut self, limit: usize) -> Self {
187 assert!(limit > 0, "max_concurrent_handlers must be greater than 0");
188 self.max_concurrent_handlers = limit;
189 self
190 }
191
192 pub fn with_shutdown<F>(mut self, signal: F) -> Self
221 where
222 F: Future<Output = ()> + Send + 'static,
223 {
224 self.custom_shutdown = Some(Box::pin(signal));
225 self
226 }
227
228 pub fn webhook<F, Fut>(self, path: &str, auth: WebhookAuth, handler: F) -> Self
252 where
253 F: Fn(Value) -> Fut + Send + Sync + Clone + 'static,
254 Fut: Future<Output = ()> + Send + 'static,
255 {
256 self.webhook_with_context(path, auth, move |ctx| handler(ctx.payload))
257 }
258
259 pub fn webhook_with_context<F, Fut>(mut self, path: &str, auth: WebhookAuth, handler: F) -> Self
292 where
293 F: Fn(WebhookContext) -> Fut + Send + Sync + Clone + 'static,
294 Fut: Future<Output = ()> + Send + 'static,
295 {
296 assert!(
297 path.starts_with('/'),
298 "webhook path must start with '/', got: {path}"
299 );
300 if matches!(auth, WebhookAuth::None) {
301 warn!(path = %path, "webhook registered with WebhookAuth::None - all requests will be accepted without authentication");
302 }
303 let handler: WebhookHandler = Arc::new(move |ctx| {
304 let handler = handler.clone();
305 Box::pin(async move { handler(ctx).await })
306 });
307 self.webhooks.push(WebhookRoute {
308 path: path.to_string(),
309 auth,
310 handler,
311 });
312 self
313 }
314
315 pub fn trigger(mut self, trigger: impl Trigger + 'static) -> Self {
342 self.triggers.push(Box::new(trigger));
343 self
344 }
345
346 pub fn on_trigger<F, Fut>(mut self, handler: F) -> Self
362 where
363 F: Fn(TriggerEvent) -> Fut + Send + Sync + 'static,
364 Fut: Future<Output = ()> + Send + 'static,
365 {
366 self.trigger_handler = Some(Arc::new(move |event| Box::pin(handler(event))));
367 self
368 }
369
370 fn build_router(
376 webhooks: Vec<WebhookRoute>,
377 handler_tracker: Arc<HandlerTracker>,
378 max_body_size: usize,
379 #[cfg(feature = "prometheus")] prom_handle: Option<
380 metrics_exporter_prometheus::PrometheusHandle,
381 >,
382 ) -> Router {
383 let mut router = Router::new();
384
385 for webhook in webhooks {
386 let auth = Arc::new(webhook.auth);
387 let handler = webhook.handler;
388 let path = webhook.path.clone();
389
390 let name: Arc<str> = Arc::from(path.as_str());
391 let route_state = WebhookState {
392 auth,
393 handler,
394 name,
395 tracker: handler_tracker.clone(),
396 };
397
398 router = router.route(&path, post(webhook_handler).with_state(route_state));
399 info!(path = %path, "registered webhook");
400 }
401
402 router = router.route("/health", get(|| async { "ok" }));
403
404 #[cfg(feature = "prometheus")]
405 if let Some(handle) = prom_handle {
406 router = router.route(
407 "/metrics",
408 get(move || {
409 let h = handle.clone();
410 async move { h.render() }
411 }),
412 );
413 info!("registered /metrics endpoint");
414 }
415
416 router
417 .layer(middleware::from_fn(security_headers))
418 .layer(DefaultBodyLimit::max(max_body_size))
419 }
420
421 pub fn into_router(self) -> Router {
436 let tracker = Arc::new(HandlerTracker::new(self.max_concurrent_handlers));
437 Self::build_router(
438 self.webhooks,
439 tracker,
440 self.max_body_size,
441 #[cfg(feature = "prometheus")]
442 None,
443 )
444 }
445
446 pub async fn serve(self, addr: &str) -> Result<(), RuntimeError> {
478 let _ = dotenvy::dotenv();
479
480 #[cfg(feature = "prometheus")]
481 let prom_handle = {
482 match metrics_exporter_prometheus::PrometheusBuilder::new().install_recorder() {
483 Ok(handle) => {
484 info!("prometheus metrics recorder installed");
485 Some(handle)
486 }
487 Err(_) => {
488 info!("prometheus metrics recorder already installed, reusing existing");
489 None
490 }
491 }
492 };
493
494 let trigger_token = CancellationToken::new();
496 let trigger_handles =
497 Self::start_triggers(self.triggers, self.trigger_handler, trigger_token.clone());
498
499 let tracker = Arc::new(HandlerTracker::new(self.max_concurrent_handlers));
500 let router = Self::build_router(
501 self.webhooks,
502 tracker.clone(),
503 self.max_body_size,
504 #[cfg(feature = "prometheus")]
505 prom_handle,
506 );
507
508 let listener = tokio::net::TcpListener::bind(addr)
509 .await
510 .map_err(RuntimeError::Bind)?;
511 info!(addr = %addr, "ironflow runtime listening");
512
513 let graceful_shutdown = match self.custom_shutdown {
514 Some(signal) => signal,
515 None => Box::pin(shutdown_signal()),
516 };
517 axum::serve(listener, router)
518 .with_graceful_shutdown(graceful_shutdown)
519 .await
520 .map_err(RuntimeError::Serve)?;
521
522 info!("waiting for in-flight webhook handlers to complete");
524 tracker.wait().await;
525
526 info!("stopping triggers");
528 trigger_token.cancel();
529 for handle in trigger_handles {
530 if let Err(e) = handle.await {
531 error!(error = %e, "trigger task panicked");
532 }
533 }
534
535 info!("ironflow runtime stopped");
536
537 Ok(())
538 }
539
540 fn start_triggers(
544 triggers: Vec<Box<dyn Trigger>>,
545 handler: Option<TriggerHandler>,
546 token: CancellationToken,
547 ) -> Vec<tokio::task::JoinHandle<()>> {
548 if triggers.is_empty() {
549 return Vec::new();
550 }
551
552 let (sink, mut rx) = TriggerSink::channel(DEFAULT_TRIGGER_CHANNEL_SIZE);
553 let mut handles = Vec::new();
554
555 for trigger in triggers {
556 let sink = sink.clone();
557 let token = token.clone();
558 let name = trigger.name().to_string();
559 let handle = tokio::spawn(async move {
560 info!(trigger = %name, "starting trigger");
561 if let Err(e) = trigger.start(sink, &token).await {
562 error!(trigger = %name, error = %e, "trigger failed");
563 }
564 info!(trigger = %name, "trigger stopped");
565 });
566 handles.push(handle);
567 }
568
569 if let Some(handler) = handler {
571 let dispatch_token = token.clone();
572 let dispatch_handle = tokio::spawn(async move {
573 loop {
574 tokio::select! {
575 _ = dispatch_token.cancelled() => break,
576 event = rx.recv() => {
577 let Some(event) = event else { break };
578 info!(
579 workflow = %event.workflow_name,
580 "trigger event received, dispatching"
581 );
582 handler(event).await;
583 }
584 }
585 }
586 });
587 handles.push(dispatch_handle);
588 } else if !handles.is_empty() {
589 warn!(
590 "triggers registered but no on_trigger handler set - trigger events will be dropped"
591 );
592 }
593
594 handles
595 }
596}
597
598impl Default for Runtime {
599 fn default() -> Self {
600 Self::new()
601 }
602}
603
604struct HandlerTracker {
609 semaphore: Arc<Semaphore>,
610 join_set: Mutex<JoinSet<()>>,
611}
612
613impl HandlerTracker {
614 fn new(max_concurrent: usize) -> Self {
615 Self {
616 semaphore: Arc::new(Semaphore::new(max_concurrent)),
617 join_set: Mutex::new(JoinSet::new()),
618 }
619 }
620
621 async fn spawn(&self, name: String, handler: WebhookHandler, ctx: WebhookContext) {
623 let semaphore = self.semaphore.clone();
624 let mut js = self.join_set.lock().await;
625 while let Some(result) = js.try_join_next() {
627 if let Err(e) = result {
628 error!(error = %e, "webhook handler panicked");
629 }
630 }
631 use tracing::Instrument;
632 let span = tracing::info_span!("webhook", path = %name);
633 js.spawn(
634 async move {
635 let _permit = semaphore
636 .acquire()
637 .await
638 .expect("semaphore closed unexpectedly");
639 info!("webhook workflow started");
640 handler(ctx).await;
641 info!("webhook workflow completed");
642 }
643 .instrument(span),
644 );
645 }
646
647 async fn wait(&self) {
649 let mut js = self.join_set.lock().await;
650 while let Some(result) = js.join_next().await {
651 if let Err(e) = result {
652 error!(error = %e, "webhook handler panicked");
653 }
654 }
655 }
656}
657
658#[derive(Clone)]
659struct WebhookState {
660 auth: Arc<WebhookAuth>,
661 handler: WebhookHandler,
662 name: Arc<str>,
663 tracker: Arc<HandlerTracker>,
664}
665
666async fn webhook_handler(
667 State(state): State<WebhookState>,
668 headers: HeaderMap,
669 body: Bytes,
670) -> StatusCode {
671 let name = &state.name;
672 if !state.auth.verify(&headers, &body) {
673 warn!(webhook = %name, "webhook auth failed");
674 #[cfg(feature = "prometheus")]
675 {
676 let label: String = name.to_string();
677 metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_REJECTED).increment(1);
678 }
679 return StatusCode::UNAUTHORIZED;
680 }
681
682 let payload: Value = match from_slice(&body) {
683 Ok(v) => v,
684 Err(e) => {
685 warn!(webhook = %name, error = %e, "invalid JSON body");
686 #[cfg(feature = "prometheus")]
687 {
688 let label: String = name.to_string();
689 metrics::counter!(metric_names::WEBHOOK_RECEIVED_TOTAL, "path" => label, "auth" => metric_names::AUTH_INVALID_BODY).increment(1);
690 }
691 return StatusCode::BAD_REQUEST;
692 }
693 };
694
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_ACCEPTED).increment(1);
699 }
700
701 let ctx = WebhookContext {
702 payload,
703 delivery_id: extract_delivery_id(&headers),
704 };
705
706 state
707 .tracker
708 .spawn(name.to_string(), state.handler.clone(), ctx)
709 .await;
710
711 StatusCode::ACCEPTED
712}
713
714async fn security_headers(
715 request: axum::http::Request<axum::body::Body>,
716 next: axum::middleware::Next,
717) -> axum::response::Response {
718 let mut response = next.run(request).await;
719 let headers = response.headers_mut();
720 headers.insert(
721 header::X_CONTENT_TYPE_OPTIONS,
722 "nosniff".parse().expect("valid header value"),
723 );
724 headers.insert(
725 header::X_FRAME_OPTIONS,
726 "DENY".parse().expect("valid header value"),
727 );
728 headers.insert(
729 "x-xss-protection",
730 "1; mode=block".parse().expect("valid header value"),
731 );
732 headers.insert(
733 header::STRICT_TRANSPORT_SECURITY,
734 "max-age=31536000; includeSubDomains"
735 .parse()
736 .expect("valid header value"),
737 );
738 headers.insert(
739 header::CONTENT_SECURITY_POLICY,
740 "default-src 'none'".parse().expect("valid header value"),
741 );
742 response
743}
744
745async fn shutdown_signal() {
746 let ctrl_c = async {
747 if let Err(e) = tokio::signal::ctrl_c().await {
748 warn!("failed to install ctrl+c handler: {e}");
749 }
750 };
751
752 #[cfg(unix)]
753 {
754 use tokio::signal::unix::{SignalKind, signal};
755 let mut sigterm =
756 signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
757 tokio::select! {
758 () = ctrl_c => info!("received SIGINT, shutting down"),
759 _ = sigterm.recv() => info!("received SIGTERM, shutting down"),
760 }
761 }
762
763 #[cfg(not(unix))]
764 {
765 ctrl_c.await;
766 info!("received ctrl+c, shutting down");
767 }
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773
774 #[test]
776 fn runtime_new_creates_with_defaults() {
777 let rt = Runtime::new();
778 assert_eq!(rt.webhooks.len(), 0);
779 assert_eq!(rt.max_body_size, DEFAULT_MAX_BODY_SIZE);
780 assert_eq!(rt.max_concurrent_handlers, DEFAULT_MAX_CONCURRENT_HANDLERS);
781 assert!(rt.custom_shutdown.is_none());
782 }
783
784 #[test]
786 fn runtime_default_equals_new() {
787 let rt_new = Runtime::new();
788 let rt_default = Runtime::default();
789 assert_eq!(rt_new.webhooks.len(), rt_default.webhooks.len());
790 assert_eq!(rt_new.max_body_size, rt_default.max_body_size);
791 assert_eq!(
792 rt_new.max_concurrent_handlers,
793 rt_default.max_concurrent_handlers
794 );
795 }
796
797 #[test]
799 fn max_body_size_sets_value_and_returns_self() {
800 let rt = Runtime::new().max_body_size(512 * 1024);
801 assert_eq!(rt.max_body_size, 512 * 1024);
802 }
803
804 #[test]
806 fn max_body_size_chainable() {
807 let rt =
808 Runtime::new()
809 .max_body_size(1024)
810 .webhook("/test", WebhookAuth::none(), |_| async {});
811 assert_eq!(rt.max_body_size, 1024);
812 assert_eq!(rt.webhooks.len(), 1);
813 }
814
815 #[test]
817 fn max_body_size_can_be_zero() {
818 let rt = Runtime::new().max_body_size(0);
819 assert_eq!(rt.max_body_size, 0);
820 }
821
822 #[test]
824 fn max_body_size_can_be_large() {
825 let large_size = 1024 * 1024 * 1024; let rt = Runtime::new().max_body_size(large_size);
827 assert_eq!(rt.max_body_size, large_size);
828 }
829
830 #[test]
832 #[should_panic(expected = "max_concurrent_handlers must be greater than 0")]
833 fn max_concurrent_handlers_zero_panics() {
834 let _ = Runtime::new().max_concurrent_handlers(0);
835 }
836
837 #[test]
839 fn max_concurrent_handlers_sets_valid_values() {
840 let rt = Runtime::new().max_concurrent_handlers(16);
841 assert_eq!(rt.max_concurrent_handlers, 16);
842 }
843
844 #[test]
846 fn max_concurrent_handlers_one_is_valid() {
847 let rt = Runtime::new().max_concurrent_handlers(1);
848 assert_eq!(rt.max_concurrent_handlers, 1);
849 }
850
851 #[test]
853 fn max_concurrent_handlers_large_value_is_valid() {
854 let large_limit = 10000;
855 let rt = Runtime::new().max_concurrent_handlers(large_limit);
856 assert_eq!(rt.max_concurrent_handlers, large_limit);
857 }
858
859 #[test]
861 fn max_concurrent_handlers_chainable() {
862 let rt = Runtime::new().max_concurrent_handlers(32).webhook(
863 "/test",
864 WebhookAuth::none(),
865 |_| async {},
866 );
867 assert_eq!(rt.max_concurrent_handlers, 32);
868 assert_eq!(rt.webhooks.len(), 1);
869 }
870
871 #[tokio::test]
873 async fn with_shutdown_sets_signal_and_returns_self() {
874 let (tx, rx) = tokio::sync::oneshot::channel();
875 let rt = Runtime::new().with_shutdown(async move {
876 let _ = rx.await;
877 });
878 assert!(rt.custom_shutdown.is_some());
879
880 let _ = tx.send(());
882 }
883
884 #[tokio::test]
886 async fn with_shutdown_chainable() {
887 let (tx, rx) = tokio::sync::oneshot::channel();
888 let rt = Runtime::new()
889 .with_shutdown(async move {
890 let _ = rx.await;
891 })
892 .webhook("/test", WebhookAuth::none(), |_| async {});
893 assert!(rt.custom_shutdown.is_some());
894 assert_eq!(rt.webhooks.len(), 1);
895
896 let _ = tx.send(());
897 }
898
899 #[test]
901 fn webhook_registers_route_and_returns_self() {
902 let rt = Runtime::new().webhook("/hooks/test", WebhookAuth::none(), |_| async {});
903 assert_eq!(rt.webhooks.len(), 1);
904 assert_eq!(rt.webhooks[0].path, "/hooks/test");
905 }
906
907 #[test]
909 #[should_panic(expected = "webhook path must start with '/'")]
910 fn webhook_path_without_slash_panics() {
911 let _ = Runtime::new().webhook("no-slash", WebhookAuth::none(), |_| async {});
912 }
913
914 #[test]
916 fn webhook_accepts_valid_paths() {
917 let rt = Runtime::new()
918 .webhook("/", WebhookAuth::none(), |_| async {})
919 .webhook("/simple", WebhookAuth::none(), |_| async {})
920 .webhook("/nested/path", WebhookAuth::none(), |_| async {})
921 .webhook("/with-dashes", WebhookAuth::none(), |_| async {})
922 .webhook("/with_underscores", WebhookAuth::none(), |_| async {})
923 .webhook("/with/numbers/123", WebhookAuth::none(), |_| async {});
924 assert_eq!(rt.webhooks.len(), 6);
925 }
926
927 #[test]
929 fn webhook_chainable() {
930 let rt = Runtime::new()
931 .webhook("/hook-a", WebhookAuth::none(), |_| async {})
932 .webhook("/hook-b", WebhookAuth::none(), |_| async {})
933 .webhook("/hook-c", WebhookAuth::none(), |_| async {});
934 assert_eq!(rt.webhooks.len(), 3);
935 assert_eq!(rt.webhooks[0].path, "/hook-a");
936 assert_eq!(rt.webhooks[1].path, "/hook-b");
937 assert_eq!(rt.webhooks[2].path, "/hook-c");
938 }
939
940 #[test]
942 fn webhook_with_various_auth_types() {
943 let rt = Runtime::new()
944 .webhook("/none", WebhookAuth::none(), |_| async {})
945 .webhook(
946 "/header",
947 WebhookAuth::header("x-api-key", "secret"),
948 |_| async {},
949 )
950 .webhook("/github", WebhookAuth::github("secret"), |_| async {})
951 .webhook("/gitlab", WebhookAuth::gitlab("token"), |_| async {});
952 assert_eq!(rt.webhooks.len(), 4);
953 }
954
955 #[test]
957 fn into_router_returns_router() {
958 let rt = Runtime::new();
959 let _router = rt.into_router();
960 }
962
963 #[test]
965 fn into_router_with_webhooks_returns_router() {
966 let rt = Runtime::new()
967 .webhook("/hook-a", WebhookAuth::none(), |_| async {})
968 .webhook("/hook-b", WebhookAuth::github("secret"), |_| async {});
969 let _router = rt.into_router();
970 }
972
973 #[test]
975 fn into_router_respects_max_body_size_config() {
976 let rt =
977 Runtime::new()
978 .max_body_size(100)
979 .webhook("/hook", WebhookAuth::none(), |_| async {});
980 let _router = rt.into_router();
981 }
983
984 #[test]
986 fn into_router_respects_max_concurrent_handlers_config() {
987 let rt = Runtime::new().max_concurrent_handlers(16).webhook(
988 "/hook",
989 WebhookAuth::none(),
990 |_| async {},
991 );
992 let _router = rt.into_router();
993 }
995
996 #[test]
998 fn builder_chain_multiple_methods() {
999 let rt = Runtime::new()
1000 .max_body_size(512 * 1024)
1001 .max_concurrent_handlers(32)
1002 .webhook("/hook-a", WebhookAuth::none(), |_| async {})
1003 .webhook("/hook-b", WebhookAuth::github("secret"), |_| async {});
1004
1005 assert_eq!(rt.max_body_size, 512 * 1024);
1006 assert_eq!(rt.max_concurrent_handlers, 32);
1007 assert_eq!(rt.webhooks.len(), 2);
1008 }
1009}