ribbit_client/
response_types.rs

1//! Strongly-typed response definitions for all Ribbit endpoints
2//!
3//! This module provides type-safe representations of responses from various
4//! Ribbit endpoints, automatically parsing BPSV data into structured Rust types.
5
6use crate::{Error, Response, error::Result};
7use ngdp_bpsv::BpsvDocument;
8
9/// Trait for typed responses that can be parsed from HTTP responses
10pub trait TypedResponse: Sized {
11    /// Parse from a raw response
12    ///
13    /// # Errors
14    /// Returns an error if the response has no data or parsing fails.
15    fn from_response(response: &Response) -> Result<Self>;
16}
17
18/// Trait for typed responses that can be parsed from BPSV documents
19pub trait TypedBpsvResponse: Sized {
20    /// Parse the response from a BPSV document
21    ///
22    /// # Errors
23    /// Returns an error if parsing the BPSV document fails.
24    fn from_bpsv(doc: &BpsvDocument) -> Result<Self>;
25}
26
27impl<T: TypedBpsvResponse> TypedResponse for T {
28    fn from_response(response: &Response) -> Result<Self> {
29        match &response.data {
30            Some(data) => {
31                // Parse directly - BPSV parser now correctly handles HEX:N as N bytes
32                let doc = BpsvDocument::parse(data)
33                    .map_err(|e| Error::ParseError(format!("BPSV parse error: {e}")))?;
34                Self::from_bpsv(&doc)
35            }
36            None => Err(Error::ParseError("No data in response".to_string())),
37        }
38    }
39}
40
41/// Product versions response containing build information for all regions
42#[derive(Debug, Clone, PartialEq)]
43pub struct ProductVersionsResponse {
44    /// Sequence number from the BPSV document
45    pub sequence_number: Option<u32>,
46    /// Version entries for each region
47    pub entries: Vec<VersionEntry>,
48}
49
50/// Single version entry for a specific region
51#[derive(Debug, Clone, PartialEq)]
52pub struct VersionEntry {
53    /// Region code (us, eu, cn, kr, tw, sg)
54    pub region: String,
55    /// Build configuration hash (16 bytes hex)
56    pub build_config: String,
57    /// CDN configuration hash (16 bytes hex)
58    pub cdn_config: String,
59    /// Optional keyring hash (16 bytes hex)
60    pub key_ring: Option<String>,
61    /// Build ID number
62    pub build_id: u32,
63    /// Human-readable version name
64    pub versions_name: String,
65    /// Product configuration hash (16 bytes hex)
66    pub product_config: String,
67}
68
69/// CDN server information response
70#[derive(Debug, Clone, PartialEq)]
71pub struct ProductCdnsResponse {
72    /// Sequence number from the BPSV document
73    pub sequence_number: Option<u32>,
74    /// CDN entries for each region/configuration
75    pub entries: Vec<CdnEntry>,
76}
77
78/// CDN configuration entry
79#[derive(Debug, Clone, PartialEq)]
80pub struct CdnEntry {
81    /// CDN name/identifier
82    pub name: String,
83    /// CDN path prefix
84    pub path: String,
85    /// List of CDN hostnames
86    pub hosts: Vec<String>,
87    /// List of CDN server URLs
88    pub servers: Vec<String>,
89    /// Configuration path on the CDN
90    pub config_path: String,
91}
92
93/// Background download configuration response
94#[derive(Debug, Clone, PartialEq)]
95pub struct ProductBgdlResponse {
96    /// Sequence number from the BPSV document
97    pub sequence_number: Option<u32>,
98    /// Background download entries per region
99    pub entries: Vec<BgdlEntry>,
100}
101
102/// Background download configuration entry
103#[derive(Debug, Clone, PartialEq)]
104pub struct BgdlEntry {
105    /// Region code
106    pub region: String,
107    /// Build configuration hash
108    pub build_config: String,
109    /// CDN configuration hash
110    pub cdn_config: String,
111    /// Optional install background download config
112    pub install_bgdl_config: Option<String>,
113    /// Optional game background download config
114    pub game_bgdl_config: Option<String>,
115}
116
117/// Summary of all available products
118#[derive(Debug, Clone, PartialEq)]
119pub struct SummaryResponse {
120    /// Sequence number from the BPSV document
121    pub sequence_number: Option<u32>,
122    /// List of available products
123    pub products: Vec<ProductSummary>,
124}
125
126/// Summary information for a single product
127#[derive(Debug, Clone, PartialEq)]
128pub struct ProductSummary {
129    /// Product code (e.g., "wow", "d3", "hero")
130    pub product: String,
131    /// Product-specific sequence number
132    pub seqn: u32,
133    /// Optional flags
134    pub flags: Option<String>,
135}
136
137/// Helper struct for accessing BPSV row data by field name
138struct FieldAccessor<'a> {
139    row: &'a ngdp_bpsv::document::BpsvRow<'a>,
140    schema: &'a ngdp_bpsv::BpsvSchema,
141}
142
143impl<'a> FieldAccessor<'a> {
144    fn new(row: &'a ngdp_bpsv::document::BpsvRow, schema: &'a ngdp_bpsv::BpsvSchema) -> Self {
145        Self { row, schema }
146    }
147
148    fn get_string(&self, field: &str) -> Result<String> {
149        self.row
150            .get_raw_by_name(field, self.schema)
151            .map(std::string::ToString::to_string)
152            .ok_or_else(|| Error::ParseError(format!("Missing field: {field}")))
153    }
154
155    fn get_string_optional(&self, field: &str) -> Option<String> {
156        self.row.get_raw_by_name(field, self.schema).and_then(|s| {
157            if s.is_empty() {
158                None
159            } else {
160                Some(s.to_string())
161            }
162        })
163    }
164
165    fn get_u32(&self, field: &str) -> Result<u32> {
166        let value = self.get_string(field)?;
167        value
168            .parse()
169            .map_err(|_| Error::ParseError(format!("Invalid u32 for {field}: {value}")))
170    }
171
172    fn get_string_list(&self, field: &str, separator: char) -> Result<Vec<String>> {
173        let value = self.get_string(field)?;
174        if value.is_empty() {
175            Ok(Vec::new())
176        } else {
177            Ok(value
178                .split(separator)
179                .map(|s| s.trim().to_string())
180                .collect())
181        }
182    }
183}
184
185// Implementations for each response type
186
187impl TypedBpsvResponse for ProductVersionsResponse {
188    fn from_bpsv(doc: &BpsvDocument) -> Result<Self> {
189        let mut entries = Vec::new();
190        let schema = doc.schema();
191
192        for row in doc.rows() {
193            let accessor = FieldAccessor::new(row, schema);
194
195            entries.push(VersionEntry {
196                region: accessor.get_string("Region")?,
197                build_config: accessor.get_string("BuildConfig")?,
198                cdn_config: accessor.get_string("CDNConfig")?,
199                key_ring: accessor.get_string_optional("KeyRing"),
200                build_id: accessor.get_u32("BuildId")?,
201                versions_name: accessor.get_string("VersionsName")?,
202                product_config: accessor.get_string("ProductConfig")?,
203            });
204        }
205
206        Ok(Self {
207            sequence_number: doc.sequence_number(),
208            entries,
209        })
210    }
211}
212
213impl TypedBpsvResponse for ProductCdnsResponse {
214    fn from_bpsv(doc: &BpsvDocument) -> Result<Self> {
215        let mut entries = Vec::new();
216        let schema = doc.schema();
217
218        for row in doc.rows() {
219            let accessor = FieldAccessor::new(row, schema);
220
221            entries.push(CdnEntry {
222                name: accessor.get_string("Name")?,
223                path: accessor.get_string("Path")?,
224                hosts: accessor.get_string_list("Hosts", ' ')?,
225                servers: accessor
226                    .get_string_optional("Servers")
227                    .map(|s| {
228                        s.split_whitespace()
229                            .map(std::string::ToString::to_string)
230                            .collect::<Vec<_>>()
231                    })
232                    .unwrap_or_default(),
233                config_path: accessor.get_string("ConfigPath")?,
234            });
235        }
236
237        Ok(Self {
238            sequence_number: doc.sequence_number(),
239            entries,
240        })
241    }
242}
243
244impl TypedBpsvResponse for ProductBgdlResponse {
245    fn from_bpsv(doc: &BpsvDocument) -> Result<Self> {
246        let mut entries = Vec::new();
247        let schema = doc.schema();
248
249        for row in doc.rows() {
250            let accessor = FieldAccessor::new(row, schema);
251
252            entries.push(BgdlEntry {
253                region: accessor.get_string("Region")?,
254                build_config: accessor.get_string("BuildConfig")?,
255                cdn_config: accessor.get_string("CDNConfig")?,
256                install_bgdl_config: accessor.get_string_optional("InstallBGDLConfig"),
257                game_bgdl_config: accessor.get_string_optional("GameBGDLConfig"),
258            });
259        }
260
261        Ok(Self {
262            sequence_number: doc.sequence_number(),
263            entries,
264        })
265    }
266}
267
268impl TypedBpsvResponse for SummaryResponse {
269    fn from_bpsv(doc: &BpsvDocument) -> Result<Self> {
270        let mut products = Vec::new();
271        let schema = doc.schema();
272
273        for row in doc.rows() {
274            let accessor = FieldAccessor::new(row, schema);
275
276            products.push(ProductSummary {
277                product: accessor.get_string("Product")?,
278                seqn: accessor.get_u32("Seqn")?,
279                flags: accessor.get_string_optional("Flags"),
280            });
281        }
282
283        Ok(Self {
284            sequence_number: doc.sequence_number(),
285            products,
286        })
287    }
288}
289
290/// Convenience methods for response types
291impl ProductVersionsResponse {
292    /// Get version entry for a specific region
293    #[must_use]
294    pub fn get_region(&self, region: &str) -> Option<&VersionEntry> {
295        self.entries.iter().find(|e| e.region == region)
296    }
297
298    /// Get all unique build IDs
299    #[must_use]
300    pub fn build_ids(&self) -> Vec<u32> {
301        let mut ids: Vec<_> = self.entries.iter().map(|e| e.build_id).collect();
302        ids.sort_unstable();
303        ids.dedup();
304        ids
305    }
306}
307
308impl ProductCdnsResponse {
309    /// Get CDN entry by name
310    #[must_use]
311    pub fn get_cdn(&self, name: &str) -> Option<&CdnEntry> {
312        self.entries.iter().find(|e| e.name == name)
313    }
314
315    /// Get all unique CDN hosts
316    #[must_use]
317    pub fn all_hosts(&self) -> Vec<String> {
318        let mut hosts = Vec::new();
319        for entry in &self.entries {
320            hosts.extend(entry.hosts.clone());
321        }
322        hosts.sort();
323        hosts.dedup();
324        hosts
325    }
326}
327
328impl SummaryResponse {
329    /// Get summary for a specific product
330    #[must_use]
331    pub fn get_product(&self, product: &str) -> Option<&ProductSummary> {
332        self.products.iter().find(|p| p.product == product)
333    }
334
335    /// Get all product codes
336    #[must_use]
337    pub fn product_codes(&self) -> Vec<&str> {
338        self.products.iter().map(|p| p.product.as_str()).collect()
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn test_parse_product_versions() {
348        // Use HEX:16 which expects 32 hex characters (16 bytes)
349        let bpsv_data = concat!(
350            "Region!STRING:0|BuildConfig!HEX:16|CDNConfig!HEX:16|BuildId!DEC:4|VersionsName!STRING:0|ProductConfig!HEX:16\n",
351            "## seqn = 12345\n",
352            "us|abcdef1234567890abcdef1234567890|fedcba0987654321fedcba0987654321|123456|10.2.5.53040|1234567890abcdef1234567890abcdef\n",
353            "eu|abcdef1234567890abcdef1234567890|fedcba0987654321fedcba0987654321|123456|10.2.5.53040|1234567890abcdef1234567890abcdef\n"
354        );
355
356        let doc = BpsvDocument::parse(bpsv_data).unwrap();
357        let response = ProductVersionsResponse::from_bpsv(&doc).unwrap();
358
359        assert_eq!(response.sequence_number, Some(12345));
360        assert_eq!(response.entries.len(), 2);
361        assert_eq!(response.entries[0].region, "us");
362        assert_eq!(response.entries[0].build_id, 123_456);
363        assert_eq!(response.entries[0].versions_name, "10.2.5.53040");
364    }
365
366    #[test]
367    fn test_parse_product_cdns() {
368        let bpsv_data = concat!(
369            "Name!STRING:0|Path!STRING:0|Hosts!STRING:0|ConfigPath!STRING:0\n",
370            "## seqn = 54321\n",
371            "us|tpr/wow|level3.blizzard.com edgecast.blizzard.com|tpr/configs/data\n",
372            "eu|tpr/wow|level3.blizzard.com|tpr/configs/data\n"
373        );
374
375        let doc = BpsvDocument::parse(bpsv_data).unwrap();
376        let response = ProductCdnsResponse::from_bpsv(&doc).unwrap();
377
378        assert_eq!(response.sequence_number, Some(54321));
379        assert_eq!(response.entries.len(), 2);
380        assert_eq!(response.entries[0].name, "us");
381        assert_eq!(response.entries[0].hosts.len(), 2);
382        assert_eq!(response.entries[0].hosts[0], "level3.blizzard.com");
383    }
384
385    #[test]
386    fn test_parse_summary() {
387        let bpsv_data = concat!(
388            "Product!STRING:0|Seqn!DEC:4|Flags!STRING:0\n",
389            "## seqn = 99999\n",
390            "wow|12345|installed\n",
391            "d3|54321|\n",
392            "hero|11111|beta\n"
393        );
394
395        let doc = BpsvDocument::parse(bpsv_data).unwrap();
396        let response = SummaryResponse::from_bpsv(&doc).unwrap();
397
398        assert_eq!(response.sequence_number, Some(99999));
399        assert_eq!(response.products.len(), 3);
400        assert_eq!(response.products[0].product, "wow");
401        assert_eq!(response.products[0].seqn, 12345);
402        assert_eq!(response.products[0].flags, Some("installed".to_string()));
403        assert_eq!(response.products[1].flags, None);
404    }
405
406    #[test]
407    fn test_from_response_with_hex_adjustment() {
408        // Test that from_response properly adjusts HEX field lengths
409        let data = concat!(
410            "Region!STRING:0|BuildConfig!HEX:16\n",
411            "## seqn = 12345\n",
412            "us|e359107662e72559b4e1ab721b157cb0\n"
413        );
414
415        let response = Response {
416            raw: data.as_bytes().to_vec(),
417            data: Some(data.to_string()),
418            mime_parts: None,
419        };
420
421        // This would fail without HEX adjustment because the data has 32 chars but header says HEX:16
422        // With adjustment, it should work
423        let result = ProductVersionsResponse::from_response(&response);
424
425        // The test expects this to fail because ProductVersionsResponse needs more fields
426        // But it should fail with a missing field error, not a HEX validation error
427        assert!(result.is_err());
428        let err_msg = result.unwrap_err().to_string();
429        assert!(err_msg.contains("Missing field") || err_msg.contains("Parse error"));
430        assert!(!err_msg.contains("Invalid value for field 'BuildConfig'"));
431    }
432}