1use crate::http::{HttpClientConfig, HttpError};
35use crate::RetryableError;
36use reqwest::Client;
37use std::fmt;
38use std::time::Duration;
39use thiserror::Error;
40
41#[derive(Debug, Clone, Copy)]
47pub enum NoDomainError {}
48
49impl fmt::Display for NoDomainError {
50 fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 match *self {}
52 }
53}
54
55impl std::error::Error for NoDomainError {}
56
57const MAX_ERROR_BODY_LENGTH: usize = 500;
59
60#[must_use]
69pub fn join_url(base: &str, path: &str) -> String {
70 if let Ok(base_url) = url::Url::parse(base) {
72 let path_to_join = path.trim_start_matches('/');
75
76 let base_path = base_url.path();
78 if base_path.ends_with('/') {
79 if let Ok(joined) = base_url.join(path_to_join) {
80 return joined.to_string();
81 }
82 } else {
83 let base_str = base.trim_end_matches('/');
85 return format!("{base_str}/{path_to_join}");
86 }
87 }
88
89 let base_str = base.trim_end_matches('/');
91 if path.starts_with('/') {
92 format!("{base_str}{path}")
93 } else {
94 format!("{base_str}/{path}")
95 }
96}
97
98#[must_use]
116pub fn sanitize_error_body(body: &str) -> String {
117 let truncated = if body.len() > MAX_ERROR_BODY_LENGTH {
119 format!("{}... (truncated)", &body[..MAX_ERROR_BODY_LENGTH])
120 } else {
121 body.to_string()
122 };
123
124 let mut result = truncated;
127
128 for pattern in [
131 "key=",
132 "apikey=",
133 "api_key=",
134 "token=",
135 "secret=",
136 "auth=",
137 "password=",
138 "private_key=",
139 "privatekey=",
140 "pk=",
141 "client_secret=",
142 "client_id=",
143 "access_key=",
144 "secret_key=",
145 ] {
146 let mut search_from = 0;
148 loop {
149 let lowercase = result.to_lowercase();
150 if let Some(relative_pos) = lowercase[search_from..].find(pattern) {
152 let start = search_from + relative_pos;
153 let value_start = start + pattern.len();
155 let value_end = result[value_start..]
156 .find(|c: char| c == '&' || c.is_whitespace())
157 .map_or(result.len(), |i| value_start + i);
158
159 let value = &result[value_start..value_end];
161 if value_end > value_start && value != "[REDACTED]" {
162 let original_pattern = &result[start..start + pattern.len()];
164 result = format!(
165 "{}{}[REDACTED]{}",
166 &result[..start],
167 original_pattern,
168 &result[value_end..]
169 );
170 search_from = start + pattern.len() + "[REDACTED]".len();
172 } else {
173 search_from = value_start;
175 }
176 } else {
177 break; }
179 }
180 }
181
182 let mut search_from = 0;
184 loop {
185 let lowercase = result.to_lowercase();
186 if let Some(relative_pos) = lowercase[search_from..].find("bearer ") {
187 let start = search_from + relative_pos;
188 let token_start = start + 7;
189 let token_end = result[token_start..]
190 .find(|c: char| c.is_whitespace())
191 .map_or(result.len(), |i| token_start + i);
192 let token = &result[token_start..token_end];
193 if token_end > token_start && token != "[REDACTED]" {
194 result = format!(
195 "{}Bearer [REDACTED]{}",
196 &result[..start],
197 &result[token_end..]
198 );
199 search_from = start + "Bearer [REDACTED]".len();
200 } else {
201 search_from = token_start;
202 }
203 } else {
204 break;
205 }
206 }
207
208 for header_pattern in [
210 "x-api-key:",
211 "x-auth-token:",
212 "authorization:",
213 "x-secret:",
214 "api-key:",
215 ] {
216 let mut search_from = 0;
217 loop {
218 let lowercase = result.to_lowercase();
219 if let Some(relative_pos) = lowercase[search_from..].find(header_pattern) {
220 let start = search_from + relative_pos;
221 let value_start = start + header_pattern.len();
222 let trimmed_start = result[value_start..]
224 .find(|c: char| !c.is_whitespace())
225 .map_or(value_start, |i| value_start + i);
226 let value_end = result[trimmed_start..]
228 .find(['\n', '\r'])
229 .map_or(result.len(), |i| trimmed_start + i);
230 let value = &result[trimmed_start..value_end];
231 if value_end > trimmed_start && value != "[REDACTED]" {
232 let original_header = &result[start..start + header_pattern.len()];
233 result = format!(
234 "{}{} [REDACTED]{}",
235 &result[..start],
236 original_header,
237 &result[value_end..]
238 );
239 search_from = start + header_pattern.len() + " [REDACTED]".len();
240 } else {
241 search_from = value_start;
242 }
243 } else {
244 break;
245 }
246 }
247 }
248
249 for json_key in [
253 "\"key\"",
254 "\"apikey\"",
255 "\"api_key\"",
256 "\"apiKey\"",
257 "\"token\"",
258 "\"secret\"",
259 "\"password\"",
260 "\"auth\"",
261 "\"access_token\"",
262 "\"accessToken\"",
263 "\"refresh_token\"",
264 "\"refreshToken\"",
265 "\"api-key\"",
266 "\"private_key\"",
267 "\"privateKey\"",
268 "\"client_secret\"",
269 "\"clientSecret\"",
270 "\"client_id\"",
271 "\"clientId\"",
272 "\"secret_key\"",
273 "\"secretKey\"",
274 ] {
275 let mut search_from = 0;
277 loop {
278 let lowercase = result.to_lowercase();
279 if let Some(relative_pos) = lowercase[search_from..].find(json_key) {
281 let key_start = search_from + relative_pos;
282 let after_key = key_start + json_key.len();
284 let remaining = &result[after_key..];
285
286 if let Some(colon_offset) = remaining.find(':') {
288 let after_colon = &remaining[colon_offset + 1..];
289 let quote_offset = after_colon.find('"');
291 if let Some(qo) = quote_offset {
292 let value_start_abs = after_key + colon_offset + 1 + qo + 1;
293 let value_content = &result[value_start_abs..];
295 let mut end_quote = 0;
296 let mut chars = value_content.chars().peekable();
297 while let Some(c) = chars.next() {
298 if c == '\\' {
299 chars.next();
301 end_quote += 2;
302 } else if c == '"' {
303 break;
304 } else {
305 end_quote += c.len_utf8();
306 }
307 }
308 if end_quote > 0 {
309 let value_end_abs = value_start_abs + end_quote;
310 result = format!(
312 "{}[REDACTED]{}",
313 &result[..value_start_abs],
314 &result[value_end_abs..]
315 );
316 search_from = value_start_abs + "[REDACTED]".len();
318 } else {
319 search_from = after_key;
321 }
322 } else {
323 search_from = after_key;
325 }
326 } else {
327 search_from = after_key;
329 }
330 } else {
331 break;
332 }
333 }
334 }
335
336 let mut search_from = 0;
339 while let Some(pos) = result[search_from..].find("0x") {
340 let abs_pos = search_from + pos;
341 let after_0x = abs_pos + 2;
342
343 if after_0x + 64 <= result.len() {
345 let potential_key = &result[after_0x..after_0x + 64];
346 if potential_key.chars().all(|c| c.is_ascii_hexdigit()) {
347 let is_exact_64 = after_0x + 64 >= result.len()
349 || !result[after_0x + 64..]
350 .chars()
351 .next()
352 .is_some_and(|c| c.is_ascii_hexdigit());
353
354 if is_exact_64 {
355 result = format!(
356 "{}0x[REDACTED_KEY]{}",
357 &result[..abs_pos],
358 &result[after_0x + 64..]
359 );
360 search_from = abs_pos + "0x[REDACTED_KEY]".len();
361 continue;
362 }
363 }
364 }
365 search_from = after_0x;
366 }
367
368 result
369}
370
371#[derive(Error, Debug)]
384#[non_exhaustive]
385pub enum ApiError<E: std::error::Error = NoDomainError> {
386 #[error("HTTP error: {0}")]
388 Http(#[from] reqwest::Error),
389
390 #[error("HTTP client error: {0}")]
392 HttpBuild(#[source] HttpError),
393
394 #[error("JSON error: {0}")]
396 Json(#[from] serde_json::Error),
397
398 #[error("API error: {status} - {message}")]
400 Api {
401 status: u16,
403 message: String,
405 },
406
407 #[error("Rate limited{}", .retry_after.map(|s| format!(" (retry after {s}s)")).unwrap_or_default())]
409 RateLimited {
410 retry_after: Option<u64>,
412 },
413
414 #[error("Server error ({status}): {message}")]
416 ServerError {
417 status: u16,
419 message: String,
421 },
422
423 #[error("URL error: {0}")]
425 Url(#[from] url::ParseError),
426
427 #[error(transparent)]
429 Domain(E),
430}
431
432impl<E: std::error::Error> From<HttpError> for ApiError<E> {
434 fn from(e: HttpError) -> Self {
435 ApiError::HttpBuild(e)
436 }
437}
438
439impl<E: std::error::Error> ApiError<E> {
440 pub fn api(status: u16, message: impl Into<String>) -> Self {
442 Self::Api {
443 status,
444 message: message.into(),
445 }
446 }
447
448 #[must_use]
450 pub fn rate_limited(retry_after: Option<u64>) -> Self {
451 Self::RateLimited { retry_after }
452 }
453
454 pub fn server_error(status: u16, message: impl Into<String>) -> Self {
456 Self::ServerError {
457 status,
458 message: message.into(),
459 }
460 }
461
462 pub fn domain(error: E) -> Self {
464 Self::Domain(error)
465 }
466
467 #[must_use]
477 pub fn from_response(status: u16, body: &str, retry_after: Option<u64>) -> Self {
478 let sanitized = sanitize_error_body(body);
479 match status {
480 429 => Self::RateLimited { retry_after },
481 500..=599 => Self::ServerError {
482 status,
483 message: sanitized,
484 },
485 _ => Self::Api {
486 status,
487 message: sanitized,
488 },
489 }
490 }
491
492 pub fn is_retryable(&self) -> bool {
499 matches!(
500 self,
501 Self::RateLimited { .. } | Self::ServerError { .. } | Self::Http(_)
502 )
503 }
504
505 pub fn retry_after(&self) -> Option<Duration> {
507 if let Self::RateLimited {
508 retry_after: Some(secs),
509 } = self
510 {
511 Some(Duration::from_secs(*secs))
512 } else {
513 None
514 }
515 }
516
517 pub fn status_code(&self) -> Option<u16> {
519 match self {
520 Self::Api { status, .. } => Some(*status),
521 Self::ServerError { status, .. } => Some(*status),
522 Self::RateLimited { .. } => Some(429),
523 _ => None,
524 }
525 }
526}
527
528impl<E: std::error::Error> RetryableError for ApiError<E> {
529 fn is_retryable(&self) -> bool {
530 ApiError::is_retryable(self)
531 }
532
533 fn retry_after(&self) -> Option<Duration> {
534 ApiError::retry_after(self)
535 }
536}
537
538pub type ApiResult<T, E = NoDomainError> = std::result::Result<T, ApiError<E>>;
540
541#[derive(Clone)]
561pub struct SecretApiKey(String);
562
563impl SecretApiKey {
564 pub fn new(key: impl Into<String>) -> Self {
566 Self(key.into())
567 }
568
569 #[must_use]
573 pub fn expose(&self) -> &str {
574 &self.0
575 }
576
577 #[must_use]
579 pub fn is_empty(&self) -> bool {
580 self.0.is_empty()
581 }
582}
583
584impl fmt::Debug for SecretApiKey {
585 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
586 f.debug_tuple("SecretApiKey").field(&"[REDACTED]").finish()
587 }
588}
589
590impl From<String> for SecretApiKey {
591 fn from(s: String) -> Self {
592 Self::new(s)
593 }
594}
595
596impl From<&str> for SecretApiKey {
597 fn from(s: &str) -> Self {
598 Self::new(s)
599 }
600}
601
602#[derive(Clone)]
618pub struct ApiConfig {
619 pub base_url: String,
621 pub api_key: Option<SecretApiKey>,
623 pub http: HttpClientConfig,
625}
626
627impl fmt::Debug for ApiConfig {
628 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
629 f.debug_struct("ApiConfig")
630 .field("base_url", &self.base_url)
631 .field("api_key", &self.api_key)
632 .field("http", &self.http)
633 .finish()
634 }
635}
636
637impl ApiConfig {
638 pub fn new(base_url: impl Into<String>) -> Self {
640 Self {
641 base_url: base_url.into(),
642 api_key: None,
643 http: HttpClientConfig::default(),
644 }
645 }
646
647 pub fn with_api_key(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
649 Self {
650 base_url: base_url.into(),
651 api_key: Some(SecretApiKey::new(api_key)),
652 http: HttpClientConfig::default(),
653 }
654 }
655
656 #[must_use]
658 pub fn api_key(mut self, key: impl Into<String>) -> Self {
659 self.api_key = Some(SecretApiKey::new(key));
660 self
661 }
662
663 #[must_use]
665 pub fn optional_api_key(mut self, key: Option<String>) -> Self {
666 self.api_key = key.map(SecretApiKey::new);
667 self
668 }
669
670 #[must_use]
672 pub fn timeout(mut self, timeout: Duration) -> Self {
673 self.http.timeout = timeout;
674 self
675 }
676
677 #[must_use]
679 pub fn with_timeout_secs(mut self, secs: u64) -> Self {
680 self.http.timeout = Duration::from_secs(secs);
681 self
682 }
683
684 #[must_use]
686 pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
687 self.http.proxy = Some(proxy.into());
688 self
689 }
690
691 #[must_use]
693 pub fn optional_proxy(mut self, proxy: Option<String>) -> Self {
694 self.http.proxy = proxy;
695 self
696 }
697
698 pub fn build_client(&self) -> Result<Client, HttpError> {
700 crate::http::build_client(&self.http)
701 }
702
703 pub fn validate(&self) -> Result<(), ConfigValidationError> {
723 let url = url::Url::parse(&self.base_url)
725 .map_err(|e| ConfigValidationError::InvalidUrl(e.to_string()))?;
726
727 match url.scheme() {
729 "https" => Ok(()),
730 "http" => {
731 if let Some(host) = url.host_str() {
733 if host == "localhost" || host == "127.0.0.1" || host == "::1" {
734 return Ok(());
735 }
736 }
737 Err(ConfigValidationError::InsecureScheme)
738 }
739 scheme => Err(ConfigValidationError::InvalidUrl(format!(
740 "Unsupported URL scheme: {scheme}"
741 ))),
742 }
743 }
744
745 #[must_use]
747 pub fn is_https(&self) -> bool {
748 self.base_url.starts_with("https://")
749 }
750
751 #[must_use]
753 pub fn get_api_key(&self) -> Option<&str> {
754 self.api_key.as_ref().map(SecretApiKey::expose)
755 }
756}
757
758#[derive(Debug, Clone, PartialEq, Eq)]
760pub enum ConfigValidationError {
761 InsecureScheme,
763 InvalidUrl(String),
765}
766
767impl fmt::Display for ConfigValidationError {
768 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
769 match self {
770 Self::InsecureScheme => write!(
771 f,
772 "Insecure URL scheme: use HTTPS instead of HTTP to protect API keys"
773 ),
774 Self::InvalidUrl(msg) => write!(f, "Invalid URL: {msg}"),
775 }
776 }
777}
778
779impl std::error::Error for ConfigValidationError {}
780
781#[derive(Debug, Clone)]
807pub struct BaseClient {
808 http: Client,
809 config: ApiConfig,
810 normalized_base_url: String,
812}
813
814impl BaseClient {
815 pub fn new(config: ApiConfig) -> Result<Self, HttpError> {
826 if let Err(e) = config.validate() {
828 return Err(HttpError::BuildError(format!(
829 "Configuration validation failed: {e}. Use HTTPS URLs to protect API keys."
830 )));
831 }
832
833 let http = config.build_client()?;
834 let normalized_base_url = config.base_url.trim_end_matches('/').to_string();
836 Ok(Self {
837 http,
838 config,
839 normalized_base_url,
840 })
841 }
842
843 #[must_use]
845 pub fn http(&self) -> &Client {
846 &self.http
847 }
848
849 #[must_use]
851 pub fn config(&self) -> &ApiConfig {
852 &self.config
853 }
854
855 #[must_use]
857 pub fn base_url(&self) -> &str {
858 &self.config.base_url
859 }
860
861 #[must_use]
866 pub fn url(&self, path: &str) -> String {
867 if !path.contains('?') && !self.normalized_base_url.contains('?') {
870 if path.starts_with('/') {
871 format!("{}{}", self.normalized_base_url, path)
872 } else {
873 format!("{}/{}", self.normalized_base_url, path)
874 }
875 } else {
876 join_url(&self.normalized_base_url, path)
878 }
879 }
880
881 pub fn default_headers(&self) -> reqwest::header::HeaderMap {
885 let mut headers = reqwest::header::HeaderMap::new();
886
887 if let Some(key) = self.config.get_api_key() {
889 if let Ok(value) = reqwest::header::HeaderValue::from_str(&format!("Bearer {key}")) {
890 headers.insert(reqwest::header::AUTHORIZATION, value);
891 }
892 }
893
894 headers
895 }
896
897 pub async fn get<T, E>(
916 &self,
917 path: &str,
918 params: &[(&str, impl AsRef<str>)],
919 ) -> Result<T, ApiError<E>>
920 where
921 T: serde::de::DeserializeOwned,
922 E: std::error::Error,
923 {
924 self.get_with_headers(path, params, self.default_headers())
925 .await
926 }
927
928 pub async fn get_with_headers<T, E>(
933 &self,
934 path: &str,
935 params: &[(&str, impl AsRef<str>)],
936 headers: reqwest::header::HeaderMap,
937 ) -> Result<T, ApiError<E>>
938 where
939 T: serde::de::DeserializeOwned,
940 E: std::error::Error,
941 {
942 let url = self.url(path);
943 let query: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_ref())).collect();
944
945 let response = self
946 .http
947 .get(&url)
948 .headers(headers)
949 .query(&query)
950 .send()
951 .await?;
952
953 self.handle_response(response).await
954 }
955
956 pub async fn post_json<T, B, E>(&self, path: &str, body: &B) -> Result<T, ApiError<E>>
971 where
972 T: serde::de::DeserializeOwned,
973 B: serde::Serialize,
974 E: std::error::Error,
975 {
976 self.post_json_with_headers(path, body, self.default_headers())
977 .await
978 }
979
980 pub async fn post_json_with_headers<T, B, E>(
982 &self,
983 path: &str,
984 body: &B,
985 headers: reqwest::header::HeaderMap,
986 ) -> Result<T, ApiError<E>>
987 where
988 T: serde::de::DeserializeOwned,
989 B: serde::Serialize,
990 E: std::error::Error,
991 {
992 let url = self.url(path);
993
994 let response = self
995 .http
996 .post(&url)
997 .headers(headers)
998 .json(body)
999 .send()
1000 .await?;
1001
1002 self.handle_response(response).await
1003 }
1004
1005 pub async fn post_form<T, E>(
1007 &self,
1008 path: &str,
1009 form: &[(&str, impl AsRef<str>)],
1010 ) -> Result<T, ApiError<E>>
1011 where
1012 T: serde::de::DeserializeOwned,
1013 E: std::error::Error,
1014 {
1015 let url = self.url(path);
1016 let form_data: Vec<(&str, &str)> = form.iter().map(|(k, v)| (*k, v.as_ref())).collect();
1017
1018 let response = self
1019 .http
1020 .post(&url)
1021 .headers(self.default_headers())
1022 .form(&form_data)
1023 .send()
1024 .await?;
1025
1026 self.handle_response(response).await
1027 }
1028
1029 async fn handle_response<T, E>(&self, response: reqwest::Response) -> Result<T, ApiError<E>>
1031 where
1032 T: serde::de::DeserializeOwned,
1033 E: std::error::Error,
1034 {
1035 if response.status().is_success() {
1036 Ok(response.json().await?)
1037 } else {
1038 Err(handle_error_response(response).await)
1039 }
1040 }
1041}
1042
1043#[must_use]
1074pub fn extract_retry_after(headers: &reqwest::header::HeaderMap) -> Option<u64> {
1075 const MAX_RETRY_AFTER_SECS: u64 = 3600; headers
1078 .get("retry-after")
1079 .and_then(|v| v.to_str().ok())
1080 .and_then(|v| v.trim().parse::<u64>().ok())
1081 .map(|secs| secs.min(MAX_RETRY_AFTER_SECS))
1082}
1083
1084pub async fn handle_error_response<E: std::error::Error>(
1089 response: reqwest::Response,
1090) -> ApiError<E> {
1091 let status = response.status().as_u16();
1092 let retry_after = extract_retry_after(response.headers());
1093 let body = response.text().await.unwrap_or_default();
1094 ApiError::from_response(status, &body, retry_after)
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099 use super::*;
1100
1101 #[test]
1102 fn test_api_error_display() {
1103 let err: ApiError = ApiError::api(400, "Bad request");
1104 assert!(err.to_string().contains("400"));
1105 assert!(err.to_string().contains("Bad request"));
1106 }
1107
1108 #[test]
1109 fn test_rate_limited_display() {
1110 let err: ApiError = ApiError::rate_limited(Some(60));
1111 assert!(err.to_string().contains("60"));
1112 }
1113
1114 #[test]
1115 fn test_rate_limited_no_retry() {
1116 let err: ApiError = ApiError::rate_limited(None);
1117 assert!(err.to_string().contains("Rate limited"));
1118 assert!(!err.to_string().contains("retry"));
1119 }
1120
1121 #[test]
1122 fn test_is_retryable() {
1123 let err: ApiError = ApiError::rate_limited(Some(10));
1124 assert!(err.is_retryable());
1125 let err: ApiError = ApiError::server_error(500, "error");
1126 assert!(err.is_retryable());
1127 let err: ApiError = ApiError::api(400, "bad request");
1128 assert!(!err.is_retryable());
1129 }
1130
1131 #[test]
1132 fn test_from_response() {
1133 let err: ApiError = ApiError::from_response(429, "rate limited", Some(30));
1134 assert!(matches!(
1135 err,
1136 ApiError::RateLimited {
1137 retry_after: Some(30)
1138 }
1139 ));
1140
1141 let err: ApiError = ApiError::from_response(503, "service unavailable", None);
1142 assert!(matches!(err, ApiError::ServerError { status: 503, .. }));
1143
1144 let err: ApiError = ApiError::from_response(400, "bad request", None);
1145 assert!(matches!(err, ApiError::Api { status: 400, .. }));
1146 }
1147
1148 #[test]
1149 fn test_retry_after() {
1150 let err: ApiError = ApiError::rate_limited(Some(30));
1151 assert_eq!(err.retry_after(), Some(Duration::from_secs(30)));
1152
1153 let err: ApiError = ApiError::api(400, "bad");
1154 assert_eq!(err.retry_after(), None);
1155 }
1156
1157 #[test]
1158 fn test_status_code() {
1159 let err: ApiError = ApiError::api(400, "bad");
1160 assert_eq!(err.status_code(), Some(400));
1161 let err: ApiError = ApiError::server_error(503, "down");
1162 assert_eq!(err.status_code(), Some(503));
1163 let err: ApiError = ApiError::rate_limited(None);
1164 assert_eq!(err.status_code(), Some(429));
1165 let err: ApiError = ApiError::Json(serde_json::from_str::<()>("invalid").unwrap_err());
1166 assert_eq!(err.status_code(), None);
1167 }
1168
1169 #[test]
1170 fn test_api_config() {
1171 let config = ApiConfig::new("https://api.example.com")
1172 .api_key("test-key")
1173 .with_timeout_secs(60)
1174 .proxy("http://proxy:8080");
1175
1176 assert_eq!(config.base_url, "https://api.example.com");
1177 assert_eq!(config.get_api_key(), Some("test-key"));
1178 assert_eq!(config.http.timeout, Duration::from_secs(60));
1179 assert_eq!(config.http.proxy, Some("http://proxy:8080".to_string()));
1180 }
1181
1182 #[test]
1183 fn test_api_config_build_client() {
1184 let config = ApiConfig::new("https://api.example.com");
1185 let client = config.build_client();
1186 assert!(client.is_ok());
1187 }
1188
1189 #[test]
1190 fn test_secret_api_key_redacted() {
1191 let key = SecretApiKey::new("sk-secret-key-12345");
1192 let debug_output = format!("{:?}", key);
1193 assert!(debug_output.contains("REDACTED"));
1194 assert!(!debug_output.contains("sk-secret"));
1195 assert_eq!(key.expose(), "sk-secret-key-12345");
1196 }
1197
1198 #[test]
1199 fn test_api_config_debug_redacts_key() {
1200 let config = ApiConfig::with_api_key("https://api.example.com", "super-secret-key");
1201 let debug_output = format!("{:?}", config);
1202 assert!(debug_output.contains("REDACTED"));
1203 assert!(!debug_output.contains("super-secret-key"));
1204 }
1205
1206 #[test]
1207 fn test_config_validation_https() {
1208 let config = ApiConfig::new("https://api.example.com");
1210 assert!(config.validate().is_ok());
1211 assert!(config.is_https());
1212
1213 let config = ApiConfig::new("http://api.example.com");
1215 assert!(config.validate().is_err());
1216 assert!(!config.is_https());
1217 assert_eq!(
1218 config.validate().unwrap_err(),
1219 ConfigValidationError::InsecureScheme
1220 );
1221 }
1222
1223 #[test]
1224 fn test_config_validation_localhost() {
1225 let config = ApiConfig::new("http://localhost:8080");
1227 assert!(config.validate().is_ok());
1228
1229 let config = ApiConfig::new("http://127.0.0.1:8080");
1230 assert!(config.validate().is_ok());
1231 }
1232
1233 #[test]
1234 fn test_config_validation_invalid_url() {
1235 let config = ApiConfig::new("not a url");
1236 let result = config.validate();
1237 assert!(matches!(result, Err(ConfigValidationError::InvalidUrl(_))));
1238 }
1239
1240 #[derive(Debug, thiserror::Error)]
1242 enum TestDomainError {
1243 #[error("No route found")]
1244 NoRouteFound,
1245 }
1246
1247 #[test]
1248 fn test_domain_error() {
1249 let err: ApiError<TestDomainError> = ApiError::domain(TestDomainError::NoRouteFound);
1250 assert!(err.to_string().contains("No route found"));
1251 assert!(!err.is_retryable());
1252 }
1253
1254 #[test]
1256 fn test_base_client_creation() {
1257 let config = ApiConfig::new("https://api.example.com");
1258 let client = BaseClient::new(config);
1259 assert!(client.is_ok());
1260 }
1261
1262 #[test]
1263 fn test_base_client_url_building() {
1264 let config = ApiConfig::new("https://api.example.com");
1265 let client = BaseClient::new(config).unwrap();
1266
1267 assert_eq!(client.url("/quote"), "https://api.example.com/quote");
1269
1270 assert_eq!(client.url("quote"), "https://api.example.com/quote");
1272
1273 assert_eq!(
1275 client.url("/v1/swap/quote"),
1276 "https://api.example.com/v1/swap/quote"
1277 );
1278 }
1279
1280 #[test]
1281 fn test_base_client_url_building_trailing_slash() {
1282 let config = ApiConfig::new("https://api.example.com/");
1284 let client = BaseClient::new(config).unwrap();
1285
1286 assert_eq!(client.url("/quote"), "https://api.example.com/quote");
1287 assert_eq!(client.url("quote"), "https://api.example.com/quote");
1288 }
1289
1290 #[test]
1291 fn test_base_client_default_headers_no_key() {
1292 let config = ApiConfig::new("https://api.example.com");
1293 let client = BaseClient::new(config).unwrap();
1294 let headers = client.default_headers();
1295
1296 assert!(!headers.contains_key(reqwest::header::AUTHORIZATION));
1298 }
1299
1300 #[test]
1301 fn test_base_client_default_headers_with_key() {
1302 let config = ApiConfig::new("https://api.example.com").api_key("test-key");
1303 let client = BaseClient::new(config).unwrap();
1304 let headers = client.default_headers();
1305
1306 assert!(headers.contains_key(reqwest::header::AUTHORIZATION));
1308 assert_eq!(
1309 headers.get(reqwest::header::AUTHORIZATION).unwrap(),
1310 "Bearer test-key"
1311 );
1312 }
1313
1314 #[test]
1315 fn test_base_client_accessors() {
1316 let config = ApiConfig::new("https://api.example.com").api_key("my-key");
1317 let client = BaseClient::new(config).unwrap();
1318
1319 assert_eq!(client.base_url(), "https://api.example.com");
1320 assert_eq!(client.config().get_api_key(), Some("my-key"));
1321 }
1322
1323 #[test]
1324 fn test_sanitize_error_body_truncation() {
1325 let long_body = "a".repeat(1000);
1326 let sanitized = super::sanitize_error_body(&long_body);
1327 assert!(sanitized.len() < 600); assert!(sanitized.ends_with("... (truncated)"));
1329 }
1330
1331 #[test]
1332 fn test_sanitize_error_body_key_redaction() {
1333 let body = "Error: ?api_key=secret123&foo=bar";
1334 let sanitized = super::sanitize_error_body(body);
1335 assert!(sanitized.contains("[REDACTED]"));
1336 assert!(!sanitized.contains("secret123"));
1337 }
1338
1339 #[test]
1340 fn test_sanitize_error_body_bearer_redaction() {
1341 let body = "Authorization: Bearer mysecrettoken123";
1342 let sanitized = super::sanitize_error_body(body);
1343 assert!(sanitized.contains("[REDACTED]"));
1344 assert!(!sanitized.contains("mysecrettoken123"));
1345 }
1346
1347 #[test]
1348 fn test_sanitize_error_body_no_redaction_needed() {
1349 let body = "Simple error message";
1350 let sanitized = super::sanitize_error_body(body);
1351 assert_eq!(sanitized, body);
1352 }
1353
1354 #[test]
1355 fn test_sanitize_error_body_json_key_redaction() {
1356 let body = r#"{"error": "Invalid API Key", "key": "sk_live_secret123", "status": 401}"#;
1358 let sanitized = super::sanitize_error_body(body);
1359 assert!(sanitized.contains("[REDACTED]"));
1360 assert!(!sanitized.contains("sk_live_secret123"));
1361 assert!(sanitized.contains("\"error\""));
1363 assert!(sanitized.contains("\"status\""));
1364 }
1365
1366 #[test]
1367 fn test_sanitize_error_body_json_api_key_redaction() {
1368 let body = r#"{"api_key": "test_api_key_12345", "message": "unauthorized"}"#;
1369 let sanitized = super::sanitize_error_body(body);
1370 assert!(sanitized.contains("[REDACTED]"));
1371 assert!(!sanitized.contains("test_api_key_12345"));
1372 }
1373
1374 #[test]
1375 fn test_sanitize_error_body_header_redaction() {
1376 let body = "Request failed\nX-API-Key: my_secret_key_here\nContent-Type: application/json";
1378 let sanitized = super::sanitize_error_body(body);
1379 assert!(sanitized.contains("[REDACTED]"));
1380 assert!(!sanitized.contains("my_secret_key_here"));
1381 assert!(sanitized.contains("Content-Type"));
1383 }
1384
1385 #[test]
1386 fn test_sanitize_error_body_authorization_header() {
1387 let body = "Error: Authorization: Basic dXNlcjpwYXNz";
1388 let sanitized = super::sanitize_error_body(body);
1389 assert!(sanitized.contains("[REDACTED]"));
1390 assert!(!sanitized.contains("dXNlcjpwYXNz"));
1391 }
1392
1393 #[test]
1394 fn test_sanitize_error_body_multiple_json_keys() {
1395 let body = r#"{"key": "key1", "token": "token1", "api_key": "api1"}"#;
1397 let sanitized = super::sanitize_error_body(body);
1398 assert!(!sanitized.contains("key1"));
1399 assert!(!sanitized.contains("token1"));
1400 assert!(!sanitized.contains("api1"));
1401 }
1402
1403 #[test]
1404 fn test_sanitize_error_body_hex_private_key() {
1405 let body = "Error: Invalid key 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef in request";
1407 let sanitized = super::sanitize_error_body(body);
1408 assert!(sanitized.contains("0x[REDACTED_KEY]"));
1409 assert!(!sanitized.contains("1234567890abcdef"));
1410 }
1411
1412 #[test]
1413 fn test_sanitize_error_body_private_key_param() {
1414 let body = "Error: ?private_key=secretkey123&foo=bar";
1416 let sanitized = super::sanitize_error_body(body);
1417 assert!(sanitized.contains("[REDACTED]"));
1418 assert!(!sanitized.contains("secretkey123"));
1419 }
1420
1421 #[test]
1422 fn test_sanitize_error_body_client_secret() {
1423 let body = r#"{"client_secret": "my_secret_value", "client_id": "my_client_id"}"#;
1425 let sanitized = super::sanitize_error_body(body);
1426 assert!(!sanitized.contains("my_secret_value"));
1427 assert!(!sanitized.contains("my_client_id"));
1428 }
1429}