Skip to main content

typecast_rust/
client.rs

1//! Typecast API client
2//!
3//! This module contains the main client for interacting with the Typecast API.
4
5use crate::errors::{Result, TypecastError};
6use crate::models::{
7    AudioFormat, ErrorResponse, TTSRequest, TTSResponse, VoiceV2, VoicesV2Filter,
8};
9use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
10use std::env;
11use std::time::Duration;
12
13/// Default API base URL
14pub const DEFAULT_BASE_URL: &str = "https://api.typecast.ai";
15
16/// Default request timeout in seconds
17pub const DEFAULT_TIMEOUT_SECS: u64 = 60;
18
19/// Configuration for the Typecast client
20#[derive(Debug, Clone)]
21pub struct ClientConfig {
22    /// API key for authentication
23    pub api_key: String,
24    /// Base URL for the API (defaults to <https://api.typecast.ai>)
25    pub base_url: String,
26    /// Request timeout duration
27    pub timeout: Duration,
28}
29
30impl Default for ClientConfig {
31    fn default() -> Self {
32        Self {
33            api_key: env::var("TYPECAST_API_KEY").unwrap_or_default(),
34            base_url: env::var("TYPECAST_API_HOST").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()),
35            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
36        }
37    }
38}
39
40impl ClientConfig {
41    /// Create a new configuration with an API key
42    pub fn new(api_key: impl Into<String>) -> Self {
43        Self {
44            api_key: api_key.into(),
45            ..Default::default()
46        }
47    }
48
49    /// Set a custom base URL
50    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
51        self.base_url = base_url.into();
52        self
53    }
54
55    /// Set a custom timeout
56    pub fn timeout(mut self, timeout: Duration) -> Self {
57        self.timeout = timeout;
58        self
59    }
60}
61
62/// The main Typecast API client
63#[derive(Debug, Clone)]
64pub struct TypecastClient {
65    client: reqwest::Client,
66    base_url: String,
67    api_key: String,
68}
69
70impl TypecastClient {
71    /// Create a new TypecastClient with the given configuration
72    pub fn new(config: ClientConfig) -> Result<Self> {
73        let mut headers = HeaderMap::new();
74        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
75        headers.insert(
76            "X-API-KEY",
77            HeaderValue::from_str(&config.api_key)
78                .map_err(|_| TypecastError::BadRequest { 
79                    detail: "Invalid API key format".to_string() 
80                })?,
81        );
82
83        let client = reqwest::Client::builder()
84            .default_headers(headers)
85            .timeout(config.timeout)
86            .build()?;
87
88        Ok(Self {
89            client,
90            base_url: config.base_url,
91            api_key: config.api_key,
92        })
93    }
94
95    /// Create a new TypecastClient from environment variables
96    ///
97    /// Reads TYPECAST_API_KEY and optionally TYPECAST_API_HOST
98    pub fn from_env() -> Result<Self> {
99        Self::new(ClientConfig::default())
100    }
101
102    /// Create a new TypecastClient with just an API key
103    pub fn with_api_key(api_key: impl Into<String>) -> Result<Self> {
104        Self::new(ClientConfig::new(api_key))
105    }
106
107    /// Get the base URL
108    pub fn base_url(&self) -> &str {
109        &self.base_url
110    }
111
112    /// Get the API key (masked)
113    pub fn api_key_masked(&self) -> String {
114        if self.api_key.len() > 8 {
115            format!("{}...{}", &self.api_key[..4], &self.api_key[self.api_key.len()-4..])
116        } else {
117            "****".to_string()
118        }
119    }
120
121    /// Build a URL with optional query parameters
122    fn build_url(&self, path: &str, params: Option<Vec<(&str, String)>>) -> String {
123        let mut url = format!("{}{}", self.base_url, path);
124        if let Some(params) = params {
125            let query: Vec<String> = params
126                .into_iter()
127                .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
128                .collect();
129            if !query.is_empty() {
130                url = format!("{}?{}", url, query.join("&"));
131            }
132        }
133        url
134    }
135
136    /// Handle an error response
137    async fn handle_error_response(&self, response: reqwest::Response) -> TypecastError {
138        let status_code = response.status().as_u16();
139        let error_response: Option<ErrorResponse> = response.json().await.ok();
140        TypecastError::from_response(status_code, error_response)
141    }
142
143    /// Convert text to speech
144    ///
145    /// # Arguments
146    ///
147    /// * `request` - The TTS request containing text, voice_id, model, and optional settings
148    ///
149    /// # Returns
150    ///
151    /// Returns a `TTSResponse` containing the audio data, duration, and format
152    ///
153    /// # Example
154    ///
155    /// ```no_run
156    /// use typecast_rust::{TypecastClient, TTSRequest, TTSModel, ClientConfig};
157    ///
158    /// # async fn example() -> typecast_rust::Result<()> {
159    /// let client = TypecastClient::from_env()?;
160    /// let request = TTSRequest::new(
161    ///     "tc_60e5426de8b95f1d3000d7b5",
162    ///     "Hello, world!",
163    ///     TTSModel::SsfmV30,
164    /// );
165    /// let response = client.text_to_speech(&request).await?;
166    /// println!("Audio duration: {} seconds", response.duration);
167    /// # Ok(())
168    /// # }
169    /// ```
170    pub async fn text_to_speech(&self, request: &TTSRequest) -> Result<TTSResponse> {
171        let url = self.build_url("/v1/text-to-speech", None);
172        
173        let response = self.client
174            .post(&url)
175            .json(request)
176            .send()
177            .await?;
178
179        if !response.status().is_success() {
180            return Err(self.handle_error_response(response).await);
181        }
182
183        // Parse content type for format
184        let content_type = response
185            .headers()
186            .get(CONTENT_TYPE)
187            .and_then(|v| v.to_str().ok())
188            .unwrap_or("audio/wav");
189        
190        let format = if content_type.contains("mp3") || content_type.contains("mpeg") {
191            AudioFormat::Mp3
192        } else {
193            AudioFormat::Wav
194        };
195
196        // Parse duration from header
197        let duration = response
198            .headers()
199            .get("X-Audio-Duration")
200            .and_then(|v| v.to_str().ok())
201            .and_then(|v| v.parse::<f64>().ok())
202            .unwrap_or(0.0);
203
204        let audio_data = response.bytes().await?.to_vec();
205
206        Ok(TTSResponse {
207            audio_data,
208            duration,
209            format,
210        })
211    }
212
213    /// Get voices with enhanced metadata (V2 API)
214    ///
215    /// # Arguments
216    ///
217    /// * `filter` - Optional filter for voices (model, gender, age, use_cases)
218    ///
219    /// # Returns
220    ///
221    /// Returns a list of `VoiceV2` with enhanced metadata
222    ///
223    /// # Example
224    ///
225    /// ```no_run
226    /// use typecast_rust::{TypecastClient, VoicesV2Filter, TTSModel, Gender, ClientConfig};
227    ///
228    /// # async fn example() -> typecast_rust::Result<()> {
229    /// let client = TypecastClient::from_env()?;
230    /// 
231    /// // Get all voices
232    /// let voices = client.get_voices_v2(None).await?;
233    /// 
234    /// // Get filtered voices
235    /// let filter = VoicesV2Filter::new()
236    ///     .model(TTSModel::SsfmV30)
237    ///     .gender(Gender::Female);
238    /// let filtered_voices = client.get_voices_v2(Some(filter)).await?;
239    /// # Ok(())
240    /// # }
241    /// ```
242    pub async fn get_voices_v2(&self, filter: Option<VoicesV2Filter>) -> Result<Vec<VoiceV2>> {
243        let mut params = Vec::new();
244
245        if let Some(f) = filter {
246            if let Some(model) = f.model {
247                params.push(("model", serde_json::to_string(&model)?.trim_matches('"').to_string()));
248            }
249            if let Some(gender) = f.gender {
250                params.push(("gender", serde_json::to_string(&gender)?.trim_matches('"').to_string()));
251            }
252            if let Some(age) = f.age {
253                params.push(("age", serde_json::to_string(&age)?.trim_matches('"').to_string()));
254            }
255            if let Some(use_cases) = f.use_cases {
256                params.push(("use_cases", serde_json::to_string(&use_cases)?.trim_matches('"').to_string()));
257            }
258        }
259
260        let url = self.build_url("/v2/voices", if params.is_empty() { None } else { Some(params) });
261
262        let response = self.client
263            .get(&url)
264            .send()
265            .await?;
266
267        if !response.status().is_success() {
268            return Err(self.handle_error_response(response).await);
269        }
270
271        let voices: Vec<VoiceV2> = response.json().await?;
272        Ok(voices)
273    }
274
275    /// Get a specific voice by ID with enhanced metadata (V2 API)
276    ///
277    /// # Arguments
278    ///
279    /// * `voice_id` - The voice ID (e.g., 'tc_60e5426de8b95f1d3000d7b5')
280    ///
281    /// # Returns
282    ///
283    /// Returns a `VoiceV2` with enhanced metadata
284    ///
285    /// # Example
286    ///
287    /// ```no_run
288    /// use typecast_rust::{TypecastClient, ClientConfig};
289    ///
290    /// # async fn example() -> typecast_rust::Result<()> {
291    /// let client = TypecastClient::from_env()?;
292    /// let voice = client.get_voice_v2("tc_60e5426de8b95f1d3000d7b5").await?;
293    /// println!("Voice: {} ({})", voice.voice_name, voice.voice_id);
294    /// # Ok(())
295    /// # }
296    /// ```
297    pub async fn get_voice_v2(&self, voice_id: &str) -> Result<VoiceV2> {
298        let url = self.build_url(&format!("/v2/voices/{}", voice_id), None);
299
300        let response = self.client
301            .get(&url)
302            .send()
303            .await?;
304
305        if !response.status().is_success() {
306            return Err(self.handle_error_response(response).await);
307        }
308
309        let voice: VoiceV2 = response.json().await?;
310        Ok(voice)
311    }
312}
313
314/// URL encoding helper
315mod urlencoding {
316    pub fn encode(s: &str) -> String {
317        url_encode(s)
318    }
319
320    fn url_encode(s: &str) -> String {
321        let mut result = String::new();
322        for c in s.chars() {
323            match c {
324                'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => {
325                    result.push(c);
326                }
327                _ => {
328                    for b in c.to_string().as_bytes() {
329                        result.push_str(&format!("%{:02X}", b));
330                    }
331                }
332            }
333        }
334        result
335    }
336}