Skip to main content

forge_core/webhook/
context.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use uuid::Uuid;
5
6use crate::env::{EnvAccess, EnvProvider, RealEnvProvider};
7use crate::function::JobDispatch;
8
9/// Context available to webhook handlers.
10pub struct WebhookContext {
11    /// Webhook name.
12    pub webhook_name: String,
13    /// Unique request ID for this webhook invocation.
14    pub request_id: String,
15    /// Idempotency key if extracted from request.
16    pub idempotency_key: Option<String>,
17    /// Request headers (lowercase keys).
18    headers: HashMap<String, String>,
19    /// Database pool.
20    db_pool: sqlx::PgPool,
21    /// HTTP client for external calls.
22    http_client: reqwest::Client,
23    /// Job dispatcher for async processing.
24    job_dispatch: Option<Arc<dyn JobDispatch>>,
25    /// Environment variable provider.
26    env_provider: Arc<dyn EnvProvider>,
27}
28
29impl WebhookContext {
30    /// Create a new webhook context.
31    pub fn new(
32        webhook_name: String,
33        request_id: String,
34        headers: HashMap<String, String>,
35        db_pool: sqlx::PgPool,
36        http_client: reqwest::Client,
37    ) -> Self {
38        Self {
39            webhook_name,
40            request_id,
41            idempotency_key: None,
42            headers,
43            db_pool,
44            http_client,
45            job_dispatch: None,
46            env_provider: Arc::new(RealEnvProvider::new()),
47        }
48    }
49
50    /// Set idempotency key.
51    pub fn with_idempotency_key(mut self, key: Option<String>) -> Self {
52        self.idempotency_key = key;
53        self
54    }
55
56    /// Set job dispatcher.
57    pub fn with_job_dispatch(mut self, dispatcher: Arc<dyn JobDispatch>) -> Self {
58        self.job_dispatch = Some(dispatcher);
59        self
60    }
61
62    /// Set environment provider.
63    pub fn with_env_provider(mut self, provider: Arc<dyn EnvProvider>) -> Self {
64        self.env_provider = provider;
65        self
66    }
67
68    /// Get database pool.
69    pub fn db(&self) -> &sqlx::PgPool {
70        &self.db_pool
71    }
72
73    /// Returns a `DbConn` wrapping the pool for shared helper functions.
74    pub fn db_conn(&self) -> crate::function::DbConn<'_> {
75        crate::function::DbConn::Pool(&self.db_pool)
76    }
77
78    /// Acquire a connection compatible with sqlx compile-time checked macros.
79    pub async fn conn(&self) -> sqlx::Result<crate::function::ForgeConn<'static>> {
80        Ok(crate::function::ForgeConn::Pool(
81            self.db_pool.acquire().await?,
82        ))
83    }
84
85    /// Get HTTP client.
86    pub fn http(&self) -> &reqwest::Client {
87        &self.http_client
88    }
89
90    /// Get a request header value.
91    ///
92    /// Header names are case-insensitive.
93    pub fn header(&self, name: &str) -> Option<&str> {
94        self.headers.get(&name.to_lowercase()).map(|s| s.as_str())
95    }
96
97    /// Get all headers.
98    pub fn headers(&self) -> &HashMap<String, String> {
99        &self.headers
100    }
101
102    /// Dispatch a background job for async processing.
103    ///
104    /// This is the recommended way to handle webhook events:
105    /// 1. Validate the webhook signature
106    /// 2. Dispatch a job to process the event
107    /// 3. Return 202 Accepted immediately
108    ///
109    /// # Arguments
110    /// * `job_type` - The registered name of the job type
111    /// * `args` - The arguments for the job (will be serialized to JSON)
112    ///
113    /// # Returns
114    /// The UUID of the dispatched job, or an error if dispatch is not available.
115    pub async fn dispatch_job<T: serde::Serialize>(
116        &self,
117        job_type: &str,
118        args: T,
119    ) -> crate::error::Result<Uuid> {
120        let dispatcher = self.job_dispatch.as_ref().ok_or_else(|| {
121            crate::error::ForgeError::Internal("Job dispatch not available".into())
122        })?;
123        let args_json = serde_json::to_value(args)?;
124        dispatcher.dispatch_by_name(job_type, args_json, None).await
125    }
126
127    /// Request cancellation for a job.
128    pub async fn cancel_job(
129        &self,
130        job_id: Uuid,
131        reason: Option<String>,
132    ) -> crate::error::Result<bool> {
133        let dispatcher = self.job_dispatch.as_ref().ok_or_else(|| {
134            crate::error::ForgeError::Internal("Job dispatch not available".into())
135        })?;
136        dispatcher.cancel(job_id, reason).await
137    }
138}
139
140impl EnvAccess for WebhookContext {
141    fn env_provider(&self) -> &dyn EnvProvider {
142        self.env_provider.as_ref()
143    }
144}
145
146#[cfg(test)]
147#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
148mod tests {
149    use super::*;
150
151    #[tokio::test]
152    async fn test_webhook_context_creation() {
153        let pool = sqlx::postgres::PgPoolOptions::new()
154            .max_connections(1)
155            .connect_lazy("postgres://localhost/nonexistent")
156            .expect("Failed to create mock pool");
157
158        let mut headers = HashMap::new();
159        headers.insert("x-github-event".to_string(), "push".to_string());
160        headers.insert("x-github-delivery".to_string(), "abc-123".to_string());
161
162        let ctx = WebhookContext::new(
163            "github_webhook".to_string(),
164            "req-123".to_string(),
165            headers,
166            pool,
167            reqwest::Client::new(),
168        )
169        .with_idempotency_key(Some("abc-123".to_string()));
170
171        assert_eq!(ctx.webhook_name, "github_webhook");
172        assert_eq!(ctx.request_id, "req-123");
173        assert_eq!(ctx.idempotency_key, Some("abc-123".to_string()));
174        assert_eq!(ctx.header("X-GitHub-Event"), Some("push"));
175        assert_eq!(ctx.header("x-github-event"), Some("push")); // case-insensitive
176        assert!(ctx.header("nonexistent").is_none());
177    }
178}