Skip to main content

rvoip_vapi/
config.rs

1//! Static Vapi adapter configuration.
2
3use std::fmt;
4use std::time::Duration;
5
6use url::Url;
7use zeroize::Zeroize;
8
9use crate::error::{Result, VapiError};
10
11/// API credential whose diagnostics and drop behavior are secret-safe.
12#[derive(Clone, Eq, PartialEq)]
13pub struct VapiApiKey(String);
14
15impl VapiApiKey {
16    pub fn new(value: impl Into<String>) -> Result<Self> {
17        let mut value = value.into();
18        if value.trim().is_empty() {
19            value.zeroize();
20            return Err(VapiError::InvalidConfiguration(
21                "the API key must not be empty",
22            ));
23        }
24        if value.chars().any(char::is_control) {
25            value.zeroize();
26            return Err(VapiError::InvalidConfiguration(
27                "the API key contains control characters",
28            ));
29        }
30        Ok(Self(value))
31    }
32
33    pub(crate) fn expose_secret(&self) -> &str {
34        &self.0
35    }
36}
37
38impl fmt::Debug for VapiApiKey {
39    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40        formatter.write_str("VapiApiKey([redacted])")
41    }
42}
43
44impl Drop for VapiApiKey {
45    fn drop(&mut self) {
46        self.0.zeroize();
47    }
48}
49
50/// Static configuration shared by all calls placed through an adapter.
51#[derive(Clone)]
52pub struct VapiConfig {
53    pub api_key: VapiApiKey,
54    pub api_base: Url,
55    pub http_timeout: Duration,
56    pub websocket_timeout: Duration,
57    pub websocket_io_timeout: Duration,
58    pub graceful_shutdown_timeout: Duration,
59    pub heartbeat_interval: Duration,
60    pub media_queue_capacity: usize,
61    pub control_queue_capacity: usize,
62    pub event_queue_capacity: usize,
63    pub max_message_bytes: usize,
64    pub startup_audio_frames: usize,
65    pub(crate) allow_insecure_transport: bool,
66}
67
68impl VapiConfig {
69    pub fn new(api_key: VapiApiKey) -> Self {
70        let api_base = match Url::parse("https://api.vapi.ai/") {
71            Ok(url) => url,
72            Err(_) => unreachable!("the built-in Vapi API URL is valid"),
73        };
74        Self {
75            api_key,
76            api_base,
77            http_timeout: Duration::from_secs(10),
78            websocket_timeout: Duration::from_secs(10),
79            websocket_io_timeout: Duration::from_secs(10),
80            graceful_shutdown_timeout: Duration::from_secs(2),
81            heartbeat_interval: Duration::from_secs(20),
82            media_queue_capacity: 100,
83            control_queue_capacity: 32,
84            event_queue_capacity: 100,
85            max_message_bytes: 1024 * 1024,
86            startup_audio_frames: 100,
87            allow_insecure_transport: false,
88        }
89    }
90
91    pub fn with_api_base(mut self, api_base: Url) -> Self {
92        self.api_base = api_base;
93        self
94    }
95
96    /// Permit HTTP and WS endpoints for a loopback-only test server.
97    ///
98    /// Production callers should never enable this. Validation rejects an
99    /// insecure host that is not an IP loopback or `localhost`.
100    pub fn with_loopback_test_transport(mut self) -> Self {
101        self.allow_insecure_transport = true;
102        self
103    }
104
105    pub fn validate(&self) -> Result<()> {
106        if self.api_base.scheme() != "https"
107            && !(self.allow_insecure_transport && is_loopback_url(&self.api_base))
108        {
109            return Err(VapiError::InvalidConfiguration(
110                "the API base must use HTTPS",
111            ));
112        }
113        if self.http_timeout.is_zero()
114            || self.websocket_timeout.is_zero()
115            || self.websocket_io_timeout.is_zero()
116            || self.graceful_shutdown_timeout.is_zero()
117            || self.heartbeat_interval.is_zero()
118        {
119            return Err(VapiError::InvalidConfiguration(
120                "timeouts and heartbeat interval must be non-zero",
121            ));
122        }
123        if self.media_queue_capacity == 0
124            || self.control_queue_capacity == 0
125            || self.event_queue_capacity == 0
126            || self.startup_audio_frames == 0
127        {
128            return Err(VapiError::InvalidConfiguration(
129                "queue capacities must be non-zero",
130            ));
131        }
132        if self.startup_audio_frames > 100 {
133            return Err(VapiError::InvalidConfiguration(
134                "startup audio buffering cannot exceed 100 frames",
135            ));
136        }
137        if self.max_message_bytes < 640 {
138            return Err(VapiError::InvalidConfiguration(
139                "maximum message size is too small for one PCM frame",
140            ));
141        }
142        Ok(())
143    }
144
145    pub(crate) fn permits_websocket_url(&self, url: &Url) -> bool {
146        url.scheme() == "wss"
147            || (self.allow_insecure_transport && url.scheme() == "ws" && is_loopback_url(url))
148    }
149}
150
151impl fmt::Debug for VapiConfig {
152    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
153        formatter
154            .debug_struct("VapiConfig")
155            .field("api_key", &self.api_key)
156            .field("api_base", &"[redacted]")
157            .field("http_timeout", &self.http_timeout)
158            .field("websocket_timeout", &self.websocket_timeout)
159            .field("websocket_io_timeout", &self.websocket_io_timeout)
160            .field("graceful_shutdown_timeout", &self.graceful_shutdown_timeout)
161            .field("heartbeat_interval", &self.heartbeat_interval)
162            .field("media_queue_capacity", &self.media_queue_capacity)
163            .field("control_queue_capacity", &self.control_queue_capacity)
164            .field("event_queue_capacity", &self.event_queue_capacity)
165            .field("max_message_bytes", &self.max_message_bytes)
166            .field("startup_audio_frames", &self.startup_audio_frames)
167            .field("insecure_transport", &self.allow_insecure_transport)
168            .finish()
169    }
170}
171
172fn is_loopback_url(url: &Url) -> bool {
173    url.host_str().is_some_and(|host| {
174        host.eq_ignore_ascii_case("localhost")
175            || host
176                .parse::<std::net::IpAddr>()
177                .is_ok_and(|address| address.is_loopback())
178    })
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn diagnostics_redact_key_and_endpoint() {
187        let config = VapiConfig::new(VapiApiKey::new("key-canary").unwrap())
188            .with_api_base(Url::parse("https://endpoint-canary.invalid/").unwrap());
189        let debug = format!("{config:?}");
190        assert!(!debug.contains("key-canary"));
191        assert!(!debug.contains("endpoint-canary"));
192    }
193
194    #[test]
195    fn insecure_transport_is_loopback_only() {
196        let key = VapiApiKey::new("test").unwrap();
197        let loopback = VapiConfig::new(key.clone())
198            .with_api_base(Url::parse("http://127.0.0.1:8000/").unwrap())
199            .with_loopback_test_transport();
200        assert!(loopback.validate().is_ok());
201
202        let remote = VapiConfig::new(key)
203            .with_api_base(Url::parse("http://example.com/").unwrap())
204            .with_loopback_test_transport();
205        assert!(remote.validate().is_err());
206    }
207
208    #[test]
209    fn startup_audio_is_capped_at_two_seconds() {
210        let mut config = VapiConfig::new(VapiApiKey::new("test").unwrap());
211        config.startup_audio_frames = 100;
212        assert!(config.validate().is_ok());
213        config.startup_audio_frames = 101;
214        assert!(config.validate().is_err());
215    }
216}