linger-openai-sdk 0.1.1

Rust-native async SDK for OpenAI APIs with typed requests, streaming, uploads, retries, and pluggable transports.
Documentation
use crate::error::{HeaderMap, LingerError};
use base64::prelude::{Engine as _, BASE64_STANDARD};
use hmac::{Hmac, Mac};
use serde::de::DeserializeOwned;
use sha2::Sha256;
use std::fmt;

type HmacSha256 = Hmac<Sha256>;

/// EN: Verifies OpenAI webhook signatures before payload parsing.
/// 中文:在解析载荷前验证 OpenAI webhook 签名。
#[derive(Clone)]
#[non_exhaustive]
pub struct WebhookVerifier {
    secret: Vec<u8>,
}

impl WebhookVerifier {
    /// EN: Creates a verifier from the configured webhook signing secret.
    /// 中文:使用配置的 webhook 签名密钥创建验证器。
    pub fn new(secret: impl AsRef<str>) -> Result<Self, LingerError> {
        let secret = secret.as_ref().trim();
        if secret.is_empty() {
            return Err(LingerError::invalid_config("webhook secret is required"));
        }
        Ok(Self {
            secret: decode_secret(secret),
        })
    }

    /// EN: Verifies the signed raw webhook body.
    /// 中文:验证已签名的原始 webhook 请求体。
    pub fn verify(&self, headers: &HeaderMap, body: &[u8]) -> Result<(), LingerError> {
        let webhook_id = required_header(headers, "webhook-id")?;
        let timestamp = required_header(headers, "webhook-timestamp")?;
        let signature = required_header(headers, "webhook-signature")?;
        let expected = signed_payload(webhook_id, timestamp, body);
        for candidate in signature_candidates(signature) {
            let decoded = BASE64_STANDARD
                .decode(candidate)
                .map_err(|_| LingerError::invalid_config("webhook signature is invalid"))?;
            let mut mac = HmacSha256::new_from_slice(&self.secret)
                .map_err(|_| LingerError::invalid_config("webhook secret is invalid"))?;
            mac.update(&expected);
            if mac.verify_slice(&decoded).is_ok() {
                return Ok(());
            }
        }
        Err(LingerError::invalid_config(
            "webhook signature verification failed",
        ))
    }

    /// EN: Verifies and deserializes a signed webhook JSON body.
    /// 中文:验证并反序列化已签名的 webhook JSON 请求体。
    pub fn parse<T>(&self, headers: &HeaderMap, body: &[u8]) -> Result<T, LingerError>
    where
        T: DeserializeOwned,
    {
        self.verify(headers, body)?;
        Ok(serde_json::from_slice(body)?)
    }
}

impl fmt::Debug for WebhookVerifier {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("WebhookVerifier")
            .field("secret", &"<redacted>")
            .finish()
    }
}

fn decode_secret(secret: &str) -> Vec<u8> {
    secret
        .strip_prefix("whsec_")
        .and_then(|encoded| BASE64_STANDARD.decode(encoded).ok())
        .unwrap_or_else(|| secret.as_bytes().to_vec())
}

fn required_header<'a>(headers: &'a HeaderMap, name: &str) -> Result<&'a str, LingerError> {
    headers
        .get(name)
        .filter(|value| !value.trim().is_empty())
        .ok_or_else(|| LingerError::invalid_config(format!("{name} header is required")))
}

fn signed_payload(webhook_id: &str, timestamp: &str, body: &[u8]) -> Vec<u8> {
    let mut payload = Vec::with_capacity(webhook_id.len() + timestamp.len() + body.len() + 2);
    payload.extend_from_slice(webhook_id.as_bytes());
    payload.push(b'.');
    payload.extend_from_slice(timestamp.as_bytes());
    payload.push(b'.');
    payload.extend_from_slice(body);
    payload
}

fn signature_candidates(header: &str) -> impl Iterator<Item = &str> {
    header
        .split(',')
        .filter_map(|part| part.trim().strip_prefix("v1,").or(Some(part.trim())))
        .filter(|part| !part.is_empty() && *part != "v1")
}