svix 1.95.1

Svix webhooks API client and webhook verification library
Documentation
use crate::{
    api::{EndpointIn, EndpointOut, Svix, SvixOptions},
    api_internal,
    error::Result,
    models::SubscribeIn,
    webhooks::{HeaderMap, Webhook, WebhookError},
};

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AutoConfigTokenContentV1 {
    #[serde(rename = "aid")]
    pub app_id: String,
    #[serde(rename = "eid")]
    pub endpoint_id: String,
    #[serde(rename = "surl")]
    pub server_url: String,
    #[serde(rename = "esec")]
    pub endpoint_secret: String,
    #[serde(rename = "tok")]
    pub token_plaintext: String,
}

pub struct AutoConfig {
    app_id: String,
    endpoint_id: String,
    endpoint: EndpointIn,
    webhook: Webhook,
    svix: Svix,
}

const AUTOCONFIG_TOKEN_PREFIX_V1: &str = "auto_v1_";

#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum AutoConfigError {
    #[error("invalid token")]
    InvalidToken,
}

fn decode_autoconfig_token_v1(
    token: &str,
) -> std::result::Result<AutoConfigTokenContentV1, AutoConfigError> {
    let token = token
        .strip_prefix(AUTOCONFIG_TOKEN_PREFIX_V1)
        .ok_or(AutoConfigError::InvalidToken)?;

    let decoded = base64::decode(token).map_err(|_| AutoConfigError::InvalidToken)?;

    serde_json::from_slice::<AutoConfigTokenContentV1>(&decoded)
        .map_err(|_| AutoConfigError::InvalidToken)
}

impl AutoConfig {
    pub fn new(token: String, endpoint: EndpointIn) -> std::result::Result<Self, AutoConfigError> {
        let content = decode_autoconfig_token_v1(&token)?;
        let webhook =
            Webhook::new(&content.endpoint_secret).map_err(|_| AutoConfigError::InvalidToken)?;

        let svix = Svix::new(
            content.token_plaintext,
            Some(SvixOptions {
                server_url: Some(content.server_url),
                ..Default::default()
            }),
        );

        Ok(Self {
            app_id: content.app_id,
            endpoint_id: content.endpoint_id,
            endpoint,
            webhook,
            svix,
        })
    }

    pub async fn subscribe(&self) -> Result<EndpointOut> {
        api_internal::endpoint_auto_config(self.svix.cfg())
            .update(
                self.app_id.clone(),
                self.endpoint_id.clone(),
                SubscribeIn::new(self.endpoint.clone()),
            )
            .await
    }

    pub fn verify<HM: HeaderMap>(
        &self,
        payload: &[u8],
        headers: &HM,
    ) -> std::result::Result<(), WebhookError> {
        self.webhook.verify(payload, headers)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn decode_autoconfig_token_v1_parses_payload() {
        let json = r#"{"aid":"app_1","eid":"ep_2","surl":"https://api.example.test","esec":"whsec_Zm9v","tok":"sk_test_xyz"}"#;
        let token = format!("{AUTOCONFIG_TOKEN_PREFIX_V1}{}", base64::encode(json));
        let content = decode_autoconfig_token_v1(&token).expect("valid token");

        assert_eq!(content.app_id, "app_1");
        assert_eq!(content.endpoint_id, "ep_2");
        assert_eq!(content.server_url, "https://api.example.test");
        assert_eq!(content.endpoint_secret, "whsec_Zm9v");
        assert_eq!(content.token_plaintext, "sk_test_xyz");
    }

    #[test]
    fn decode_autoconfig_token_v1_rejects_bad_prefix() {
        let json = r#"{"aid":"a","eid":"e","surl":"https://x","esec":"whsec_Zm9v","tok":"t"}"#;
        let token = format!("wrong_{}", base64::encode(json));
        assert!(matches!(
            decode_autoconfig_token_v1(&token),
            Err(AutoConfigError::InvalidToken)
        ));
    }

    #[test]
    fn decode_autoconfig_token_v1_rejects_invalid_json() {
        let token = format!("{AUTOCONFIG_TOKEN_PREFIX_V1}{}", base64::encode("not json"));
        assert!(matches!(
            decode_autoconfig_token_v1(&token),
            Err(AutoConfigError::InvalidToken)
        ));
    }
}