Skip to main content

forge_core/webhook/
traits.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::error::Result;
9
10use super::context::WebhookContext;
11use super::signature::{IdempotencyConfig, SignatureConfig};
12
13/// Trait for FORGE webhook handlers.
14///
15/// Webhooks are HTTP endpoints that receive external events (e.g., from Stripe, GitHub).
16/// They support signature validation, idempotency, and bypass authentication.
17pub trait ForgeWebhook: Send + Sync + 'static {
18    /// Get webhook metadata.
19    fn info() -> WebhookInfo;
20
21    /// Execute the webhook handler.
22    ///
23    /// # Arguments
24    /// * `ctx` - Webhook context with db, http, and dispatch capabilities
25    /// * `payload` - The raw JSON payload from the request body
26    fn execute(
27        ctx: &WebhookContext,
28        payload: Value,
29    ) -> Pin<Box<dyn Future<Output = Result<WebhookResult>> + Send + '_>>;
30}
31
32/// Webhook metadata.
33#[derive(Debug, Clone)]
34pub struct WebhookInfo {
35    /// Webhook name (used for identification).
36    pub name: &'static str,
37    /// URL path for the webhook (e.g., "/webhooks/stripe").
38    pub path: &'static str,
39    /// Signature validation configuration.
40    pub signature: Option<SignatureConfig>,
41    /// Allow unsigned requests for this webhook.
42    ///
43    /// Defaults to `false` for security. Only enable for trusted internal callers.
44    pub allow_unsigned: bool,
45    /// Idempotency configuration.
46    pub idempotency: Option<IdempotencyConfig>,
47    /// Request timeout.
48    pub timeout: Duration,
49    /// Default timeout for outbound HTTP requests made by the webhook.
50    pub http_timeout: Option<Duration>,
51}
52
53impl Default for WebhookInfo {
54    fn default() -> Self {
55        Self {
56            name: "",
57            path: "",
58            signature: None,
59            allow_unsigned: false,
60            idempotency: None,
61            timeout: Duration::from_secs(30),
62            http_timeout: None,
63        }
64    }
65}
66
67/// Result returned by webhook handlers.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(tag = "status")]
70pub enum WebhookResult {
71    /// Request processed successfully (HTTP 200).
72    #[serde(rename = "ok")]
73    Ok,
74    /// Request accepted for async processing (HTTP 202).
75    #[serde(rename = "accepted")]
76    Accepted,
77    /// Custom response with specific status and body.
78    #[serde(rename = "custom")]
79    Custom {
80        /// HTTP status code.
81        status_code: u16,
82        /// Response body.
83        body: Value,
84    },
85}
86
87impl WebhookResult {
88    /// Get the HTTP status code for this result.
89    pub fn status_code(&self) -> u16 {
90        match self {
91            Self::Ok => 200,
92            Self::Accepted => 202,
93            Self::Custom { status_code, .. } => *status_code,
94        }
95    }
96
97    /// Get the response body.
98    pub fn body(&self) -> Value {
99        match self {
100            Self::Ok => serde_json::json!({"status": "ok"}),
101            Self::Accepted => serde_json::json!({"status": "accepted"}),
102            Self::Custom { body, .. } => body.clone(),
103        }
104    }
105}
106
107#[cfg(test)]
108#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn test_default_webhook_info() {
114        let info = WebhookInfo::default();
115        assert!(info.signature.is_none());
116        assert!(!info.allow_unsigned);
117        assert!(info.idempotency.is_none());
118        assert_eq!(info.timeout, Duration::from_secs(30));
119        assert_eq!(info.http_timeout, None);
120    }
121
122    #[test]
123    fn test_webhook_result_status_codes() {
124        assert_eq!(WebhookResult::Ok.status_code(), 200);
125        assert_eq!(WebhookResult::Accepted.status_code(), 202);
126        assert_eq!(
127            WebhookResult::Custom {
128                status_code: 400,
129                body: serde_json::json!({"error": "bad request"})
130            }
131            .status_code(),
132            400
133        );
134    }
135
136    #[test]
137    fn test_webhook_result_body() {
138        assert_eq!(
139            WebhookResult::Ok.body(),
140            serde_json::json!({"status": "ok"})
141        );
142        assert_eq!(
143            WebhookResult::Accepted.body(),
144            serde_json::json!({"status": "accepted"})
145        );
146    }
147}