Skip to main content

ironflow_runtime/
runtime.rs

1//! The runtime server builder and HTTP serving logic.
2//!
3//! [`Runtime`] is the central entry-point for configuring and launching an
4//! ironflow daemon. It uses a builder pattern to register webhook routes,
5//! then starts an [Axum](https://docs.rs/axum) HTTP server with graceful
6//! shutdown on `Ctrl+C`.
7//!
8//! Webhook handlers are executed in the background via [`tokio::spawn`], so
9//! the HTTP endpoint responds with **202 Accepted** immediately while the
10//! workflow runs asynchronously.
11
12use 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
33/// Default buffer size for the trigger event channel.
34const DEFAULT_TRIGGER_CHANNEL_SIZE: usize = 256;
35
36/// Callback invoked when a [`Trigger`] emits a [`TriggerEvent`].
37type TriggerHandler =
38    Arc<dyn Fn(TriggerEvent) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
39
40/// Default maximum body size for webhook payloads (2 MiB).
41const DEFAULT_MAX_BODY_SIZE: usize = 2 * 1024 * 1024;
42
43/// Default maximum number of concurrently running webhook handlers.
44const 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/// What a webhook handler registered via [`Runtime::webhook_with_context`] receives.
51///
52/// # Examples
53///
54/// ```no_run
55/// use ironflow_runtime::prelude::*;
56///
57/// let runtime = Runtime::new().webhook_with_context(
58///     "/hooks/github",
59///     WebhookAuth::github("secret"),
60///     |ctx: WebhookContext| async move {
61///         println!("{} from {:?}", ctx.payload, ctx.delivery_id);
62///     },
63/// );
64/// ```
65#[derive(Debug, Clone)]
66pub struct WebhookContext {
67    /// The parsed JSON body.
68    pub payload: Value,
69    /// Provider delivery identifier, already prefixed (`github:…`, `gitlab:…`).
70    ///
71    /// `None` when the provider sent no known delivery header. Suitable as-is
72    /// for the `Idempotency-Key` header of `POST /api/v1/runs`.
73    pub delivery_id: Option<String>,
74}
75
76/// Metric name constants for the runtime (webhook).
77#[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
92/// The ironflow runtime server builder.
93///
94/// `Runtime` uses a builder pattern: create one with [`Runtime::new`], register
95/// webhook routes with [`Runtime::webhook`], then call [`Runtime::serve`] to
96/// start the HTTP server.
97///
98/// # Built-in endpoints
99///
100/// | Method | Path | Description |
101/// |--------|------|-------------|
102/// | `GET`  | `/health` | Returns `200 OK` with body `"ok"`. Useful for load-balancer health checks. |
103/// | `POST` | *user-defined* | Webhook endpoints registered via [`Runtime::webhook`]. |
104///
105/// # Examples
106///
107/// ```no_run
108/// use ironflow_runtime::prelude::*;
109///
110/// #[tokio::main]
111/// async fn main() -> Result<(), ironflow_runtime::error::RuntimeError> {
112///     Runtime::new()
113///         .webhook("/hooks/deploy", WebhookAuth::github("secret"), |payload| async move {
114///             println!("deploy triggered: {payload}");
115///         })
116///         .serve("0.0.0.0:3000")
117///         .await?;
118///
119///     Ok(())
120/// }
121/// ```
122pub 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    /// Creates a new, empty `Runtime` with no webhooks.
133    ///
134    /// # Examples
135    ///
136    /// ```no_run
137    /// use ironflow_runtime::runtime::Runtime;
138    ///
139    /// let runtime = Runtime::new();
140    /// ```
141    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    /// Set the maximum allowed body size for webhook payloads.
153    ///
154    /// Requests exceeding this limit are rejected by axum before reaching the
155    /// handler. Defaults to 2 MiB.
156    ///
157    /// # Examples
158    ///
159    /// ```no_run
160    /// use ironflow_runtime::runtime::Runtime;
161    ///
162    /// let runtime = Runtime::new().max_body_size(512 * 1024); // 512 KiB
163    /// ```
164    pub fn max_body_size(mut self, bytes: usize) -> Self {
165        self.max_body_size = bytes;
166        self
167    }
168
169    /// Set the maximum number of concurrently running webhook handlers.
170    ///
171    /// When the limit is reached, new webhook requests still receive
172    /// **202 Accepted** but their handlers are queued until a slot is
173    /// available. Defaults to 64.
174    ///
175    /// # Panics
176    ///
177    /// Panics if `limit` is `0`.
178    ///
179    /// # Examples
180    ///
181    /// ```no_run
182    /// use ironflow_runtime::runtime::Runtime;
183    ///
184    /// let runtime = Runtime::new().max_concurrent_handlers(16);
185    /// ```
186    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    /// Override the default shutdown signal (`Ctrl+C` / `SIGTERM`).
193    ///
194    /// By default, [`Runtime::serve`] blocks until the process receives
195    /// `Ctrl+C` or `SIGTERM`. Use this method to provide a custom future
196    /// that resolves when the runtime should shut down.
197    ///
198    /// This is useful in tests where you want to trigger a clean shutdown
199    /// without relying on OS signals.
200    ///
201    /// # Examples
202    ///
203    /// ```no_run
204    /// use ironflow_runtime::runtime::Runtime;
205    /// use ironflow_runtime::prelude::*;
206    /// use tokio::sync::oneshot;
207    ///
208    /// # async fn example() -> Result<(), ironflow_runtime::error::RuntimeError> {
209    /// let (tx, rx) = oneshot::channel::<()>();
210    ///
211    /// let rt = Runtime::new()
212    ///     .with_shutdown(async { let _ = rx.await; })
213    ///     .webhook("/hooks/test", WebhookAuth::none(), |_| async {});
214    ///
215    /// // In another task: tx.send(()) to trigger shutdown.
216    /// rt.serve("127.0.0.1:3000").await?;
217    /// # Ok(())
218    /// # }
219    /// ```
220    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    /// Registers a webhook route.
229    ///
230    /// The handler receives the parsed JSON body as a [`serde_json::Value`].
231    /// When a request arrives, the server verifies authentication using `auth`,
232    /// then spawns the handler in the background and immediately returns
233    /// **202 Accepted** to the caller.
234    ///
235    /// # Arguments
236    ///
237    /// * `path` - The URL path to listen on (e.g. `"/hooks/github"`).
238    /// * `auth` - The [`WebhookAuth`] strategy for this endpoint.
239    /// * `handler` - An async function receiving the JSON payload.
240    ///
241    /// # Examples
242    ///
243    /// ```no_run
244    /// use ironflow_runtime::prelude::*;
245    ///
246    /// let runtime = Runtime::new()
247    ///     .webhook("/hooks/github", WebhookAuth::github("secret"), |payload| async move {
248    ///         println!("payload: {payload}");
249    ///     });
250    /// ```
251    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    /// Registers a webhook route whose handler also sees the delivery identifier.
260    ///
261    /// Identical to [`Runtime::webhook`], except the handler receives a
262    /// [`WebhookContext`] instead of the bare payload. Use this when the workflow
263    /// should be enqueued idempotently: replaying the same provider delivery then
264    /// resolves to the run it already created rather than a duplicate.
265    ///
266    /// # Arguments
267    ///
268    /// * `path` - The URL path to listen on (e.g. `"/hooks/github"`).
269    /// * `auth` - The [`WebhookAuth`] strategy for this endpoint.
270    /// * `handler` - An async function receiving the [`WebhookContext`].
271    ///
272    /// # Panics
273    ///
274    /// Panics if `path` does not start with `/`.
275    ///
276    /// # Examples
277    ///
278    /// ```no_run
279    /// use ironflow_runtime::prelude::*;
280    ///
281    /// let runtime = Runtime::new().webhook_with_context(
282    ///     "/hooks/github",
283    ///     WebhookAuth::github("secret"),
284    ///     |ctx| async move {
285    ///         // `ctx.delivery_id` is `Some("github:<X-GitHub-Delivery>")` when
286    ///         // the provider stamped the request.
287    ///         println!("delivery: {:?}", ctx.delivery_id);
288    ///     },
289    /// );
290    /// ```
291    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    /// Registers a [`Trigger`] that will be started alongside the
316    /// HTTP server and cron scheduler.
317    ///
318    /// Triggers emit [`TriggerEvent`]s through a channel. Use
319    /// [`Runtime::on_trigger`] to handle those events (typically by
320    /// creating a workflow run).
321    ///
322    /// # Examples
323    ///
324    /// ```no_run
325    /// use ironflow_runtime::prelude::*;
326    /// use ironflow_runtime::trigger::event::{EventTrigger, EventTriggerRule};
327    /// use ironflow_store::entities::EventKind;
328    ///
329    /// let trigger = EventTrigger::new(vec![
330    ///     EventTriggerRule {
331    ///         on_event: EventKind::RunFailed,
332    ///         source_workflow: "deploy".to_string(),
333    ///         target_workflow: "rollback".to_string(),
334    ///         max_chain_depth: 3,
335    ///         conditions: vec![],
336    ///     },
337    /// ]);
338    ///
339    /// let runtime = Runtime::new().trigger(trigger);
340    /// ```
341    pub fn trigger(mut self, trigger: impl Trigger + 'static) -> Self {
342        self.triggers.push(Box::new(trigger));
343        self
344    }
345
346    /// Set the handler called when a [`Trigger`] emits a
347    /// [`TriggerEvent`].
348    ///
349    /// The handler typically creates a workflow run via the API.
350    ///
351    /// # Examples
352    ///
353    /// ```no_run
354    /// use ironflow_runtime::prelude::*;
355    ///
356    /// let runtime = Runtime::new()
357    ///     .on_trigger(|event| async move {
358    ///         println!("trigger fired: {} -> {}", event.workflow_name, event.payload);
359    ///     });
360    /// ```
361    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    /// Build the axum [`Router`] from the registered webhooks.
371    ///
372    /// This is separated from [`Runtime::serve`] so the router can be tested
373    /// independently (e.g. with `tower::ServiceExt::oneshot` or by
374    /// binding to a random port in integration tests).
375    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    /// Consumes the runtime and returns only the axum [`Router`].
422    ///
423    /// This is useful for testing the HTTP layer in isolation without
424    /// side-effects (e.g. with `tower::ServiceExt::oneshot`).
425    ///
426    /// # Examples
427    ///
428    /// ```no_run
429    /// use ironflow_runtime::prelude::*;
430    ///
431    /// let router = Runtime::new()
432    ///     .webhook("/hooks/test", WebhookAuth::none(), |_payload| async {})
433    ///     .into_router();
434    /// ```
435    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    /// Starts the HTTP server, blocking until shutdown.
447    ///
448    /// This method:
449    ///
450    /// 1. Loads environment variables from `.env` via [`dotenvy`].
451    /// 2. Builds an [Axum](https://docs.rs/axum) router with all registered webhook
452    ///    routes plus a `GET /health` endpoint.
453    /// 3. Starts registered triggers.
454    /// 4. Binds to `addr` and serves until a `Ctrl+C` signal is received.
455    /// 5. Gracefully shuts down triggers and waits for in-flight handlers.
456    ///
457    /// # Errors
458    ///
459    /// Returns an error if:
460    ///
461    /// - The TCP listener cannot bind to `addr`.
462    /// - The Axum server encounters a fatal I/O error.
463    ///
464    /// # Examples
465    ///
466    /// ```no_run
467    /// use ironflow_runtime::prelude::*;
468    ///
469    /// #[tokio::main]
470    /// async fn main() -> Result<(), ironflow_runtime::error::RuntimeError> {
471    ///     Runtime::new()
472    ///         .serve("127.0.0.1:3000")
473    ///         .await?;
474    ///     Ok(())
475    /// }
476    /// ```
477    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        // Start triggers
495        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        // Wait for all in-flight webhook handlers to finish.
523        info!("waiting for in-flight webhook handlers to complete");
524        tracker.wait().await;
525
526        // Stop triggers
527        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    /// Start all registered triggers and the event-dispatch loop.
541    ///
542    /// Returns handles for each trigger task and the dispatch task.
543    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        // Dispatch loop: forward trigger events to the handler.
570        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
604/// Tracks in-flight webhook handlers and enforces a concurrency limit.
605///
606/// Combines a [`Semaphore`] for backpressure with a [`JoinSet`] so that
607/// [`Runtime::serve`] can wait for all running handlers before exiting.
608struct 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    /// Spawn a handler task, respecting the concurrency limit.
622    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        // Reap completed tasks to detect panics early.
626        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    /// Wait for all in-flight handlers to complete.
648    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 that Runtime::new() creates a runtime with default values.
775    #[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 that Runtime::default() is equivalent to Runtime::new().
785    #[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 that max_body_size() builder method sets the value and returns self.
798    #[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 that max_body_size() can be chained with other builder methods.
805    #[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 that max_body_size() can be set to zero.
816    #[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 that max_body_size() can be set to large values.
823    #[test]
824    fn max_body_size_can_be_large() {
825        let large_size = 1024 * 1024 * 1024; // 1 GiB
826        let rt = Runtime::new().max_body_size(large_size);
827        assert_eq!(rt.max_body_size, large_size);
828    }
829
830    /// Test that max_concurrent_handlers() panics when given 0.
831    #[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 that max_concurrent_handlers() sets the value for valid inputs.
838    #[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 that max_concurrent_handlers() with 1 is allowed.
845    #[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 that max_concurrent_handlers() with large values is allowed.
852    #[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 that max_concurrent_handlers() returns self for chaining.
860    #[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    /// Test that with_shutdown() sets a custom shutdown signal and returns self.
872    #[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        // Signal to verify it was set properly.
881        let _ = tx.send(());
882    }
883
884    /// Test that with_shutdown() is chainable.
885    #[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 that webhook() registers a route and returns self.
900    #[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 that webhook() panics if path does not start with '/'.
908    #[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 that webhook() accepts paths with various formats.
915    #[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 that webhook() can be chained multiple times.
928    #[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 that webhook() works with different auth types.
941    #[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 that into_router() returns a Router (compiles and doesn't panic).
956    #[test]
957    fn into_router_returns_router() {
958        let rt = Runtime::new();
959        let _router = rt.into_router();
960        // If this compiles and doesn't panic, the router was successfully created.
961    }
962
963    /// Test that into_router() with webhooks returns a Router.
964    #[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        // If this compiles and doesn't panic, the router was successfully created with all webhooks.
971    }
972
973    /// Test that into_router() with max_body_size returns a Router.
974    #[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        // Router created; actual body size limit enforcement is tested in integration tests.
982    }
983
984    /// Test that into_router() with max_concurrent_handlers returns a Router.
985    #[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        // Router created; concurrency limit enforcement is tested in integration tests.
994    }
995
996    /// Test full builder chain with multiple methods.
997    #[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}