tact_client/
response_types.rs

1//! Response types for TACT HTTP endpoints using ngdp-bpsv
2
3use crate::{Error, Result};
4use ngdp_bpsv::BpsvDocument;
5
6/// Version configuration entry (from /versions endpoint)
7#[derive(Debug, Clone, PartialEq)]
8pub struct VersionEntry {
9    /// Region code
10    pub region: String,
11    /// Build configuration hash
12    pub build_config: String,
13    /// CDN configuration hash
14    pub cdn_config: String,
15    /// Optional key ring hash
16    pub key_ring: Option<String>,
17    /// Build ID number
18    pub build_id: u32,
19    /// Human-readable version name
20    pub versions_name: String,
21    /// Product configuration hash
22    pub product_config: String,
23}
24
25/// CDN configuration entry (from /cdns endpoint)
26#[derive(Debug, Clone, PartialEq)]
27pub struct CdnEntry {
28    /// CDN name/identifier (e.g., "us", "eu")
29    pub name: String,
30    /// CDN path prefix (e.g., "tpr/wow")
31    pub path: String,
32    /// List of CDN hostnames (space-separated in manifest)
33    pub hosts: Vec<String>,
34    /// List of CDN server URLs (space-separated in manifest)
35    pub servers: Vec<String>,
36    /// Configuration path on the CDN
37    pub config_path: String,
38}
39
40/// Background download configuration entry (from /bgdl endpoint)
41#[derive(Debug, Clone, PartialEq)]
42pub struct BgdlEntry {
43    /// Region code
44    pub region: String,
45    /// Build configuration hash
46    pub build_config: String,
47    /// CDN configuration hash
48    pub cdn_config: String,
49    /// Optional install background download config
50    pub install_bgdl_config: Option<String>,
51    /// Optional game background download config
52    pub game_bgdl_config: Option<String>,
53}
54
55/// Parse versions manifest into typed entries
56pub fn parse_versions(content: &str) -> Result<Vec<VersionEntry>> {
57    let doc = BpsvDocument::parse(content)?;
58    let schema = doc.schema();
59
60    // Pre-compute field indices for direct access
61    let region_idx = schema
62        .get_field("Region")
63        .ok_or_else(|| Error::InvalidManifest {
64            line: 0,
65            reason: "Missing Region field".to_string(),
66        })?
67        .index;
68    let build_config_idx = schema
69        .get_field("BuildConfig")
70        .ok_or_else(|| Error::InvalidManifest {
71            line: 0,
72            reason: "Missing BuildConfig field".to_string(),
73        })?
74        .index;
75    let cdn_config_idx = schema
76        .get_field("CDNConfig")
77        .ok_or_else(|| Error::InvalidManifest {
78            line: 0,
79            reason: "Missing CDNConfig field".to_string(),
80        })?
81        .index;
82    let key_ring_idx = schema.get_field("KeyRing").map(|f| f.index);
83    let build_id_idx = schema
84        .get_field("BuildId")
85        .ok_or_else(|| Error::InvalidManifest {
86            line: 0,
87            reason: "Missing BuildId field".to_string(),
88        })?
89        .index;
90    let versions_name_idx = schema
91        .get_field("VersionsName")
92        .ok_or_else(|| Error::InvalidManifest {
93            line: 0,
94            reason: "Missing VersionsName field".to_string(),
95        })?
96        .index;
97    let product_config_idx = schema
98        .get_field("ProductConfig")
99        .ok_or_else(|| Error::InvalidManifest {
100            line: 0,
101            reason: "Missing ProductConfig field".to_string(),
102        })?
103        .index;
104
105    let mut entries = Vec::with_capacity(doc.rows().len());
106
107    for row in doc.rows() {
108        entries.push(VersionEntry {
109            region: row
110                .get_raw(region_idx)
111                .ok_or_else(|| Error::missing_field("Region"))?
112                .to_string(),
113            build_config: row
114                .get_raw(build_config_idx)
115                .ok_or_else(|| Error::missing_field("BuildConfig"))?
116                .to_string(),
117            cdn_config: row
118                .get_raw(cdn_config_idx)
119                .ok_or_else(|| Error::missing_field("CDNConfig"))?
120                .to_string(),
121            key_ring: key_ring_idx.and_then(|idx| {
122                row.get_raw(idx).and_then(|s| {
123                    if s.is_empty() {
124                        None
125                    } else {
126                        Some(s.to_string())
127                    }
128                })
129            }),
130            build_id: row
131                .get_raw(build_id_idx)
132                .ok_or_else(|| Error::missing_field("BuildId"))?
133                .parse()
134                .map_err(|_| {
135                    let build_id_str = row.get_raw(build_id_idx).unwrap_or("<missing>"); // Safe because we checked above
136                    Error::InvalidManifest {
137                        line: 0,
138                        reason: format!("Invalid BuildId: {build_id_str}"),
139                    }
140                })?,
141            versions_name: row
142                .get_raw(versions_name_idx)
143                .ok_or_else(|| Error::missing_field("VersionsName"))?
144                .to_string(),
145            product_config: row
146                .get_raw(product_config_idx)
147                .ok_or_else(|| Error::missing_field("ProductConfig"))?
148                .to_string(),
149        });
150    }
151
152    Ok(entries)
153}
154
155/// Parse CDN manifest into typed entries
156pub fn parse_cdns(content: &str) -> Result<Vec<CdnEntry>> {
157    let doc = BpsvDocument::parse(content)?;
158    let schema = doc.schema();
159
160    // Pre-compute field indices for direct access
161    let name_idx = schema
162        .get_field("Name")
163        .ok_or_else(|| Error::InvalidManifest {
164            line: 0,
165            reason: "Missing Name field".to_string(),
166        })?
167        .index;
168    let path_idx = schema
169        .get_field("Path")
170        .ok_or_else(|| Error::InvalidManifest {
171            line: 0,
172            reason: "Missing Path field".to_string(),
173        })?
174        .index;
175    let hosts_idx = schema
176        .get_field("Hosts")
177        .ok_or_else(|| Error::InvalidManifest {
178            line: 0,
179            reason: "Missing Hosts field".to_string(),
180        })?
181        .index;
182    let servers_idx = schema.get_field("Servers").map(|f| f.index);
183    let config_path_idx = schema
184        .get_field("ConfigPath")
185        .ok_or_else(|| Error::InvalidManifest {
186            line: 0,
187            reason: "Missing ConfigPath field".to_string(),
188        })?
189        .index;
190
191    let mut entries = Vec::with_capacity(doc.rows().len());
192
193    for row in doc.rows() {
194        // Parse hosts as space-separated list
195        let hosts_str = row
196            .get_raw(hosts_idx)
197            .ok_or_else(|| Error::missing_field("Hosts"))?;
198        let hosts = if hosts_str.is_empty() {
199            Vec::new()
200        } else {
201            hosts_str
202                .split_whitespace()
203                .map(|s| s.to_string())
204                .collect()
205        };
206
207        // Parse servers as optional space-separated list
208        let servers = servers_idx
209            .and_then(|idx| row.get_raw(idx))
210            .filter(|s| !s.is_empty())
211            .map(|s| {
212                s.split_whitespace()
213                    .map(|s| s.to_string())
214                    .collect::<Vec<_>>()
215            })
216            .unwrap_or_default();
217
218        entries.push(CdnEntry {
219            name: row
220                .get_raw(name_idx)
221                .ok_or_else(|| Error::missing_field("Name"))?
222                .to_string(),
223            path: row
224                .get_raw(path_idx)
225                .ok_or_else(|| Error::missing_field("Path"))?
226                .to_string(),
227            hosts,
228            servers,
229            config_path: row
230                .get_raw(config_path_idx)
231                .ok_or_else(|| Error::missing_field("ConfigPath"))?
232                .to_string(),
233        });
234    }
235
236    Ok(entries)
237}
238
239/// Parse BGDL manifest into typed entries
240pub fn parse_bgdl(content: &str) -> Result<Vec<BgdlEntry>> {
241    let doc = BpsvDocument::parse(content)?;
242    let schema = doc.schema();
243
244    // Pre-compute field indices for direct access
245    let region_idx = schema
246        .get_field("Region")
247        .ok_or_else(|| Error::InvalidManifest {
248            line: 0,
249            reason: "Missing Region field".to_string(),
250        })?
251        .index;
252    let build_config_idx = schema
253        .get_field("BuildConfig")
254        .ok_or_else(|| Error::InvalidManifest {
255            line: 0,
256            reason: "Missing BuildConfig field".to_string(),
257        })?
258        .index;
259    let cdn_config_idx = schema
260        .get_field("CDNConfig")
261        .ok_or_else(|| Error::InvalidManifest {
262            line: 0,
263            reason: "Missing CDNConfig field".to_string(),
264        })?
265        .index;
266    let install_bgdl_idx = schema.get_field("InstallBGDLConfig").map(|f| f.index);
267    let game_bgdl_idx = schema.get_field("GameBGDLConfig").map(|f| f.index);
268
269    let mut entries = Vec::with_capacity(doc.rows().len());
270
271    for row in doc.rows() {
272        entries.push(BgdlEntry {
273            region: row
274                .get_raw(region_idx)
275                .ok_or_else(|| Error::missing_field("Region"))?
276                .to_string(),
277            build_config: row
278                .get_raw(build_config_idx)
279                .ok_or_else(|| Error::missing_field("BuildConfig"))?
280                .to_string(),
281            cdn_config: row
282                .get_raw(cdn_config_idx)
283                .ok_or_else(|| Error::missing_field("CDNConfig"))?
284                .to_string(),
285            install_bgdl_config: install_bgdl_idx.and_then(|idx| {
286                row.get_raw(idx).and_then(|s| {
287                    if s.is_empty() {
288                        None
289                    } else {
290                        Some(s.to_string())
291                    }
292                })
293            }),
294            game_bgdl_config: game_bgdl_idx.and_then(|idx| {
295                row.get_raw(idx).and_then(|s| {
296                    if s.is_empty() {
297                        None
298                    } else {
299                        Some(s.to_string())
300                    }
301                })
302            }),
303        });
304    }
305
306    Ok(entries)
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_parse_cdns_with_servers() {
315        let content = r#"Name!STRING:0|Path!STRING:0|Hosts!STRING:0|Servers!STRING:0|ConfigPath!STRING:0
316us|tpr/wow|blzddist1-a.akamaihd.net level3.blizzard.com|http://blzddist1-a.akamaihd.net/?maxhosts=4 http://level3.blizzard.com/?maxhosts=4|tpr/configs/data
317eu|tpr/wow|blzddist1-a.akamaihd.net level3.blizzard.com|http://eu.cdn.blizzard.com/?maxhosts=4 https://blzddist1-a.akamaihd.net/?fallback=1&maxhosts=4|tpr/configs/data"#;
318
319        let entries = parse_cdns(content).unwrap();
320        assert_eq!(entries.len(), 2);
321
322        let us_cdn = &entries[0];
323        assert_eq!(us_cdn.name, "us");
324        assert_eq!(us_cdn.path, "tpr/wow");
325        assert_eq!(us_cdn.hosts.len(), 2);
326        assert_eq!(us_cdn.hosts[0], "blzddist1-a.akamaihd.net");
327        assert_eq!(us_cdn.hosts[1], "level3.blizzard.com");
328        assert_eq!(us_cdn.servers.len(), 2);
329        assert_eq!(
330            us_cdn.servers[0],
331            "http://blzddist1-a.akamaihd.net/?maxhosts=4"
332        );
333        assert_eq!(us_cdn.servers[1], "http://level3.blizzard.com/?maxhosts=4");
334    }
335
336    #[test]
337    fn test_parse_cdns_without_servers() {
338        let content = r#"Name!STRING:0|Path!STRING:0|Hosts!STRING:0|Servers!STRING:0|ConfigPath!STRING:0
339us|tpr/wow|host1.com host2.com||tpr/configs/data"#;
340
341        let entries = parse_cdns(content).unwrap();
342        assert_eq!(entries.len(), 1);
343
344        let entry = &entries[0];
345        assert_eq!(entry.hosts, vec!["host1.com", "host2.com"]);
346        assert_eq!(entry.servers, Vec::<String>::new());
347    }
348
349    #[test]
350    fn test_parse_versions() {
351        let content = r#"Region!STRING:0|BuildConfig!STRING:0|CDNConfig!STRING:0|KeyRing!STRING:0|BuildId!DEC:4|VersionsName!STRING:0|ProductConfig!STRING:0
352us|abcd1234|efgh5678||12345|1.0.0.12345|ijkl9012
353eu|abcd1234|efgh5678|mnop3456|12345|1.0.0.12345|ijkl9012"#;
354
355        let entries = parse_versions(content).unwrap();
356        assert_eq!(entries.len(), 2);
357
358        let us_version = &entries[0];
359        assert_eq!(us_version.region, "us");
360        assert_eq!(us_version.build_id, 12345);
361        assert!(us_version.key_ring.is_none());
362
363        let eu_version = &entries[1];
364        assert_eq!(eu_version.key_ring, Some("mnop3456".to_string()));
365    }
366
367    #[test]
368    fn test_parse_bgdl() {
369        let content = r#"Region!STRING:0|BuildConfig!STRING:0|CDNConfig!STRING:0|InstallBGDLConfig!STRING:0|GameBGDLConfig!STRING:0
370us|abcd1234|efgh5678|install123|game456
371eu|abcd1234|efgh5678||game789"#;
372
373        let entries = parse_bgdl(content).unwrap();
374        assert_eq!(entries.len(), 2);
375
376        let us_bgdl = &entries[0];
377        assert_eq!(us_bgdl.install_bgdl_config, Some("install123".to_string()));
378        assert_eq!(us_bgdl.game_bgdl_config, Some("game456".to_string()));
379
380        let eu_bgdl = &entries[1];
381        assert!(eu_bgdl.install_bgdl_config.is_none());
382        assert_eq!(eu_bgdl.game_bgdl_config, Some("game789".to_string()));
383    }
384
385    #[test]
386    fn test_ribbit_vs_http_compatibility() {
387        // This test verifies that both Ribbit and HTTP endpoints return
388        // CDN data in the same format with hosts and servers fields
389        let ribbit_format = r#"Name!STRING:0|Path!STRING:0|Hosts!STRING:0|Servers!STRING:0|ConfigPath!STRING:0
390us|tpr/wow|level3.blizzard.com us.cdn.blizzard.com|http://level3.blizzard.com/?maxhosts=4 http://us.cdn.blizzard.com/?maxhosts=4|tpr/configs/data"#;
391
392        let entries = parse_cdns(ribbit_format).unwrap();
393        let entry = &entries[0];
394
395        // Verify that both hosts and servers are parsed as lists
396        assert_eq!(entry.hosts.len(), 2);
397        assert_eq!(entry.servers.len(), 2);
398
399        // Verify the data matches what we see in reference implementations
400        assert_eq!(entry.hosts[0], "level3.blizzard.com");
401        assert_eq!(entry.hosts[1], "us.cdn.blizzard.com");
402        assert_eq!(entry.servers[0], "http://level3.blizzard.com/?maxhosts=4");
403        assert_eq!(entry.servers[1], "http://us.cdn.blizzard.com/?maxhosts=4");
404    }
405}