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 and
5//! cron jobs, then starts an [Axum](https://docs.rs/axum) HTTP server with
6//! graceful 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 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
32/// Default maximum body size for webhook payloads (2 MiB).
33const DEFAULT_MAX_BODY_SIZE: usize = 2 * 1024 * 1024;
34
35/// Default maximum number of concurrently running webhook handlers.
36const 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/// What a webhook handler registered via [`Runtime::webhook_with_context`] receives.
43///
44/// # Examples
45///
46/// ```no_run
47/// use ironflow_runtime::prelude::*;
48///
49/// let runtime = Runtime::new().webhook_with_context(
50///     "/hooks/github",
51///     WebhookAuth::github("secret"),
52///     |ctx: WebhookContext| async move {
53///         println!("{} from {:?}", ctx.payload, ctx.delivery_id);
54///     },
55/// );
56/// ```
57#[derive(Debug, Clone)]
58pub struct WebhookContext {
59    /// The parsed JSON body.
60    pub payload: Value,
61    /// Provider delivery identifier, already prefixed (`github:…`, `gitlab:…`).
62    ///
63    /// `None` when the provider sent no known delivery header. Suitable as-is
64    /// for the `Idempotency-Key` header of `POST /api/v1/runs`.
65    pub delivery_id: Option<String>,
66}
67
68/// Metric name constants for the runtime (webhook + cron).
69#[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
85/// The ironflow runtime server builder.
86///
87/// `Runtime` uses a builder pattern: create one with [`Runtime::new`], register
88/// webhook routes with [`Runtime::webhook`] and cron jobs with
89/// [`Runtime::cron`], then call [`Runtime::serve`] to start both the HTTP
90/// server and the cron scheduler, or [`Runtime::run_crons`] to run only the
91/// cron scheduler without an HTTP listener.
92///
93/// # Built-in endpoints
94///
95/// | Method | Path | Description |
96/// |--------|------|-------------|
97/// | `GET`  | `/health` | Returns `200 OK` with body `"ok"`. Useful for load-balancer health checks. |
98/// | `POST` | *user-defined* | Webhook endpoints registered via [`Runtime::webhook`]. |
99///
100/// # Examples
101///
102/// ```no_run
103/// use ironflow_runtime::prelude::*;
104///
105/// #[tokio::main]
106/// async fn main() -> Result<(), ironflow_runtime::error::RuntimeError> {
107///     Runtime::new()
108///         .webhook("/hooks/deploy", WebhookAuth::github("secret"), |payload| async move {
109///             println!("deploy triggered: {payload}");
110///         })
111///         .cron("0 0 * * * *", "hourly-sync", || async {
112///             println!("syncing...");
113///         })
114///         .serve("0.0.0.0:3000")
115///         .await?;
116///
117///     Ok(())
118/// }
119/// ```
120pub 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    /// Creates a new, empty `Runtime` with no webhooks or cron jobs.
130    ///
131    /// # Examples
132    ///
133    /// ```no_run
134    /// use ironflow_runtime::runtime::Runtime;
135    ///
136    /// let runtime = Runtime::new();
137    /// ```
138    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    /// Set the maximum allowed body size for webhook payloads.
149    ///
150    /// Requests exceeding this limit are rejected by axum before reaching the
151    /// handler. Defaults to 2 MiB.
152    ///
153    /// # Examples
154    ///
155    /// ```no_run
156    /// use ironflow_runtime::runtime::Runtime;
157    ///
158    /// let runtime = Runtime::new().max_body_size(512 * 1024); // 512 KiB
159    /// ```
160    pub fn max_body_size(mut self, bytes: usize) -> Self {
161        self.max_body_size = bytes;
162        self
163    }
164
165    /// Set the maximum number of concurrently running webhook handlers.
166    ///
167    /// When the limit is reached, new webhook requests still receive
168    /// **202 Accepted** but their handlers are queued until a slot is
169    /// available. Defaults to 64.
170    ///
171    /// # Panics
172    ///
173    /// Panics if `limit` is `0`.
174    ///
175    /// # Examples
176    ///
177    /// ```no_run
178    /// use ironflow_runtime::runtime::Runtime;
179    ///
180    /// let runtime = Runtime::new().max_concurrent_handlers(16);
181    /// ```
182    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    /// Override the default shutdown signal (`Ctrl+C` / `SIGTERM`).
189    ///
190    /// By default, [`Runtime::serve`] and [`Runtime::run_crons`] block until
191    /// the process receives `Ctrl+C` or `SIGTERM`. Use this method to provide
192    /// a custom future that resolves when the runtime should shut down.
193    ///
194    /// This is useful in tests where you want to trigger a clean shutdown
195    /// (including `scheduler.shutdown()`) without relying on OS signals.
196    ///
197    /// # Examples
198    ///
199    /// ```no_run
200    /// use ironflow_runtime::runtime::Runtime;
201    /// use tokio::sync::oneshot;
202    ///
203    /// # async fn example() -> Result<(), ironflow_runtime::error::RuntimeError> {
204    /// let (tx, rx) = oneshot::channel::<()>();
205    ///
206    /// let rt = Runtime::new()
207    ///     .with_shutdown(async { let _ = rx.await; })
208    ///     .cron("0 */5 * * * *", "check", || async {});
209    ///
210    /// // In another task: tx.send(()) to trigger shutdown.
211    /// rt.run_crons().await?;
212    /// # Ok(())
213    /// # }
214    /// ```
215    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    /// Registers a webhook route.
224    ///
225    /// The handler receives the parsed JSON body as a [`serde_json::Value`].
226    /// When a request arrives, the server verifies authentication using `auth`,
227    /// then spawns the handler in the background and immediately returns
228    /// **202 Accepted** to the caller.
229    ///
230    /// # Arguments
231    ///
232    /// * `path` - The URL path to listen on (e.g. `"/hooks/github"`).
233    /// * `auth` - The [`WebhookAuth`] strategy for this endpoint.
234    /// * `handler` - An async function receiving the JSON payload.
235    ///
236    /// # Examples
237    ///
238    /// ```no_run
239    /// use ironflow_runtime::prelude::*;
240    ///
241    /// let runtime = Runtime::new()
242    ///     .webhook("/hooks/github", WebhookAuth::github("secret"), |payload| async move {
243    ///         println!("payload: {payload}");
244    ///     });
245    /// ```
246    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    /// Registers a webhook route whose handler also sees the delivery identifier.
255    ///
256    /// Identical to [`Runtime::webhook`], except the handler receives a
257    /// [`WebhookContext`] instead of the bare payload. Use this when the workflow
258    /// should be enqueued idempotently: replaying the same provider delivery then
259    /// resolves to the run it already created rather than a duplicate.
260    ///
261    /// # Arguments
262    ///
263    /// * `path` - The URL path to listen on (e.g. `"/hooks/github"`).
264    /// * `auth` - The [`WebhookAuth`] strategy for this endpoint.
265    /// * `handler` - An async function receiving the [`WebhookContext`].
266    ///
267    /// # Panics
268    ///
269    /// Panics if `path` does not start with `/`.
270    ///
271    /// # Examples
272    ///
273    /// ```no_run
274    /// use ironflow_runtime::prelude::*;
275    ///
276    /// let runtime = Runtime::new().webhook_with_context(
277    ///     "/hooks/github",
278    ///     WebhookAuth::github("secret"),
279    ///     |ctx| async move {
280    ///         // `ctx.delivery_id` is `Some("github:<X-GitHub-Delivery>")` when
281    ///         // the provider stamped the request.
282    ///         println!("delivery: {:?}", ctx.delivery_id);
283    ///     },
284    /// );
285    /// ```
286    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    /// Registers a cron job.
311    ///
312    /// The `schedule` uses a **6-field cron expression** (seconds granularity):
313    /// `sec min hour day-of-month month day-of-week`.
314    ///
315    /// # Arguments
316    ///
317    /// * `schedule` - A 6-field cron expression, e.g. `"0 */5 * * * *"` for every 5 minutes.
318    /// * `name` - A human-readable name for logging.
319    /// * `handler` - An async function to execute on each tick.
320    ///
321    /// # Examples
322    ///
323    /// ```no_run
324    /// use ironflow_runtime::prelude::*;
325    ///
326    /// let runtime = Runtime::new()
327    ///     .cron("0 0 * * * *", "hourly-cleanup", || async {
328    ///         println!("cleaning up...");
329    ///     });
330    /// ```
331    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    /// Build the axum [`Router`] from the registered webhooks.
347    ///
348    /// This is separated from [`Runtime::serve`] so the router can be tested
349    /// independently (e.g. with `tower::ServiceExt::oneshot` or by
350    /// binding to a random port in integration tests).
351    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    /// Consumes the runtime and returns only the axum [`Router`].
398    ///
399    /// Cron jobs are **not** started. This is useful for testing the HTTP
400    /// layer in isolation without side-effects (e.g. with
401    /// `tower::ServiceExt::oneshot`).
402    ///
403    /// # Examples
404    ///
405    /// ```no_run
406    /// use ironflow_runtime::prelude::*;
407    ///
408    /// let router = Runtime::new()
409    ///     .webhook("/hooks/test", WebhookAuth::none(), |_payload| async {})
410    ///     .into_router();
411    /// ```
412    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    /// Starts the cron scheduler with all registered cron jobs.
430    ///
431    /// This is an internal helper used by both [`Runtime::serve`] and
432    /// [`Runtime::run_crons`].
433    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    /// Starts only the cron scheduler, blocking until a shutdown signal is
466    /// received (`Ctrl+C` / `SIGTERM`).
467    ///
468    /// Unlike [`Runtime::serve`], this does **not** start an HTTP server. Any
469    /// registered webhooks are ignored (a warning is logged if webhooks were
470    /// registered).
471    ///
472    /// # Errors
473    ///
474    /// Returns an error if:
475    ///
476    /// - The cron scheduler fails to initialise or a cron expression is invalid.
477    /// - The scheduler fails to shut down cleanly.
478    ///
479    /// # Examples
480    ///
481    /// ```no_run
482    /// use ironflow_runtime::prelude::*;
483    ///
484    /// #[tokio::main]
485    /// async fn main() -> Result<(), ironflow_runtime::error::RuntimeError> {
486    ///     Runtime::new()
487    ///         .cron("0 0 * * * *", "hourly-sync", || async {
488    ///             println!("syncing...");
489    ///         })
490    ///         .run_crons()
491    ///         .await?;
492    ///     Ok(())
493    /// }
494    /// ```
495    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    /// Starts the HTTP server and cron scheduler, blocking until shutdown.
531    ///
532    /// This method:
533    ///
534    /// 1. Loads environment variables from `.env` via [`dotenvy`].
535    /// 2. Starts the [`tokio_cron_scheduler`] scheduler with all registered cron jobs.
536    /// 3. Builds an [Axum](https://docs.rs/axum) router with all registered webhook
537    ///    routes plus a `GET /health` endpoint.
538    /// 4. Binds to `addr` and serves until a `Ctrl+C` signal is received.
539    /// 5. Gracefully shuts down the scheduler before returning.
540    ///
541    /// If you only need cron jobs without an HTTP server, use
542    /// [`Runtime::run_crons`] instead.
543    ///
544    /// # Errors
545    ///
546    /// Returns an error if:
547    ///
548    /// - The cron scheduler fails to initialise or a cron expression is invalid.
549    /// - The TCP listener cannot bind to `addr`.
550    /// - The Axum server encounters a fatal I/O error.
551    ///
552    /// # Examples
553    ///
554    /// ```no_run
555    /// use ironflow_runtime::prelude::*;
556    ///
557    /// #[tokio::main]
558    /// async fn main() -> Result<(), ironflow_runtime::error::RuntimeError> {
559    ///     Runtime::new()
560    ///         .serve("127.0.0.1:3000")
561    ///         .await?;
562    ///     Ok(())
563    /// }
564    /// ```
565    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        // Wait for all in-flight webhook handlers to finish.
608        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
625/// Tracks in-flight webhook handlers and enforces a concurrency limit.
626///
627/// Combines a [`Semaphore`] for backpressure with a [`JoinSet`] so that
628/// [`Runtime::serve`] can wait for all running handlers before exiting.
629struct 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    /// Spawn a handler task, respecting the concurrency limit.
643    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        // Reap completed tasks to detect panics early.
647        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    /// Wait for all in-flight handlers to complete.
669    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 that Runtime::new() creates a runtime with default values.
796    #[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 that Runtime::default() is equivalent to Runtime::new().
807    #[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 that max_body_size() builder method sets the value and returns self.
821    #[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 that max_body_size() can be chained with other builder methods.
828    #[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 that max_body_size() can be set to zero.
839    #[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 that max_body_size() can be set to large values.
846    #[test]
847    fn max_body_size_can_be_large() {
848        let large_size = 1024 * 1024 * 1024; // 1 GiB
849        let rt = Runtime::new().max_body_size(large_size);
850        assert_eq!(rt.max_body_size, large_size);
851    }
852
853    /// Test that max_concurrent_handlers() panics when given 0.
854    #[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 that max_concurrent_handlers() sets the value for valid inputs.
861    #[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 that max_concurrent_handlers() with 1 is allowed.
868    #[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 that max_concurrent_handlers() with large values is allowed.
875    #[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 that max_concurrent_handlers() returns self for chaining.
883    #[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    /// Test that with_shutdown() sets a custom shutdown signal and returns self.
895    #[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        // Signal to verify it was set properly.
904        let _ = tx.send(());
905    }
906
907    /// Test that with_shutdown() is chainable.
908    #[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 that webhook() registers a route and returns self.
923    #[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 that webhook() panics if path does not start with '/'.
931    #[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 that webhook() accepts paths with various formats.
938    #[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 that webhook() can be chained multiple times.
951    #[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 that webhook() works with different auth types.
964    #[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 that cron() registers a job and returns self.
979    #[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 that cron() is chainable.
988    #[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 that cron() preserves all parameters correctly.
997    #[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 that into_router() returns a Router (compiles and doesn't panic).
1009    #[test]
1010    fn into_router_returns_router() {
1011        let rt = Runtime::new();
1012        let _router = rt.into_router();
1013        // If this compiles and doesn't panic, the router was successfully created.
1014    }
1015
1016    /// Test that into_router() with webhooks returns a Router.
1017    #[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        // If this compiles and doesn't panic, the router was successfully created with all webhooks.
1024    }
1025
1026    /// Test that into_router() with cron jobs (warns but doesn't panic).
1027    #[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        // Crons are dropped but not an error; router should still be created.
1034    }
1035
1036    /// Test that into_router() with max_body_size returns a Router.
1037    #[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        // Router created; actual body size limit enforcement is tested in integration tests.
1045    }
1046
1047    /// Test that into_router() with max_concurrent_handlers returns a Router.
1048    #[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        // Router created; concurrency limit enforcement is tested in integration tests.
1057    }
1058
1059    /// Test full builder chain with multiple methods.
1060    #[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 that into_router() drops cron jobs with a warning logged.
1076    #[test]
1077    fn into_router_with_crons_doesnt_start_them() {
1078        let rt = Runtime::new().cron("0 0 * * * *", "test-cron", || async {});
1079        // This should not panic; crons are simply dropped.
1080        let _router = rt.into_router();
1081    }
1082}