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
57#[derive(Error, Debug)]
70#[non_exhaustive]
71pub enum ApiError<E: std::error::Error = NoDomainError> {
72 #[error("HTTP error: {0}")]
74 Http(#[from] reqwest::Error),
75
76 #[error("HTTP client error: {0}")]
78 HttpBuild(#[source] HttpError),
79
80 #[error("JSON error: {0}")]
82 Json(#[from] serde_json::Error),
83
84 #[error("API error: {status} - {message}")]
86 Api {
87 status: u16,
89 message: String,
91 },
92
93 #[error("Rate limited{}", .retry_after.map(|s| format!(" (retry after {}s)", s)).unwrap_or_default())]
95 RateLimited {
96 retry_after: Option<u64>,
98 },
99
100 #[error("Server error ({status}): {message}")]
102 ServerError {
103 status: u16,
105 message: String,
107 },
108
109 #[error("URL error: {0}")]
111 Url(#[from] url::ParseError),
112
113 #[error(transparent)]
115 Domain(E),
116}
117
118impl<E: std::error::Error> From<HttpError> for ApiError<E> {
120 fn from(e: HttpError) -> Self {
121 ApiError::HttpBuild(e)
122 }
123}
124
125impl<E: std::error::Error> ApiError<E> {
126 pub fn api(status: u16, message: impl Into<String>) -> Self {
128 Self::Api {
129 status,
130 message: message.into(),
131 }
132 }
133
134 pub fn rate_limited(retry_after: Option<u64>) -> Self {
136 Self::RateLimited { retry_after }
137 }
138
139 pub fn server_error(status: u16, message: impl Into<String>) -> Self {
141 Self::ServerError {
142 status,
143 message: message.into(),
144 }
145 }
146
147 pub fn domain(error: E) -> Self {
149 Self::Domain(error)
150 }
151
152 pub fn from_response(status: u16, body: &str, retry_after: Option<u64>) -> Self {
159 match status {
160 429 => Self::RateLimited { retry_after },
161 500..=599 => Self::ServerError {
162 status,
163 message: body.to_string(),
164 },
165 _ => Self::Api {
166 status,
167 message: body.to_string(),
168 },
169 }
170 }
171
172 pub fn is_retryable(&self) -> bool {
179 matches!(
180 self,
181 Self::RateLimited { .. } | Self::ServerError { .. } | Self::Http(_)
182 )
183 }
184
185 pub fn retry_after(&self) -> Option<Duration> {
187 if let Self::RateLimited {
188 retry_after: Some(secs),
189 } = self
190 {
191 Some(Duration::from_secs(*secs))
192 } else {
193 None
194 }
195 }
196
197 pub fn status_code(&self) -> Option<u16> {
199 match self {
200 Self::Api { status, .. } => Some(*status),
201 Self::ServerError { status, .. } => Some(*status),
202 Self::RateLimited { .. } => Some(429),
203 _ => None,
204 }
205 }
206}
207
208impl<E: std::error::Error> RetryableError for ApiError<E> {
209 fn is_retryable(&self) -> bool {
210 ApiError::is_retryable(self)
211 }
212
213 fn retry_after(&self) -> Option<Duration> {
214 ApiError::retry_after(self)
215 }
216}
217
218pub type ApiResult<T, E = NoDomainError> = std::result::Result<T, ApiError<E>>;
220
221#[derive(Clone)]
241pub struct SecretApiKey(String);
242
243impl SecretApiKey {
244 pub fn new(key: impl Into<String>) -> Self {
246 Self(key.into())
247 }
248
249 #[must_use]
253 pub fn expose(&self) -> &str {
254 &self.0
255 }
256
257 #[must_use]
259 pub fn is_empty(&self) -> bool {
260 self.0.is_empty()
261 }
262}
263
264impl fmt::Debug for SecretApiKey {
265 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266 f.debug_tuple("SecretApiKey").field(&"[REDACTED]").finish()
267 }
268}
269
270impl From<String> for SecretApiKey {
271 fn from(s: String) -> Self {
272 Self::new(s)
273 }
274}
275
276impl From<&str> for SecretApiKey {
277 fn from(s: &str) -> Self {
278 Self::new(s)
279 }
280}
281
282#[derive(Clone)]
298pub struct ApiConfig {
299 pub base_url: String,
301 pub api_key: Option<SecretApiKey>,
303 pub http: HttpClientConfig,
305}
306
307impl fmt::Debug for ApiConfig {
308 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309 f.debug_struct("ApiConfig")
310 .field("base_url", &self.base_url)
311 .field("api_key", &self.api_key)
312 .field("http", &self.http)
313 .finish()
314 }
315}
316
317impl ApiConfig {
318 pub fn new(base_url: impl Into<String>) -> Self {
320 Self {
321 base_url: base_url.into(),
322 api_key: None,
323 http: HttpClientConfig::default(),
324 }
325 }
326
327 pub fn with_api_key(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
329 Self {
330 base_url: base_url.into(),
331 api_key: Some(SecretApiKey::new(api_key)),
332 http: HttpClientConfig::default(),
333 }
334 }
335
336 #[must_use]
338 pub fn api_key(mut self, key: impl Into<String>) -> Self {
339 self.api_key = Some(SecretApiKey::new(key));
340 self
341 }
342
343 #[must_use]
345 pub fn optional_api_key(mut self, key: Option<String>) -> Self {
346 self.api_key = key.map(SecretApiKey::new);
347 self
348 }
349
350 #[must_use]
352 pub fn timeout(mut self, timeout: Duration) -> Self {
353 self.http.timeout = timeout;
354 self
355 }
356
357 #[must_use]
359 pub fn with_timeout_secs(mut self, secs: u64) -> Self {
360 self.http.timeout = Duration::from_secs(secs);
361 self
362 }
363
364 #[must_use]
366 pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
367 self.http.proxy = Some(proxy.into());
368 self
369 }
370
371 #[must_use]
373 pub fn optional_proxy(mut self, proxy: Option<String>) -> Self {
374 self.http.proxy = proxy;
375 self
376 }
377
378 pub fn build_client(&self) -> Result<Client, HttpError> {
380 crate::http::build_client(&self.http)
381 }
382
383 pub fn validate(&self) -> Result<(), ConfigValidationError> {
403 let url = url::Url::parse(&self.base_url)
405 .map_err(|e| ConfigValidationError::InvalidUrl(e.to_string()))?;
406
407 match url.scheme() {
409 "https" => Ok(()),
410 "http" => {
411 if let Some(host) = url.host_str() {
413 if host == "localhost" || host == "127.0.0.1" || host == "::1" {
414 return Ok(());
415 }
416 }
417 Err(ConfigValidationError::InsecureScheme)
418 }
419 scheme => Err(ConfigValidationError::InvalidUrl(format!(
420 "Unsupported URL scheme: {}",
421 scheme
422 ))),
423 }
424 }
425
426 #[must_use]
428 pub fn is_https(&self) -> bool {
429 self.base_url.starts_with("https://")
430 }
431
432 #[must_use]
434 pub fn get_api_key(&self) -> Option<&str> {
435 self.api_key.as_ref().map(|k| k.expose())
436 }
437}
438
439#[derive(Debug, Clone, PartialEq, Eq)]
441pub enum ConfigValidationError {
442 InsecureScheme,
444 InvalidUrl(String),
446}
447
448impl fmt::Display for ConfigValidationError {
449 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
450 match self {
451 Self::InsecureScheme => write!(
452 f,
453 "Insecure URL scheme: use HTTPS instead of HTTP to protect API keys"
454 ),
455 Self::InvalidUrl(msg) => write!(f, "Invalid URL: {}", msg),
456 }
457 }
458}
459
460impl std::error::Error for ConfigValidationError {}
461
462#[derive(Debug, Clone)]
488pub struct BaseClient {
489 http: Client,
490 config: ApiConfig,
491}
492
493impl BaseClient {
494 pub fn new(config: ApiConfig) -> Result<Self, HttpError> {
500 let http = config.build_client()?;
501 Ok(Self { http, config })
502 }
503
504 #[must_use]
506 pub fn http(&self) -> &Client {
507 &self.http
508 }
509
510 #[must_use]
512 pub fn config(&self) -> &ApiConfig {
513 &self.config
514 }
515
516 #[must_use]
518 pub fn base_url(&self) -> &str {
519 &self.config.base_url
520 }
521
522 #[must_use]
524 pub fn url(&self, path: &str) -> String {
525 if path.starts_with('/') {
526 format!("{}{}", self.config.base_url.trim_end_matches('/'), path)
527 } else {
528 format!("{}/{}", self.config.base_url.trim_end_matches('/'), path)
529 }
530 }
531
532 pub fn default_headers(&self) -> reqwest::header::HeaderMap {
536 let mut headers = reqwest::header::HeaderMap::new();
537
538 if let Some(key) = self.config.get_api_key() {
540 if let Ok(value) = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", key)) {
541 headers.insert(reqwest::header::AUTHORIZATION, value);
542 }
543 }
544
545 headers
546 }
547
548 pub async fn get<T, E>(
560 &self,
561 path: &str,
562 params: &[(&str, impl AsRef<str>)],
563 ) -> Result<T, ApiError<E>>
564 where
565 T: serde::de::DeserializeOwned,
566 E: std::error::Error,
567 {
568 self.get_with_headers(path, params, self.default_headers())
569 .await
570 }
571
572 pub async fn get_with_headers<T, E>(
577 &self,
578 path: &str,
579 params: &[(&str, impl AsRef<str>)],
580 headers: reqwest::header::HeaderMap,
581 ) -> Result<T, ApiError<E>>
582 where
583 T: serde::de::DeserializeOwned,
584 E: std::error::Error,
585 {
586 let url = self.url(path);
587 let query: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_ref())).collect();
588
589 let response = self
590 .http
591 .get(&url)
592 .headers(headers)
593 .query(&query)
594 .send()
595 .await?;
596
597 self.handle_response(response).await
598 }
599
600 pub async fn post_json<T, B, E>(&self, path: &str, body: &B) -> Result<T, ApiError<E>>
608 where
609 T: serde::de::DeserializeOwned,
610 B: serde::Serialize,
611 E: std::error::Error,
612 {
613 self.post_json_with_headers(path, body, self.default_headers())
614 .await
615 }
616
617 pub async fn post_json_with_headers<T, B, E>(
619 &self,
620 path: &str,
621 body: &B,
622 headers: reqwest::header::HeaderMap,
623 ) -> Result<T, ApiError<E>>
624 where
625 T: serde::de::DeserializeOwned,
626 B: serde::Serialize,
627 E: std::error::Error,
628 {
629 let url = self.url(path);
630
631 let response = self
632 .http
633 .post(&url)
634 .headers(headers)
635 .json(body)
636 .send()
637 .await?;
638
639 self.handle_response(response).await
640 }
641
642 pub async fn post_form<T, E>(
644 &self,
645 path: &str,
646 form: &[(&str, impl AsRef<str>)],
647 ) -> Result<T, ApiError<E>>
648 where
649 T: serde::de::DeserializeOwned,
650 E: std::error::Error,
651 {
652 let url = self.url(path);
653 let form_data: Vec<(&str, &str)> = form.iter().map(|(k, v)| (*k, v.as_ref())).collect();
654
655 let response = self
656 .http
657 .post(&url)
658 .headers(self.default_headers())
659 .form(&form_data)
660 .send()
661 .await?;
662
663 self.handle_response(response).await
664 }
665
666 async fn handle_response<T, E>(&self, response: reqwest::Response) -> Result<T, ApiError<E>>
668 where
669 T: serde::de::DeserializeOwned,
670 E: std::error::Error,
671 {
672 if response.status().is_success() {
673 Ok(response.json().await?)
674 } else {
675 Err(handle_error_response(response).await)
676 }
677 }
678}
679
680pub fn extract_retry_after(headers: &reqwest::header::HeaderMap) -> Option<u64> {
686 headers
687 .get("retry-after")
688 .and_then(|v| v.to_str().ok())
689 .and_then(|v| v.parse().ok())
690}
691
692pub async fn handle_error_response<E: std::error::Error>(
697 response: reqwest::Response,
698) -> ApiError<E> {
699 let status = response.status().as_u16();
700 let retry_after = extract_retry_after(response.headers());
701 let body = response.text().await.unwrap_or_default();
702 ApiError::from_response(status, &body, retry_after)
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708
709 #[test]
710 fn test_api_error_display() {
711 let err: ApiError = ApiError::api(400, "Bad request");
712 assert!(err.to_string().contains("400"));
713 assert!(err.to_string().contains("Bad request"));
714 }
715
716 #[test]
717 fn test_rate_limited_display() {
718 let err: ApiError = ApiError::rate_limited(Some(60));
719 assert!(err.to_string().contains("60"));
720 }
721
722 #[test]
723 fn test_rate_limited_no_retry() {
724 let err: ApiError = ApiError::rate_limited(None);
725 assert!(err.to_string().contains("Rate limited"));
726 assert!(!err.to_string().contains("retry"));
727 }
728
729 #[test]
730 fn test_is_retryable() {
731 let err: ApiError = ApiError::rate_limited(Some(10));
732 assert!(err.is_retryable());
733 let err: ApiError = ApiError::server_error(500, "error");
734 assert!(err.is_retryable());
735 let err: ApiError = ApiError::api(400, "bad request");
736 assert!(!err.is_retryable());
737 }
738
739 #[test]
740 fn test_from_response() {
741 let err: ApiError = ApiError::from_response(429, "rate limited", Some(30));
742 assert!(matches!(
743 err,
744 ApiError::RateLimited {
745 retry_after: Some(30)
746 }
747 ));
748
749 let err: ApiError = ApiError::from_response(503, "service unavailable", None);
750 assert!(matches!(err, ApiError::ServerError { status: 503, .. }));
751
752 let err: ApiError = ApiError::from_response(400, "bad request", None);
753 assert!(matches!(err, ApiError::Api { status: 400, .. }));
754 }
755
756 #[test]
757 fn test_retry_after() {
758 let err: ApiError = ApiError::rate_limited(Some(30));
759 assert_eq!(err.retry_after(), Some(Duration::from_secs(30)));
760
761 let err: ApiError = ApiError::api(400, "bad");
762 assert_eq!(err.retry_after(), None);
763 }
764
765 #[test]
766 fn test_status_code() {
767 let err: ApiError = ApiError::api(400, "bad");
768 assert_eq!(err.status_code(), Some(400));
769 let err: ApiError = ApiError::server_error(503, "down");
770 assert_eq!(err.status_code(), Some(503));
771 let err: ApiError = ApiError::rate_limited(None);
772 assert_eq!(err.status_code(), Some(429));
773 let err: ApiError = ApiError::Json(serde_json::from_str::<()>("invalid").unwrap_err());
774 assert_eq!(err.status_code(), None);
775 }
776
777 #[test]
778 fn test_api_config() {
779 let config = ApiConfig::new("https://api.example.com")
780 .api_key("test-key")
781 .with_timeout_secs(60)
782 .proxy("http://proxy:8080");
783
784 assert_eq!(config.base_url, "https://api.example.com");
785 assert_eq!(config.get_api_key(), Some("test-key"));
786 assert_eq!(config.http.timeout, Duration::from_secs(60));
787 assert_eq!(config.http.proxy, Some("http://proxy:8080".to_string()));
788 }
789
790 #[test]
791 fn test_api_config_build_client() {
792 let config = ApiConfig::new("https://api.example.com");
793 let client = config.build_client();
794 assert!(client.is_ok());
795 }
796
797 #[test]
798 fn test_secret_api_key_redacted() {
799 let key = SecretApiKey::new("sk-secret-key-12345");
800 let debug_output = format!("{:?}", key);
801 assert!(debug_output.contains("REDACTED"));
802 assert!(!debug_output.contains("sk-secret"));
803 assert_eq!(key.expose(), "sk-secret-key-12345");
804 }
805
806 #[test]
807 fn test_api_config_debug_redacts_key() {
808 let config = ApiConfig::with_api_key("https://api.example.com", "super-secret-key");
809 let debug_output = format!("{:?}", config);
810 assert!(debug_output.contains("REDACTED"));
811 assert!(!debug_output.contains("super-secret-key"));
812 }
813
814 #[test]
815 fn test_config_validation_https() {
816 let config = ApiConfig::new("https://api.example.com");
818 assert!(config.validate().is_ok());
819 assert!(config.is_https());
820
821 let config = ApiConfig::new("http://api.example.com");
823 assert!(config.validate().is_err());
824 assert!(!config.is_https());
825 assert_eq!(
826 config.validate().unwrap_err(),
827 ConfigValidationError::InsecureScheme
828 );
829 }
830
831 #[test]
832 fn test_config_validation_localhost() {
833 let config = ApiConfig::new("http://localhost:8080");
835 assert!(config.validate().is_ok());
836
837 let config = ApiConfig::new("http://127.0.0.1:8080");
838 assert!(config.validate().is_ok());
839 }
840
841 #[test]
842 fn test_config_validation_invalid_url() {
843 let config = ApiConfig::new("not a url");
844 let result = config.validate();
845 assert!(matches!(result, Err(ConfigValidationError::InvalidUrl(_))));
846 }
847
848 #[derive(Debug, thiserror::Error)]
850 enum TestDomainError {
851 #[error("No route found")]
852 NoRouteFound,
853 #[error("Insufficient liquidity")]
854 InsufficientLiquidity,
855 }
856
857 #[test]
858 fn test_domain_error() {
859 let err: ApiError<TestDomainError> = ApiError::domain(TestDomainError::NoRouteFound);
860 assert!(err.to_string().contains("No route found"));
861 assert!(!err.is_retryable());
862 }
863
864 #[test]
866 fn test_base_client_creation() {
867 let config = ApiConfig::new("https://api.example.com");
868 let client = BaseClient::new(config);
869 assert!(client.is_ok());
870 }
871
872 #[test]
873 fn test_base_client_url_building() {
874 let config = ApiConfig::new("https://api.example.com");
875 let client = BaseClient::new(config).unwrap();
876
877 assert_eq!(client.url("/quote"), "https://api.example.com/quote");
879
880 assert_eq!(client.url("quote"), "https://api.example.com/quote");
882
883 assert_eq!(
885 client.url("/v1/swap/quote"),
886 "https://api.example.com/v1/swap/quote"
887 );
888 }
889
890 #[test]
891 fn test_base_client_url_building_trailing_slash() {
892 let config = ApiConfig::new("https://api.example.com/");
894 let client = BaseClient::new(config).unwrap();
895
896 assert_eq!(client.url("/quote"), "https://api.example.com/quote");
897 assert_eq!(client.url("quote"), "https://api.example.com/quote");
898 }
899
900 #[test]
901 fn test_base_client_default_headers_no_key() {
902 let config = ApiConfig::new("https://api.example.com");
903 let client = BaseClient::new(config).unwrap();
904 let headers = client.default_headers();
905
906 assert!(!headers.contains_key(reqwest::header::AUTHORIZATION));
908 }
909
910 #[test]
911 fn test_base_client_default_headers_with_key() {
912 let config = ApiConfig::new("https://api.example.com").api_key("test-key");
913 let client = BaseClient::new(config).unwrap();
914 let headers = client.default_headers();
915
916 assert!(headers.contains_key(reqwest::header::AUTHORIZATION));
918 assert_eq!(
919 headers.get(reqwest::header::AUTHORIZATION).unwrap(),
920 "Bearer test-key"
921 );
922 }
923
924 #[test]
925 fn test_base_client_accessors() {
926 let config = ApiConfig::new("https://api.example.com").api_key("my-key");
927 let client = BaseClient::new(config).unwrap();
928
929 assert_eq!(client.base_url(), "https://api.example.com");
930 assert_eq!(client.config().get_api_key(), Some("my-key"));
931 }
932}