use serde::{Deserialize, Serialize};
pub const SIGNATURE_HEADER: &str = "X-Awa-Signature";
pub const DEFAULT_HEARTBEAT_TIMEOUT_SECS: f64 = 3600.0;
pub const DEFAULT_CALLBACK_PATH_PREFIX: &str = "/api/callbacks";
pub fn callback_url(base: &str, prefix: &str, callback_id: &str, action: &str) -> String {
let base = base.trim_end_matches('/');
let prefix = normalize_prefix(prefix);
format!("{base}{prefix}/{callback_id}/{action}")
}
fn normalize_prefix(prefix: &str) -> String {
let trimmed = prefix.trim().trim_end_matches('/');
if trimmed.is_empty() {
String::new()
} else if trimmed.starts_with('/') {
trimmed.to_string()
} else {
format!("/{trimmed}")
}
}
pub fn sign(secret: &[u8; 32], callback_id: &str) -> String {
blake3::keyed_hash(secret, callback_id.as_bytes())
.to_hex()
.to_string()
}
pub fn verify(secret: &[u8; 32], callback_id: &str, signature: &str) -> bool {
let expected = blake3::keyed_hash(secret, callback_id.as_bytes());
blake3::Hash::from_hex(signature).is_ok_and(|h| expected == h)
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct CompletePayload {
#[serde(default)]
pub payload: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FailPayload {
pub error: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HeartbeatPayload {
#[serde(default = "default_heartbeat_timeout")]
pub timeout_seconds: f64,
}
impl Default for HeartbeatPayload {
fn default() -> Self {
Self {
timeout_seconds: DEFAULT_HEARTBEAT_TIMEOUT_SECS,
}
}
}
fn default_heartbeat_timeout() -> f64 {
DEFAULT_HEARTBEAT_TIMEOUT_SECS
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallbackResponse {
pub id: i64,
pub state: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sign_and_verify_roundtrip() {
let secret = [3u8; 32];
let id = "abc";
let sig = sign(&secret, id);
assert!(verify(&secret, id, &sig));
}
#[test]
fn verify_rejects_wrong_secret() {
let secret = [3u8; 32];
let other = [4u8; 32];
let id = "abc";
let sig = sign(&secret, id);
assert!(!verify(&other, id, &sig));
}
#[test]
fn verify_rejects_wrong_id() {
let secret = [3u8; 32];
let sig = sign(&secret, "abc");
assert!(!verify(&secret, "abd", &sig));
}
#[test]
fn verify_rejects_malformed_signature() {
let secret = [3u8; 32];
assert!(!verify(&secret, "abc", "not-hex"));
assert!(!verify(&secret, "abc", ""));
}
#[test]
fn heartbeat_default_uses_constant() {
let payload: HeartbeatPayload =
serde_json::from_str("{}").expect("empty object should deserialize");
assert_eq!(payload.timeout_seconds, DEFAULT_HEARTBEAT_TIMEOUT_SECS);
}
#[test]
fn callback_url_default_path() {
let url = callback_url(
"https://awa.example.com",
DEFAULT_CALLBACK_PATH_PREFIX,
"abc",
"complete",
);
assert_eq!(url, "https://awa.example.com/api/callbacks/abc/complete");
}
#[test]
fn callback_url_custom_prefix() {
let url = callback_url("https://api.example.com", "/awa-cb", "abc", "fail");
assert_eq!(url, "https://api.example.com/awa-cb/abc/fail");
}
#[test]
fn callback_url_strips_trailing_slashes() {
let url = callback_url("https://api.example.com/", "/awa-cb/", "abc", "heartbeat");
assert_eq!(url, "https://api.example.com/awa-cb/abc/heartbeat");
}
#[test]
fn callback_url_normalizes_missing_leading_slash() {
let url = callback_url("https://api.example.com", "awa-cb", "abc", "complete");
assert_eq!(url, "https://api.example.com/awa-cb/abc/complete");
}
#[test]
fn callback_url_handles_empty_prefix() {
let url = callback_url("https://api.example.com", "", "abc", "complete");
assert_eq!(url, "https://api.example.com/abc/complete");
let url = callback_url("https://api.example.com", " ", "abc", "complete");
assert_eq!(url, "https://api.example.com/abc/complete");
}
#[test]
fn sign_pinned_test_vector() {
let secret = [7u8; 32];
let signature = sign(&secret, "abc");
assert_eq!(
signature,
"b1495225f01fa8cd09e410d288d09b68c1cc9fb2414686c0d3ac13fc905497d9",
);
}
#[test]
fn callback_url_nested_prefix() {
let url = callback_url(
"https://api.example.com",
"/jobs/v1/callbacks",
"abc",
"complete",
);
assert_eq!(
url,
"https://api.example.com/jobs/v1/callbacks/abc/complete"
);
}
}