Skip to main content

everymap_core/client/
mod.rs

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