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;
9use crate::function::{FunctionInfo, FunctionKind};
10
11use super::context::WebhookContext;
12use super::signature::{IdempotencyConfig, SignatureConfig};
13
14/// Trait for inbound webhook handlers.
15pub trait ForgeWebhook: crate::__sealed::Sealed + Send + Sync + 'static {
16    type Payload: serde::de::DeserializeOwned + Send + Sync + 'static;
17
18    fn info() -> WebhookInfo;
19
20    fn execute(
21        ctx: &WebhookContext,
22        payload: Self::Payload,
23    ) -> Pin<Box<dyn Future<Output = Result<WebhookResult>> + Send + '_>>;
24}
25
26/// Metadata for a registered webhook handler.
27#[derive(Debug, Clone)]
28pub struct WebhookInfo {
29    pub name: &'static str,
30    pub description: Option<&'static str>,
31    pub path: &'static str,
32    pub signature: Option<SignatureConfig>,
33    pub allow_unsigned: bool,
34    pub idempotency: Option<IdempotencyConfig>,
35    pub timeout: Duration,
36    pub http_timeout: Option<Duration>,
37}
38
39impl Default for WebhookInfo {
40    fn default() -> Self {
41        Self {
42            name: "",
43            description: None,
44            path: "",
45            signature: None,
46            allow_unsigned: false,
47            idempotency: None,
48            timeout: Duration::from_secs(30),
49            http_timeout: None,
50        }
51    }
52}
53
54impl From<&WebhookInfo> for FunctionInfo {
55    fn from(webhook: &WebhookInfo) -> Self {
56        Self {
57            name: webhook.name,
58            description: webhook.description,
59            kind: FunctionKind::Webhook,
60            required_role: None,
61            is_public: true,
62            cache_ttl: None,
63            timeout: Some(webhook.timeout),
64            http_timeout: webhook.http_timeout,
65            rate_limit_requests: None,
66            rate_limit_per_secs: None,
67            rate_limit_key: None,
68            log_level: None,
69            table_dependencies: &[],
70            selected_columns: &[],
71            changed_columns: &[],
72            transactional: false,
73            consistent: false,
74            max_upload_size_bytes: None,
75            requires_tenant_scope: false,
76        }
77    }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(tag = "status")]
82#[non_exhaustive]
83pub enum WebhookResult {
84    #[serde(rename = "ok")]
85    Ok,
86    #[serde(rename = "accepted")]
87    Accepted,
88    #[serde(rename = "custom")]
89    Custom { status_code: u16, body: Value },
90}
91
92impl WebhookResult {
93    pub fn status_code(&self) -> u16 {
94        match self {
95            Self::Ok => 200,
96            Self::Accepted => 202,
97            Self::Custom { status_code, .. } => *status_code,
98        }
99    }
100
101    pub fn body(&self) -> Value {
102        match self {
103            Self::Ok => serde_json::json!({"status": "ok"}),
104            Self::Accepted => serde_json::json!({"status": "accepted"}),
105            Self::Custom { body, .. } => body.clone(),
106        }
107    }
108}
109
110#[cfg(test)]
111#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn test_default_webhook_info() {
117        let info = WebhookInfo::default();
118        assert!(info.signature.is_none());
119        assert!(!info.allow_unsigned);
120        assert!(info.idempotency.is_none());
121        assert_eq!(info.timeout, Duration::from_secs(30));
122        assert_eq!(info.http_timeout, None);
123    }
124
125    #[test]
126    fn test_webhook_result_status_codes() {
127        assert_eq!(WebhookResult::Ok.status_code(), 200);
128        assert_eq!(WebhookResult::Accepted.status_code(), 202);
129        assert_eq!(
130            WebhookResult::Custom {
131                status_code: 400,
132                body: serde_json::json!({"error": "bad request"})
133            }
134            .status_code(),
135            400
136        );
137    }
138
139    #[test]
140    fn test_webhook_result_body() {
141        assert_eq!(
142            WebhookResult::Ok.body(),
143            serde_json::json!({"status": "ok"})
144        );
145        assert_eq!(
146            WebhookResult::Accepted.body(),
147            serde_json::json!({"status": "accepted"})
148        );
149    }
150}