Skip to main content

everymap_core/client/
mod.rs

1use crate::auth::AuthProvider;
2use crate::error::{EveryMapError, EveryMapResult};
3use async_trait::async_trait;
4use std::sync::Arc;
5use std::time::Instant;
6
7/// Maximum bytes of response body to display in verbose mode.
8const VERBOSE_BODY_LIMIT: usize = 10_240;
9
10/// Maximum bytes of response body included in deserialization error messages.
11const ERROR_BODY_LIMIT: usize = 256;
12
13/// HTTP header name for rate-limit retry indication.
14const RETRY_AFTER_HEADER: &str = "retry-after";
15
16/// Error code for JSON deserialization failures.
17const DESERIALIZATION_ERROR: &str = "DESERIALIZATION_ERROR";
18
19/// Fallback status text when `canonical_reason()` returns `None`.
20const UNKNOWN_STATUS: &str = "Unknown";
21
22/// Trait for HTTP clients, allowing dependency injection and testing.
23///
24/// Custom implementations can be used for testing (mock clients),
25/// retry logic, or custom transport configuration (timeouts, proxies).
26#[async_trait]
27pub trait HttpClient: Send + Sync {
28    /// Send an HTTP request and return the response.
29    async fn send(&self, builder: reqwest::RequestBuilder) -> EveryMapResult<reqwest::Response>;
30}
31
32/// A generic provider HTTP client that consolidates the common request/response logic
33/// shared across all provider implementations (HERE, Google, TomTom, MapBox, Radar).
34///
35/// Each provider crate should use this instead of maintaining its own duplicated client.
36/// The only configuration specific to each provider is the `provider_name` (used in
37/// error messages) and the auth mechanism (injected via `AuthProvider`).
38pub struct ProviderClient {
39    http_client: reqwest::Client,
40    auth_provider: Arc<dyn AuthProvider>,
41    verbose: bool,
42    provider_name: &'static str,
43}
44
45impl ProviderClient {
46    /// Creates a new `ProviderClient` with the given authentication provider.
47    pub fn new(auth_provider: Arc<dyn AuthProvider>, provider_name: &'static str) -> Self {
48        Self {
49            http_client: reqwest::Client::new(),
50            auth_provider,
51            verbose: false,
52            provider_name,
53        }
54    }
55
56    /// Creates a `ProviderClient` with a custom `reqwest::Client` configuration.
57    pub fn with_client_builder(
58        builder: reqwest::ClientBuilder,
59        auth_provider: Arc<dyn AuthProvider>,
60        provider_name: &'static str,
61    ) -> EveryMapResult<Self> {
62        Ok(Self {
63            http_client: builder.build()?,
64            auth_provider,
65            verbose: false,
66            provider_name,
67        })
68    }
69
70    /// Enable or disable verbose output (request/response logging to stderr).
71    pub fn set_verbose(&mut self, verbose: bool) {
72        self.verbose = verbose;
73    }
74
75    /// Whether verbose mode is enabled.
76    pub fn is_verbose(&self) -> bool {
77        self.verbose
78    }
79
80    /// The provider name (e.g., "here", "google", "tomtom", "mapbox", "radar").
81    pub fn provider_name(&self) -> &'static str {
82        self.provider_name
83    }
84
85    /// Builds a request to the given full URL with the specified HTTP method.
86    pub fn build_request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder {
87        self.http_client.request(method, url)
88    }
89
90    /// Sends a request, applying authentication first.
91    ///
92    /// Returns an `EveryMapError::HttpError` for non-2xx status codes,
93    /// and `EveryMapError::RateLimited` for 429 responses.
94    pub async fn request(
95        &self,
96        builder: reqwest::RequestBuilder,
97    ) -> EveryMapResult<reqwest::Response> {
98        let start = Instant::now();
99        let builder = self.auth_provider.apply(builder).await?;
100        let response: reqwest::Response = builder.send().await?;
101        let elapsed = start.elapsed();
102        let status = response.status();
103
104        if self.verbose {
105            let url = response.url().to_string();
106            eprintln!(
107                "[VERBOSE] {} {} — {} ({:.0}ms)",
108                status.as_u16(),
109                redact_api_key(&url),
110                status.canonical_reason().unwrap_or(UNKNOWN_STATUS),
111                elapsed.as_secs_f64() * 1000.0
112            );
113        }
114
115        if status.is_success() {
116            Ok(response)
117        } else {
118            let status_code = status.as_u16();
119            let status_text = status
120                .canonical_reason()
121                .unwrap_or(UNKNOWN_STATUS)
122                .to_string();
123            let retry_after = if status_code == 429 {
124                response
125                    .headers()
126                    .get(RETRY_AFTER_HEADER)
127                    .and_then(|v| v.to_str().ok())
128                    .and_then(|v| v.parse::<u64>().ok())
129            } else {
130                None
131            };
132            let body = response.text().await.unwrap_or_default();
133
134            if self.verbose {
135                let snippet = truncate_str(&body, VERBOSE_BODY_LIMIT);
136                eprintln!("[VERBOSE] Error response body:\n{}", snippet);
137            }
138
139            if status_code == 429 {
140                Err(EveryMapError::rate_limited(self.provider_name, retry_after))
141            } else {
142                Err(EveryMapError::http_with_body(
143                    status_code,
144                    format!("HTTP error: {}", status_text),
145                    body,
146                ))
147            }
148        }
149    }
150
151    /// Sends a request and deserializes the JSON response into `T`.
152    ///
153    /// Reads the response body as text first, then deserializes with
154    /// `serde_json::from_str`. If deserialization fails, the error includes
155    /// a truncated excerpt of the raw response body for debugging.
156    pub async fn request_json<T: serde::de::DeserializeOwned>(
157        &self,
158        builder: reqwest::RequestBuilder,
159    ) -> EveryMapResult<T> {
160        let start = Instant::now();
161        let response = self.request(builder).await?;
162        let body = response.text().await.map_err(EveryMapError::ClientError)?;
163
164        if self.verbose {
165            let snippet = truncate_str(&body, VERBOSE_BODY_LIMIT);
166            eprintln!(
167                "[VERBOSE] Response body ({:.0}ms, {} bytes):\n{}",
168                start.elapsed().as_secs_f64() * 1000.0,
169                body.len(),
170                snippet
171            );
172        }
173
174        serde_json::from_str::<T>(&body).map_err(|e| {
175            let snippet = truncate_str(&body, ERROR_BODY_LIMIT);
176            EveryMapError::provider(
177                self.provider_name,
178                DESERIALIZATION_ERROR,
179                format!(
180                    "Failed to deserialize response: {}\nResponse body (first {} bytes): {}",
181                    e, ERROR_BODY_LIMIT, snippet
182                ),
183            )
184        })
185    }
186
187    /// Sends a POST request with a JSON body and deserializes the response.
188    pub async fn post_json<T: serde::de::DeserializeOwned>(
189        &self,
190        url: &str,
191        body: &serde_json::Value,
192    ) -> EveryMapResult<T> {
193        let builder = self.build_request(reqwest::Method::POST, url).json(body);
194        self.request_json(builder).await
195    }
196}
197
198/// API key query parameter names to redact from URLs in verbose output.
199const REDACTED_PARAMS: &[&str] = &["apiKey", "key", "access_token"];
200
201/// Redact API key values from a URL string for verbose output.
202/// Replaces the value of common API key query params with `***`.
203pub fn redact_api_key(url: &str) -> String {
204    let mut result = url.to_string();
205    for param in REDACTED_PARAMS {
206        let prefix = format!("{}=", param);
207        if let Some(start) = result.find(&prefix) {
208            let val_start = start + prefix.len();
209            let val_end = result[val_start..]
210                .find('&')
211                .map(|i| val_start + i)
212                .unwrap_or(result.len());
213            result.replace_range(val_start..val_end, "***");
214        }
215    }
216    result
217}
218
219/// Truncate a string to the given byte limit, appending info if truncated.
220pub fn truncate_str(s: &str, limit: usize) -> String {
221    if s.len() <= limit {
222        s.to_string()
223    } else {
224        let end = s
225            .char_indices()
226            .take_while(|(byte_index, _)| *byte_index < limit)
227            .last()
228            .map(|(byte_index, char_value)| byte_index + char_value.len_utf8())
229            .unwrap_or(limit.min(s.len()));
230        format!("{}...\n[truncated, {} bytes total]", &s[..end], s.len())
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_redact_api_key_here() {
240        let url = "https://router.hereapi.com/v8/routes?apiKey=secret123&origin=52.5,13.3";
241        assert_eq!(
242            redact_api_key(url),
243            "https://router.hereapi.com/v8/routes?apiKey=***&origin=52.5,13.3"
244        );
245    }
246
247    #[test]
248    fn test_redact_api_key_google() {
249        let url = "https://maps.googleapis.com/maps/api/geocode/json?key=secret123&address=Berlin";
250        assert_eq!(
251            redact_api_key(url),
252            "https://maps.googleapis.com/maps/api/geocode/json?key=***&address=Berlin"
253        );
254    }
255
256    #[test]
257    fn test_redact_api_key_mapbox() {
258        let url = "https://api.mapbox.com/geocode/v6/forward?q=test&access_token=pk.secret123";
259        assert_eq!(
260            redact_api_key(url),
261            "https://api.mapbox.com/geocode/v6/forward?q=test&access_token=***"
262        );
263    }
264
265    #[test]
266    fn test_redact_no_key() {
267        let url = "https://example.com/api?foo=bar";
268        assert_eq!(redact_api_key(url), url);
269    }
270
271    #[test]
272    fn test_truncate_str_short() {
273        let s = "hello";
274        assert_eq!(truncate_str(s, 100), "hello");
275    }
276
277    #[test]
278    fn test_truncate_str_long() {
279        let s = "a".repeat(20000);
280        let truncated = truncate_str(&s, 1024);
281        assert!(truncated.len() < s.len());
282        assert!(truncated.contains("[truncated"));
283    }
284
285    #[test]
286    fn test_provider_client_new() {
287        let auth: Arc<dyn AuthProvider> = Arc::new(crate::auth::ApiKeyProvider::new(
288            "test".to_string(),
289            "key".to_string(),
290        ));
291        let client = ProviderClient::new(auth, "test");
292        assert_eq!(client.provider_name(), "test");
293        assert!(!client.is_verbose());
294    }
295
296    #[test]
297    fn test_provider_client_verbose() {
298        let auth: Arc<dyn AuthProvider> = Arc::new(crate::auth::ApiKeyProvider::new(
299            "test".to_string(),
300            "key".to_string(),
301        ));
302        let mut client = ProviderClient::new(auth, "test");
303        client.set_verbose(true);
304        assert!(client.is_verbose());
305    }
306}