1use 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
13pub const DEFAULT_BASE_URL: &str = "https://api.typecast.ai";
15
16pub const DEFAULT_TIMEOUT_SECS: u64 = 60;
18
19#[derive(Debug, Clone)]
21pub struct ClientConfig {
22 pub api_key: String,
24 pub base_url: String,
26 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 pub fn new(api_key: impl Into<String>) -> Self {
43 Self {
44 api_key: api_key.into(),
45 ..Default::default()
46 }
47 }
48
49 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
51 self.base_url = base_url.into();
52 self
53 }
54
55 pub fn timeout(mut self, timeout: Duration) -> Self {
57 self.timeout = timeout;
58 self
59 }
60}
61
62#[derive(Debug, Clone)]
64pub struct TypecastClient {
65 client: reqwest::Client,
66 base_url: String,
67 api_key: String,
68}
69
70impl TypecastClient {
71 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 pub fn from_env() -> Result<Self> {
99 Self::new(ClientConfig::default())
100 }
101
102 pub fn with_api_key(api_key: impl Into<String>) -> Result<Self> {
104 Self::new(ClientConfig::new(api_key))
105 }
106
107 pub fn base_url(&self) -> &str {
109 &self.base_url
110 }
111
112 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 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 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 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 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 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 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 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
314mod 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}