discourse_webhooks/
lib.rs

1//! # Discourse Webhooks
2//!
3//! A type-safe Rust library for handling Discourse webhook events.
4//!
5//! This crate provides:
6//! - Type-safe event parsing for Discourse webhooks
7//! - HMAC-SHA256 signature verification
8//! - Trait-based event handling system
9//! - Support for all major Discourse webhook events
10//!
11//! ## Quick Start
12//!
13//! ```rust
14//! use discourse_webhooks::{WebhookEventHandler, WebhookProcessor, TopicWebhookEvent};
15//!
16//! struct MyHandler;
17//!
18//! impl WebhookEventHandler for MyHandler {
19//!     type Error = String;
20//!
21//!     fn handle_topic_created(&mut self, event: &TopicWebhookEvent) -> Result<(), Self::Error> {
22//!         println!("New topic: {}", event.topic.title);
23//!         Ok(())
24//!     }
25//! }
26//!
27//! // Process webhook events
28//! let processor = WebhookProcessor::new();
29//! let mut handler = MyHandler;
30//! // processor.process_json(&mut handler, "topic_created", payload, None)?;
31//! ```
32
33pub mod error;
34pub mod events;
35pub mod signature;
36
37pub use error::{Result, WebhookError};
38pub use events::{
39    parse_webhook_payload, PostWebhookEvent, TopicWebhookEvent, WebhookEventPayload, WebhookPost,
40    WebhookTopic, WebhookUser,
41};
42pub use signature::{verify_json_signature, verify_signature, SignatureVerificationError};
43
44use serde::{Deserialize, Serialize};
45
46/// Represents a Discourse webhook payload structure
47#[derive(Debug, Serialize, Deserialize, Clone)]
48pub struct DiscourseWebhookPayload {
49    /// The event type (e.g., "topic_created", "post_edited")
50    #[serde(default)]
51    pub event: Option<String>,
52    /// The webhook data payload
53    #[serde(default)]
54    pub data: Option<serde_json::Value>,
55    /// Unix timestamp when the event occurred
56    #[serde(default)]
57    pub timestamp: Option<i64>,
58}
59
60/// Trait for handling different types of webhook events
61///
62/// Implement this trait to define custom behavior for each event type.
63/// All methods have default implementations that do nothing, so you only
64/// need to implement the events you care about.
65pub trait WebhookEventHandler {
66    /// The error type returned by event handlers
67    type Error;
68
69    /// Called when a new topic is created
70    fn handle_topic_created(
71        &mut self,
72        event: &TopicWebhookEvent,
73    ) -> std::result::Result<(), Self::Error> {
74        let _ = event;
75        Ok(())
76    }
77
78    /// Called when a topic is edited
79    fn handle_topic_edited(
80        &mut self,
81        event: &TopicWebhookEvent,
82    ) -> std::result::Result<(), Self::Error> {
83        let _ = event;
84        Ok(())
85    }
86
87    /// Called when a topic is deleted/destroyed
88    fn handle_topic_destroyed(
89        &mut self,
90        event: &TopicWebhookEvent,
91    ) -> std::result::Result<(), Self::Error> {
92        let _ = event;
93        Ok(())
94    }
95
96    /// Called when a deleted topic is recovered
97    fn handle_topic_recovered(
98        &mut self,
99        event: &TopicWebhookEvent,
100    ) -> std::result::Result<(), Self::Error> {
101        let _ = event;
102        Ok(())
103    }
104
105    /// Called when a new post is created
106    fn handle_post_created(
107        &mut self,
108        event: &PostWebhookEvent,
109    ) -> std::result::Result<(), Self::Error> {
110        let _ = event;
111        Ok(())
112    }
113
114    /// Called when a post is edited
115    fn handle_post_edited(
116        &mut self,
117        event: &PostWebhookEvent,
118    ) -> std::result::Result<(), Self::Error> {
119        let _ = event;
120        Ok(())
121    }
122
123    /// Called when a post is deleted/destroyed
124    fn handle_post_destroyed(
125        &mut self,
126        event: &PostWebhookEvent,
127    ) -> std::result::Result<(), Self::Error> {
128        let _ = event;
129        Ok(())
130    }
131
132    /// Called when a deleted post is recovered
133    fn handle_post_recovered(
134        &mut self,
135        event: &PostWebhookEvent,
136    ) -> std::result::Result<(), Self::Error> {
137        let _ = event;
138        Ok(())
139    }
140
141    /// Called when a ping event is received
142    fn handle_ping(&mut self) -> std::result::Result<(), Self::Error> {
143        Ok(())
144    }
145}
146
147/// Process a webhook event using the provided handler
148///
149/// This function parses the payload based on the event type and calls
150/// the appropriate handler method.
151///
152/// # Arguments
153/// * `handler` - Mutable reference to an event handler
154/// * `event_type` - The type of event (e.g., "topic_created")
155/// * `payload` - The JSON payload from the webhook
156///
157/// # Returns
158/// * `Ok(())` if the event was processed successfully
159/// * `Err(WebhookError)` if parsing or handling failed
160pub fn process_webhook_event<H: WebhookEventHandler>(
161    handler: &mut H,
162    event_type: &str,
163    payload: serde_json::Value,
164) -> std::result::Result<(), WebhookError<H::Error>> {
165    match event_type {
166        "topic_created" | "topic_edited" | "topic_destroyed" | "topic_recovered" => {
167            let event = parse_webhook_payload(event_type, payload)?;
168            if let WebhookEventPayload::TopicEvent(topic_event) = event {
169                match event_type {
170                    "topic_created" => handler.handle_topic_created(&topic_event),
171                    "topic_edited" => handler.handle_topic_edited(&topic_event),
172                    "topic_destroyed" => handler.handle_topic_destroyed(&topic_event),
173                    "topic_recovered" => handler.handle_topic_recovered(&topic_event),
174                    _ => unreachable!(),
175                }
176                .map_err(WebhookError::HandlerError)?;
177            }
178        }
179        "post_created" | "post_edited" | "post_destroyed" | "post_recovered" => {
180            let event = parse_webhook_payload(event_type, payload)?;
181            if let WebhookEventPayload::PostEvent(post_event) = event {
182                match event_type {
183                    "post_created" => handler.handle_post_created(&post_event),
184                    "post_edited" => handler.handle_post_edited(&post_event),
185                    "post_destroyed" => handler.handle_post_destroyed(&post_event),
186                    "post_recovered" => handler.handle_post_recovered(&post_event),
187                    _ => unreachable!(),
188                }
189                .map_err(WebhookError::HandlerError)?;
190            }
191        }
192        "ping" => {
193            handler.handle_ping().map_err(WebhookError::HandlerError)?;
194        }
195        _ => {
196            return Err(WebhookError::UnknownEventType(event_type.to_string()));
197        }
198    }
199
200    Ok(())
201}
202
203/// A webhook processor that handles signature verification and event dispatching
204///
205/// This struct provides a convenient way to process webhook events with
206/// optional signature verification.
207///
208/// # Examples
209///
210/// ```rust
211/// use discourse_webhooks::WebhookProcessor;
212///
213/// // Without signature verification
214/// let processor = WebhookProcessor::new();
215///
216/// // With signature verification
217/// let processor = WebhookProcessor::new()
218///     .with_secret("your_webhook_secret");
219/// ```
220#[derive(Debug, Clone)]
221pub struct WebhookProcessor {
222    secret: Option<String>,
223    verify_signatures: bool,
224}
225
226impl WebhookProcessor {
227    /// Create a new webhook processor with default settings
228    ///
229    /// By default, signature verification is disabled.
230    pub fn new() -> Self {
231        Self {
232            secret: None,
233            verify_signatures: false,
234        }
235    }
236
237    /// Enable signature verification with the provided secret
238    ///
239    /// # Arguments
240    /// * `secret` - The shared secret key for HMAC verification
241    pub fn with_secret<S: Into<String>>(mut self, secret: S) -> Self {
242        self.secret = Some(secret.into());
243        self.verify_signatures = true;
244        self
245    }
246
247    /// Disable signature verification
248    ///
249    /// This can be useful for development or when webhooks are received
250    /// through a trusted channel.
251    pub fn without_signature_verification(mut self) -> Self {
252        self.verify_signatures = false;
253        self
254    }
255
256    /// Check if signature verification is enabled
257    pub fn verifies_signatures(&self) -> bool {
258        self.verify_signatures
259    }
260
261    /// Get the configured secret (if any)
262    pub fn secret(&self) -> Option<&str> {
263        self.secret.as_deref()
264    }
265
266    /// Process a webhook from a string payload
267    ///
268    /// # Arguments
269    /// * `handler` - Mutable reference to an event handler
270    /// * `event_type` - The type of event (e.g., "topic_created")
271    /// * `payload` - The raw JSON payload as a string
272    /// * `signature` - Optional signature header for verification
273    pub fn process<H: WebhookEventHandler>(
274        &self,
275        handler: &mut H,
276        event_type: &str,
277        payload: &str,
278        signature: Option<&str>,
279    ) -> Result<(), H::Error> {
280        if self.verify_signatures {
281            if let Some(secret) = &self.secret {
282                if let Some(sig) = signature {
283                    signature::verify_signature(secret, payload, sig)
284                        .map_err(|_| WebhookError::InvalidSignature)?;
285                } else {
286                    return Err(WebhookError::InvalidSignature);
287                }
288            }
289        }
290
291        let json_payload: serde_json::Value = serde_json::from_str(payload)?;
292        process_webhook_event(handler, event_type, json_payload)
293    }
294
295    /// Process a webhook from a JSON value
296    ///
297    /// # Arguments
298    /// * `handler` - Mutable reference to an event handler
299    /// * `event_type` - The type of event (e.g., "topic_created")
300    /// * `payload` - The JSON payload as a serde_json::Value
301    /// * `signature` - Optional signature header for verification
302    pub fn process_json<H: WebhookEventHandler>(
303        &self,
304        handler: &mut H,
305        event_type: &str,
306        payload: serde_json::Value,
307        signature: Option<&str>,
308    ) -> Result<(), H::Error> {
309        if self.verify_signatures {
310            if let Some(secret) = &self.secret {
311                if let Some(sig) = signature {
312                    signature::verify_json_signature(secret, &payload, sig)
313                        .map_err(|_| WebhookError::InvalidSignature)?;
314                } else {
315                    return Err(WebhookError::InvalidSignature);
316                }
317            }
318        }
319
320        process_webhook_event(handler, event_type, payload)
321    }
322}
323
324impl Default for WebhookProcessor {
325    fn default() -> Self {
326        Self::new()
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use hmac::Mac;
334    use serde_json::json;
335
336    struct TestHandler {
337        pub topic_created_count: usize,
338        pub post_created_count: usize,
339        pub ping_count: usize,
340    }
341
342    impl TestHandler {
343        fn new() -> Self {
344            Self {
345                topic_created_count: 0,
346                post_created_count: 0,
347                ping_count: 0,
348            }
349        }
350    }
351
352    impl WebhookEventHandler for TestHandler {
353        type Error = String;
354
355        fn handle_topic_created(
356            &mut self,
357            _event: &TopicWebhookEvent,
358        ) -> std::result::Result<(), Self::Error> {
359            self.topic_created_count += 1;
360            Ok(())
361        }
362
363        fn handle_post_created(
364            &mut self,
365            _event: &PostWebhookEvent,
366        ) -> std::result::Result<(), Self::Error> {
367            self.post_created_count += 1;
368            Ok(())
369        }
370
371        fn handle_ping(&mut self) -> std::result::Result<(), Self::Error> {
372            self.ping_count += 1;
373            Ok(())
374        }
375    }
376
377    #[test]
378    fn test_webhook_handler_ping() {
379        let mut handler = TestHandler::new();
380        let result = process_webhook_event(&mut handler, "ping", json!({}));
381        assert!(result.is_ok());
382        assert_eq!(handler.ping_count, 1);
383    }
384
385    #[test]
386    fn test_webhook_handler_invalid_event_type() {
387        let mut handler = TestHandler::new();
388        let result = process_webhook_event(&mut handler, "hatasj", json!({}));
389        assert!(result.is_err());
390
391        if let Err(WebhookError::UnknownEventType(event)) = result {
392            assert_eq!(event, "hatasj");
393        } else {
394            panic!("Expected UnknownEventType error");
395        }
396    }
397
398    #[test]
399    fn test_webhook_processor() {
400        let processor = WebhookProcessor::new();
401        let mut handler = TestHandler::new();
402
403        let result = processor.process_json(&mut handler, "ping", json!({}), None);
404
405        assert!(result.is_ok());
406        assert_eq!(handler.ping_count, 1);
407    }
408
409    #[test]
410    fn test_webhook_processor_with_signature() {
411        let secret = "test_secret";
412        let processor = WebhookProcessor::new().with_secret(secret);
413        let mut handler = TestHandler::new();
414
415        let payload = json!({});
416        let payload_str = serde_json::to_string(&payload).unwrap();
417
418        let mut mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()).unwrap();
419        mac.update(payload_str.as_bytes());
420        let signature = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
421
422        let result = processor.process_json(&mut handler, "ping", payload, Some(&signature));
423
424        assert!(result.is_ok());
425        assert_eq!(handler.ping_count, 1);
426    }
427
428    #[test]
429    fn test_unknown_event_type() {
430        let mut handler = TestHandler::new();
431        let result = process_webhook_event(&mut handler, "unknown_event", json!({}));
432        assert!(result.is_err());
433
434        if let Err(WebhookError::UnknownEventType(event)) = result {
435            assert_eq!(event, "unknown_event");
436        } else {
437            panic!("Expected UnknownEventType error");
438        }
439    }
440}