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