Skip to main content

sms_web_generic/
lib.rs

1//! # SMS Web Generic
2//!
3//! Framework-agnostic webhook processing for smskit.
4//!
5//! This crate provides [`WebhookProcessor`], which takes an
6//! [`InboundRegistry`] of providers and handles the full
7//! verify → parse → respond pipeline without coupling to any HTTP framework.
8//!
9//! Framework adapters (`sms-web-axum`, `sms-web-warp`, etc.) convert their
10//! native request/response types to/from the generic types defined here
11//! using [`HeaderConverter`] and [`ResponseConverter`].
12
13use sms_core::{
14    Headers, HttpStatus, InboundMessage, InboundRegistry, WebhookError, WebhookResponse,
15};
16
17/// Framework-agnostic webhook processor.
18///
19/// Holds an [`InboundRegistry`] and drives the full inbound pipeline:
20///
21/// 1. Look up the provider in the registry.
22/// 2. Verify the webhook signature (if the provider implements it).
23/// 3. Parse the raw body into an [`InboundMessage`].
24/// 4. Return a [`WebhookResponse`] that the framework adapter can convert
25///    into its native response type.
26#[derive(Clone)]
27pub struct WebhookProcessor {
28    registry: InboundRegistry,
29}
30
31impl WebhookProcessor {
32    /// Create a processor backed by the given provider registry.
33    pub fn new(registry: InboundRegistry) -> Self {
34        Self { registry }
35    }
36
37    /// Process an incoming webhook request and return a framework-agnostic response.
38    ///
39    /// `provider` is the name extracted from the URL path (e.g. `"plivo"`).
40    pub fn process_webhook(
41        &self,
42        provider: &str,
43        headers: Headers,
44        body: &[u8],
45    ) -> WebhookResponse {
46        match self.process_webhook_internal(provider, headers, body) {
47            Ok(message) => WebhookResponse::success(message),
48            Err(e) => self.error_to_response(e),
49        }
50    }
51
52    fn process_webhook_internal(
53        &self,
54        provider: &str,
55        headers: Headers,
56        body: &[u8],
57    ) -> Result<InboundMessage, WebhookError> {
58        let hook = self
59            .registry
60            .get(provider)
61            .ok_or_else(|| WebhookError::ProviderNotFound(provider.to_string()))?;
62
63        hook.verify(&headers, body)
64            .map_err(|e| WebhookError::VerificationFailed(e.to_string()))?;
65
66        hook.parse_inbound(&headers, body)
67            .map_err(|e| WebhookError::ParseError(e.to_string()))
68    }
69
70    fn error_to_response(&self, error: WebhookError) -> WebhookResponse {
71        match error {
72            WebhookError::ProviderNotFound(_) => {
73                WebhookResponse::error(HttpStatus::NotFound, "unknown provider")
74            }
75            WebhookError::VerificationFailed(msg) => WebhookResponse::error(
76                HttpStatus::Unauthorized,
77                &format!("verification failed: {}", msg),
78            ),
79            WebhookError::ParseError(msg) => {
80                WebhookResponse::error(HttpStatus::BadRequest, &format!("parse error: {}", msg))
81            }
82            WebhookError::SmsError(e) => WebhookResponse::error(
83                HttpStatus::InternalServerError,
84                &format!("SMS error: {}", e),
85            ),
86        }
87    }
88}
89
90/// Trait for converting framework-specific request headers into the generic
91/// [`Headers`] type.
92pub trait HeaderConverter {
93    /// The framework's native header type (e.g. `axum::http::HeaderMap`).
94    type HeaderType;
95
96    /// Convert framework headers to generic `Vec<(String, String)>`.
97    fn to_generic_headers(headers: &Self::HeaderType) -> Headers;
98}
99
100/// Trait for converting a generic [`WebhookResponse`] into the framework's
101/// native response type.
102pub trait ResponseConverter {
103    /// The framework's native response type.
104    type ResponseType;
105
106    /// Build a framework response from the generic webhook response.
107    fn from_webhook_response(response: WebhookResponse) -> Self::ResponseType;
108}
109
110/// Convenience macro for implementing webhook handlers in different frameworks.
111#[macro_export]
112macro_rules! implement_webhook_handler {
113    ($framework:ident, $handler_name:ident, $request_type:ty, $response_type:ty) => {
114        pub async fn $handler_name(
115            processor: &WebhookProcessor,
116            provider: String,
117            headers: impl Into<Headers>,
118            body: &[u8],
119        ) -> $response_type {
120            let response = processor.process_webhook(&provider, headers.into(), body);
121            <$response_type as ResponseConverter>::from_webhook_response(response)
122        }
123    };
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use sms_core::{InboundMessage, InboundRegistry, InboundWebhook, SmsError};
130
131    /// A fake provider for testing the processor pipeline.
132    struct FakeProvider;
133
134    impl InboundWebhook for FakeProvider {
135        fn provider(&self) -> &'static str {
136            "fake"
137        }
138
139        fn parse_inbound(&self, _headers: &Headers, body: &[u8]) -> Result<InboundMessage, SmsError> {
140            let text = String::from_utf8(body.to_vec())
141                .map_err(|e| SmsError::Invalid(e.to_string()))?;
142            Ok(InboundMessage {
143                id: Some("fake-id".into()),
144                from: "+1111".into(),
145                to: "+2222".into(),
146                text,
147                timestamp: None,
148                provider: "fake",
149                raw: serde_json::json!({}),
150            })
151        }
152    }
153
154    /// A provider that always fails verification.
155    struct FailVerifyProvider;
156
157    impl InboundWebhook for FailVerifyProvider {
158        fn provider(&self) -> &'static str {
159            "fail-verify"
160        }
161
162        fn parse_inbound(&self, _headers: &Headers, _body: &[u8]) -> Result<InboundMessage, SmsError> {
163            unreachable!("should not be called if verify fails");
164        }
165
166        fn verify(&self, _headers: &Headers, _body: &[u8]) -> Result<(), SmsError> {
167            Err(SmsError::Auth("bad signature".into()))
168        }
169    }
170
171    /// A provider that fails to parse.
172    struct FailParseProvider;
173
174    impl InboundWebhook for FailParseProvider {
175        fn provider(&self) -> &'static str {
176            "fail-parse"
177        }
178
179        fn parse_inbound(&self, _headers: &Headers, _body: &[u8]) -> Result<InboundMessage, SmsError> {
180            Err(SmsError::Invalid("cannot parse this".into()))
181        }
182    }
183
184    fn processor_with(providers: Vec<std::sync::Arc<dyn InboundWebhook>>) -> WebhookProcessor {
185        let mut registry = InboundRegistry::new();
186        for p in providers {
187            registry = registry.with(p);
188        }
189        WebhookProcessor::new(registry)
190    }
191
192    #[test]
193    fn unknown_provider_returns_404() {
194        let processor = processor_with(vec![]);
195        let response = processor.process_webhook("unknown", vec![], b"test");
196        assert_eq!(response.status.as_u16(), 404);
197        assert!(response.body.contains("unknown provider"));
198    }
199
200    #[test]
201    fn known_provider_returns_200() {
202        let processor = processor_with(vec![std::sync::Arc::new(FakeProvider)]);
203        let response = processor.process_webhook("fake", vec![], b"hello");
204        assert_eq!(response.status.as_u16(), 200);
205        assert!(response.body.contains("fake-id"));
206        assert!(response.body.contains("hello"));
207    }
208
209    #[test]
210    fn verification_failure_returns_401() {
211        let processor = processor_with(vec![std::sync::Arc::new(FailVerifyProvider)]);
212        let response = processor.process_webhook("fail-verify", vec![], b"data");
213        assert_eq!(response.status.as_u16(), 401);
214        assert!(response.body.contains("verification failed"));
215    }
216
217    #[test]
218    fn parse_failure_returns_400() {
219        let processor = processor_with(vec![std::sync::Arc::new(FailParseProvider)]);
220        let response = processor.process_webhook("fail-parse", vec![], b"data");
221        assert_eq!(response.status.as_u16(), 400);
222        assert!(response.body.contains("parse error"));
223    }
224
225    #[test]
226    fn content_type_is_json() {
227        let processor = processor_with(vec![std::sync::Arc::new(FakeProvider)]);
228        let response = processor.process_webhook("fake", vec![], b"msg");
229        assert_eq!(response.content_type, "application/json");
230    }
231
232    #[test]
233    fn processor_passes_headers_to_provider() {
234        // FakeProvider ignores headers, but we verify the pipeline doesn't
235        // drop them by simply ensuring it doesn't panic.
236        let processor = processor_with(vec![std::sync::Arc::new(FakeProvider)]);
237        let headers = vec![
238            ("X-Custom".to_string(), "value".to_string()),
239            ("Content-Type".to_string(), "application/json".to_string()),
240        ];
241        let response = processor.process_webhook("fake", headers, b"body");
242        assert_eq!(response.status.as_u16(), 200);
243    }
244}