ngdp_cache/
hybrid_version_client.rs

1//! Hybrid version discovery client that prioritizes HTTP over legacy Ribbit
2//!
3//! This module provides a client that follows modern NGDP best practices:
4//! 1. Primary: HTTPS endpoints (<https://us.version.battle.net/wow/versions>)
5//! 2. Fallback: Legacy Ribbit TCP protocol (:1119) for backward compatibility
6//!
7//! # Example
8//!
9//! ```no_run
10//! use ngdp_cache::hybrid_version_client::HybridVersionClient;
11//! use ribbit_client::Region;
12//!
13//! # #[tokio::main]
14//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
15//! // Create a hybrid client that prioritizes HTTP
16//! let client = HybridVersionClient::new(Region::US).await?;
17//!
18//! // This will try HTTPS first, fallback to Ribbit if needed
19//! let versions = client.get_product_versions("wow").await?;
20//! println!("Found {} versions", versions.entries.len());
21//! # Ok(())
22//! # }
23//! ```
24
25use std::path::PathBuf;
26use std::time::Duration;
27use tracing::{debug, info, warn};
28
29use ribbit_client::{ProductCdnsResponse, ProductVersionsResponse, Region as RibbitRegion};
30use tact_client::Region as TactRegion;
31use tact_client::http::{HttpClient, ProtocolVersion};
32
33use crate::{Result, ensure_dir, get_cache_dir};
34
35/// Default TTL for HTTP responses (5 minutes)
36#[allow(dead_code)]
37const DEFAULT_HTTP_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
38
39/// Default TTL for Ribbit fallback responses (2 minutes - shorter due to being fallback)
40#[allow(dead_code)]
41const DEFAULT_RIBBIT_CACHE_TTL: Duration = Duration::from_secs(2 * 60);
42
43/// Hybrid version discovery client that prioritizes modern HTTP endpoints
44pub struct HybridVersionClient {
45    /// Primary HTTP client (V2 protocol)
46    http_client: HttpClient,
47    /// Fallback Ribbit client (legacy)
48    ribbit_client: Option<crate::cached_ribbit_client::CachedRibbitClient>,
49    /// Base directory for cache
50    #[allow(dead_code)]
51    cache_dir: PathBuf,
52    /// Region for this client
53    region: RibbitRegion,
54    /// Whether to use Ribbit fallback
55    enable_ribbit_fallback: bool,
56}
57
58impl HybridVersionClient {
59    /// Create a new hybrid version discovery client
60    pub async fn new(region: RibbitRegion) -> Result<Self> {
61        // Convert ribbit region to tact region
62        let tact_region = convert_ribbit_to_tact_region(region)?;
63        // Primary HTTP client using modern HTTPS endpoints
64        let http_client = HttpClient::new(tact_region, ProtocolVersion::V2)
65            .map_err(crate::Error::TactClient)?
66            .with_max_retries(2)
67            .with_user_agent("cascette-rs/0.3.1");
68
69        // Fallback Ribbit client
70        let ribbit_client = match crate::cached_ribbit_client::CachedRibbitClient::new(region).await
71        {
72            Ok(client) => Some(client),
73            Err(e) => {
74                warn!("Failed to create Ribbit fallback client: {}", e);
75                None
76            }
77        };
78
79        let cache_dir = get_cache_dir()?.join("hybrid");
80        ensure_dir(&cache_dir).await?;
81
82        let has_ribbit_fallback = ribbit_client.is_some();
83        info!(
84            "Initialized hybrid version client for region {:?} (HTTP primary, Ribbit fallback: {})",
85            region, has_ribbit_fallback
86        );
87
88        Ok(Self {
89            http_client,
90            ribbit_client,
91            cache_dir,
92            region,
93            enable_ribbit_fallback: has_ribbit_fallback,
94        })
95    }
96
97    /// Create a new hybrid client with HTTP-only (no Ribbit fallback)
98    pub async fn http_only(region: RibbitRegion) -> Result<Self> {
99        let tact_region = convert_ribbit_to_tact_region(region)?;
100        let http_client = HttpClient::new(tact_region, ProtocolVersion::V2)
101            .map_err(crate::Error::TactClient)?
102            .with_max_retries(3)
103            .with_user_agent("cascette-rs/0.3.1");
104
105        let cache_dir = get_cache_dir()?.join("hybrid");
106        ensure_dir(&cache_dir).await?;
107
108        info!(
109            "Initialized HTTP-only version client for region {:?}",
110            region
111        );
112
113        Ok(Self {
114            http_client,
115            ribbit_client: None,
116            cache_dir,
117            region,
118            enable_ribbit_fallback: false,
119        })
120    }
121
122    /// Enable or disable Ribbit fallback
123    pub fn set_ribbit_fallback(&mut self, enabled: bool) {
124        self.enable_ribbit_fallback = enabled && self.ribbit_client.is_some();
125    }
126
127    /// Get product versions with HTTP-first, Ribbit-fallback strategy
128    pub async fn get_product_versions(&self, product: &str) -> Result<ProductVersionsResponse> {
129        debug!(
130            "Getting product versions for '{}' using hybrid approach",
131            product
132        );
133
134        // Try HTTP first (primary method)
135        match self.try_http_versions(product).await {
136            Ok(response) => {
137                info!("✓ Product versions retrieved via HTTPS for '{}'", product);
138                return Ok(response);
139            }
140            Err(e) => {
141                warn!("✗ HTTP version discovery failed for '{}': {}", product, e);
142                debug!("HTTP error details: {:?}", e);
143            }
144        }
145
146        // Fallback to Ribbit if enabled
147        if self.enable_ribbit_fallback {
148            if let Some(ref ribbit_client) = self.ribbit_client {
149                debug!("Falling back to Ribbit for product versions: '{}'", product);
150                match ribbit_client.get_product_versions(product).await {
151                    Ok(response) => {
152                        info!(
153                            "✓ Product versions retrieved via Ribbit fallback for '{}'",
154                            product
155                        );
156                        return Ok(response);
157                    }
158                    Err(e) => {
159                        warn!("✗ Ribbit fallback also failed for '{}': {}", product, e);
160                    }
161                }
162            }
163        }
164
165        Err(crate::Error::Network(format!(
166            "Both HTTP and Ribbit failed for product versions: {product}"
167        )))
168    }
169
170    /// Get CDN configuration with HTTP-first, Ribbit-fallback strategy
171    pub async fn get_product_cdns(&self, product: &str) -> Result<ProductCdnsResponse> {
172        debug!(
173            "Getting CDN configuration for '{}' using hybrid approach",
174            product
175        );
176
177        // Try HTTP first (primary method)
178        match self.try_http_cdns(product).await {
179            Ok(response) => {
180                info!("✓ CDN configuration retrieved via HTTPS for '{}'", product);
181                return Ok(response);
182            }
183            Err(e) => {
184                warn!("✗ HTTP CDN discovery failed for '{}': {}", product, e);
185                debug!("HTTP error details: {:?}", e);
186            }
187        }
188
189        // Fallback to Ribbit if enabled
190        if self.enable_ribbit_fallback {
191            if let Some(ref ribbit_client) = self.ribbit_client {
192                debug!(
193                    "Falling back to Ribbit for CDN configuration: '{}'",
194                    product
195                );
196                match ribbit_client.get_product_cdns(product).await {
197                    Ok(response) => {
198                        info!(
199                            "✓ CDN configuration retrieved via Ribbit fallback for '{}'",
200                            product
201                        );
202                        return Ok(response);
203                    }
204                    Err(e) => {
205                        warn!("✗ Ribbit fallback also failed for '{}': {}", product, e);
206                    }
207                }
208            }
209        }
210
211        Err(crate::Error::Network(format!(
212            "Both HTTP and Ribbit failed for CDN configuration: {product}"
213        )))
214    }
215
216    /// Try to get versions via HTTP
217    async fn try_http_versions(&self, product: &str) -> Result<ProductVersionsResponse> {
218        let versions = self
219            .http_client
220            .get_product_versions_http_parsed(product)
221            .await
222            .map_err(crate::Error::TactClient)?;
223
224        // Convert to ribbit_client format for compatibility
225        Ok(ProductVersionsResponse {
226            sequence_number: None, // HTTP responses don't have sequence numbers
227            entries: versions
228                .into_iter()
229                .map(|v| ribbit_client::VersionEntry {
230                    region: v.region,
231                    build_config: v.build_config,
232                    cdn_config: v.cdn_config,
233                    key_ring: v.key_ring,
234                    build_id: v.build_id,
235                    versions_name: v.versions_name,
236                    product_config: v.product_config,
237                })
238                .collect(),
239        })
240    }
241
242    /// Try to get CDNs via HTTP
243    async fn try_http_cdns(&self, product: &str) -> Result<ProductCdnsResponse> {
244        let cdns = self
245            .http_client
246            .get_product_cdns_http_parsed(product)
247            .await
248            .map_err(crate::Error::TactClient)?;
249
250        // Convert to ribbit_client format for compatibility
251        Ok(ProductCdnsResponse {
252            sequence_number: None, // HTTP responses don't have sequence numbers
253            entries: cdns
254                .into_iter()
255                .map(|c| ribbit_client::CdnEntry {
256                    name: c.name,
257                    path: c.path,
258                    hosts: c.hosts,
259                    servers: Vec::new(), // HTTP CDN entries don't have separate servers field
260                    config_path: c.config_path,
261                })
262                .collect(),
263        })
264    }
265
266    /// Get the current region
267    pub fn region(&self) -> RibbitRegion {
268        self.region
269    }
270
271    /// Check if Ribbit fallback is available and enabled
272    pub fn has_ribbit_fallback(&self) -> bool {
273        self.enable_ribbit_fallback && self.ribbit_client.is_some()
274    }
275}
276
277/// Convert ribbit region to tact region
278fn convert_ribbit_to_tact_region(region: RibbitRegion) -> Result<TactRegion> {
279    match region {
280        RibbitRegion::US => Ok(TactRegion::US),
281        RibbitRegion::EU => Ok(TactRegion::EU),
282        RibbitRegion::CN => Ok(TactRegion::CN),
283        RibbitRegion::KR => Ok(TactRegion::KR),
284        RibbitRegion::TW => Ok(TactRegion::TW),
285        RibbitRegion::SG => Err(crate::Error::Network(
286            "Singapore region not supported by TACT client".to_string(),
287        )),
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[tokio::test]
296    async fn test_hybrid_client_creation() {
297        let client = HybridVersionClient::new(RibbitRegion::US).await;
298        assert!(client.is_ok(), "Should create hybrid client successfully");
299
300        let client = client.unwrap();
301        assert_eq!(client.region(), RibbitRegion::US);
302    }
303
304    #[tokio::test]
305    async fn test_http_only_client() {
306        let client = HybridVersionClient::http_only(RibbitRegion::EU).await;
307        assert!(
308            client.is_ok(),
309            "Should create HTTP-only client successfully"
310        );
311
312        let client = client.unwrap();
313        assert_eq!(client.region(), RibbitRegion::EU);
314        assert!(
315            !client.has_ribbit_fallback(),
316            "HTTP-only client should not have Ribbit fallback"
317        );
318    }
319}