use sms_core::{
Headers, HttpStatus, InboundMessage, InboundRegistry, WebhookError, WebhookResponse,
};
#[derive(Clone)]
pub struct WebhookProcessor {
registry: InboundRegistry,
}
impl WebhookProcessor {
pub fn new(registry: InboundRegistry) -> Self {
Self { registry }
}
pub fn process_webhook(
&self,
provider: &str,
headers: Headers,
body: &[u8],
) -> WebhookResponse {
match self.process_webhook_internal(provider, headers, body) {
Ok(message) => WebhookResponse::success(message),
Err(e) => self.error_to_response(e),
}
}
fn process_webhook_internal(
&self,
provider: &str,
headers: Headers,
body: &[u8],
) -> Result<InboundMessage, WebhookError> {
let hook = self
.registry
.get(provider)
.ok_or_else(|| WebhookError::ProviderNotFound(provider.to_string()))?;
hook.verify(&headers, body)
.map_err(|e| WebhookError::VerificationFailed(e.to_string()))?;
hook.parse_inbound(&headers, body)
.map_err(|e| WebhookError::ParseError(e.to_string()))
}
fn error_to_response(&self, error: WebhookError) -> WebhookResponse {
match error {
WebhookError::ProviderNotFound(_) => {
WebhookResponse::error(HttpStatus::NotFound, "unknown provider")
}
WebhookError::VerificationFailed(msg) => WebhookResponse::error(
HttpStatus::Unauthorized,
&format!("verification failed: {}", msg),
),
WebhookError::ParseError(msg) => {
WebhookResponse::error(HttpStatus::BadRequest, &format!("parse error: {}", msg))
}
WebhookError::SmsError(e) => WebhookResponse::error(
HttpStatus::InternalServerError,
&format!("SMS error: {}", e),
),
}
}
}
pub trait HeaderConverter {
type HeaderType;
fn to_generic_headers(headers: &Self::HeaderType) -> Headers;
}
pub trait ResponseConverter {
type ResponseType;
fn from_webhook_response(response: WebhookResponse) -> Self::ResponseType;
}
#[macro_export]
macro_rules! implement_webhook_handler {
($framework:ident, $handler_name:ident, $request_type:ty, $response_type:ty) => {
pub async fn $handler_name(
processor: &WebhookProcessor,
provider: String,
headers: impl Into<Headers>,
body: &[u8],
) -> $response_type {
let response = processor.process_webhook(&provider, headers.into(), body);
<$response_type as ResponseConverter>::from_webhook_response(response)
}
};
}
#[cfg(test)]
mod tests {
use super::*;
use sms_core::{InboundMessage, InboundRegistry, InboundWebhook, SmsError};
struct FakeProvider;
impl InboundWebhook for FakeProvider {
fn provider(&self) -> &'static str {
"fake"
}
fn parse_inbound(&self, _headers: &Headers, body: &[u8]) -> Result<InboundMessage, SmsError> {
let text = String::from_utf8(body.to_vec())
.map_err(|e| SmsError::Invalid(e.to_string()))?;
Ok(InboundMessage {
id: Some("fake-id".into()),
from: "+1111".into(),
to: "+2222".into(),
text,
timestamp: None,
provider: "fake",
raw: serde_json::json!({}),
})
}
}
struct FailVerifyProvider;
impl InboundWebhook for FailVerifyProvider {
fn provider(&self) -> &'static str {
"fail-verify"
}
fn parse_inbound(&self, _headers: &Headers, _body: &[u8]) -> Result<InboundMessage, SmsError> {
unreachable!("should not be called if verify fails");
}
fn verify(&self, _headers: &Headers, _body: &[u8]) -> Result<(), SmsError> {
Err(SmsError::Auth("bad signature".into()))
}
}
struct FailParseProvider;
impl InboundWebhook for FailParseProvider {
fn provider(&self) -> &'static str {
"fail-parse"
}
fn parse_inbound(&self, _headers: &Headers, _body: &[u8]) -> Result<InboundMessage, SmsError> {
Err(SmsError::Invalid("cannot parse this".into()))
}
}
fn processor_with(providers: Vec<std::sync::Arc<dyn InboundWebhook>>) -> WebhookProcessor {
let mut registry = InboundRegistry::new();
for p in providers {
registry = registry.with(p);
}
WebhookProcessor::new(registry)
}
#[test]
fn unknown_provider_returns_404() {
let processor = processor_with(vec![]);
let response = processor.process_webhook("unknown", vec![], b"test");
assert_eq!(response.status.as_u16(), 404);
assert!(response.body.contains("unknown provider"));
}
#[test]
fn known_provider_returns_200() {
let processor = processor_with(vec![std::sync::Arc::new(FakeProvider)]);
let response = processor.process_webhook("fake", vec![], b"hello");
assert_eq!(response.status.as_u16(), 200);
assert!(response.body.contains("fake-id"));
assert!(response.body.contains("hello"));
}
#[test]
fn verification_failure_returns_401() {
let processor = processor_with(vec![std::sync::Arc::new(FailVerifyProvider)]);
let response = processor.process_webhook("fail-verify", vec![], b"data");
assert_eq!(response.status.as_u16(), 401);
assert!(response.body.contains("verification failed"));
}
#[test]
fn parse_failure_returns_400() {
let processor = processor_with(vec![std::sync::Arc::new(FailParseProvider)]);
let response = processor.process_webhook("fail-parse", vec![], b"data");
assert_eq!(response.status.as_u16(), 400);
assert!(response.body.contains("parse error"));
}
#[test]
fn content_type_is_json() {
let processor = processor_with(vec![std::sync::Arc::new(FakeProvider)]);
let response = processor.process_webhook("fake", vec![], b"msg");
assert_eq!(response.content_type, "application/json");
}
#[test]
fn processor_passes_headers_to_provider() {
let processor = processor_with(vec![std::sync::Arc::new(FakeProvider)]);
let headers = vec![
("X-Custom".to_string(), "value".to_string()),
("Content-Type".to_string(), "application/json".to_string()),
];
let response = processor.process_webhook("fake", headers, b"body");
assert_eq!(response.status.as_u16(), 200);
}
}