use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WebhookSchemeKind {
HmacBody,
HmacTimestampedBody,
HmacUrlFormFields,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebhookRequest {
pub url: String,
pub method: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
#[serde(default)]
pub received_at_ms: u64,
}
impl WebhookRequest {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum WebhookError {
MissingHeader(String),
MalformedPayload(String),
InvalidSignature(String),
ReplayDetected(String),
Backend(String),
}
impl WebhookError {
pub fn missing_header(name: impl Into<String>) -> Self {
WebhookError::MissingHeader(name.into())
}
pub fn malformed(detail: impl Into<String>) -> Self {
WebhookError::MalformedPayload(detail.into())
}
pub fn invalid_signature(detail: impl Into<String>) -> Self {
WebhookError::InvalidSignature(detail.into())
}
pub fn replay(detail: impl Into<String>) -> Self {
WebhookError::ReplayDetected(detail.into())
}
pub fn backend(detail: impl Into<String>) -> Self {
WebhookError::Backend(detail.into())
}
pub fn message(&self) -> &str {
match self {
WebhookError::MissingHeader(m)
| WebhookError::MalformedPayload(m)
| WebhookError::InvalidSignature(m)
| WebhookError::ReplayDetected(m)
| WebhookError::Backend(m) => m,
}
}
pub fn http_status(&self) -> u16 {
match self {
WebhookError::MissingHeader(_) | WebhookError::MalformedPayload(_) => 400,
WebhookError::InvalidSignature(_) | WebhookError::ReplayDetected(_) => 401,
WebhookError::Backend(_) => 500,
}
}
}
impl std::fmt::Display for WebhookError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let tag = match self {
WebhookError::MissingHeader(_) => "missing-header",
WebhookError::MalformedPayload(_) => "malformed-payload",
WebhookError::InvalidSignature(_) => "invalid-signature",
WebhookError::ReplayDetected(_) => "replay-detected",
WebhookError::Backend(_) => "backend",
};
write!(f, "webhook {}: {}", tag, self.message())
}
}
impl std::error::Error for WebhookError {}
pub trait WebhookPlugin: Send + Sync {
fn provider(&self) -> &str;
fn scheme(&self) -> WebhookSchemeKind;
fn verify(&self, req: &WebhookRequest) -> Result<(), WebhookError>;
fn is_healthy(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn header_lookup_is_case_insensitive() {
let req = WebhookRequest {
headers: vec![
("Content-Type".into(), "application/json".into()),
("X-Hub-Signature-256".into(), "sha256=abc".into()),
],
..Default::default()
};
assert_eq!(req.header("content-type"), Some("application/json"));
assert_eq!(req.header("X-HUB-SIGNATURE-256"), Some("sha256=abc"));
assert_eq!(req.header("missing"), None);
}
#[test]
fn header_lookup_returns_first_duplicate() {
let req = WebhookRequest {
headers: vec![
("X-Forwarded-For".into(), "a".into()),
("X-Forwarded-For".into(), "b".into()),
],
..Default::default()
};
assert_eq!(req.header("x-forwarded-for"), Some("a"));
}
#[test]
fn webhook_error_helpers_and_http_status() {
let e = WebhookError::missing_header("Stripe-Signature");
assert!(matches!(e, WebhookError::MissingHeader(_)));
assert_eq!(e.http_status(), 400);
assert_eq!(e.message(), "Stripe-Signature");
assert!(e.to_string().contains("missing-header"));
assert_eq!(WebhookError::malformed("x").http_status(), 400);
assert_eq!(WebhookError::invalid_signature("x").http_status(), 401);
assert_eq!(WebhookError::replay("x").http_status(), 401);
assert_eq!(WebhookError::backend("x").http_status(), 500);
}
struct AlwaysOkPlugin;
impl WebhookPlugin for AlwaysOkPlugin {
fn provider(&self) -> &str { "test" }
fn scheme(&self) -> WebhookSchemeKind { WebhookSchemeKind::HmacBody }
fn verify(&self, _req: &WebhookRequest) -> Result<(), WebhookError> { Ok(()) }
}
#[test]
fn trait_is_object_safe_and_default_health_works() {
let p: Box<dyn WebhookPlugin> = Box::new(AlwaysOkPlugin);
assert_eq!(p.provider(), "test");
assert!(p.is_healthy());
assert!(p.verify(&WebhookRequest::default()).is_ok());
}
}