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.get_raw(region_idx).unwrap().to_string(),
110            build_config: row.get_raw(build_config_idx).unwrap().to_string(),
111            cdn_config: row.get_raw(cdn_config_idx).unwrap().to_string(),
112            key_ring: key_ring_idx.and_then(|idx| {
113                row.get_raw(idx).and_then(|s| {
114                    if s.is_empty() {
115                        None
116                    } else {
117                        Some(s.to_string())
118                    }
119                })
120            }),
121            build_id: row.get_raw(build_id_idx).unwrap().parse().map_err(|_| {
122                Error::InvalidManifest {
123                    line: 0,
124                    reason: format!("Invalid BuildId: {}", row.get_raw(build_id_idx).unwrap()),
125                }
126            })?,
127            versions_name: row.get_raw(versions_name_idx).unwrap().to_string(),
128            product_config: row.get_raw(product_config_idx).unwrap().to_string(),
129        });
130    }
131
132    Ok(entries)
133}
134
135/// Parse CDN manifest into typed entries
136pub fn parse_cdns(content: &str) -> Result<Vec<CdnEntry>> {
137    let doc = BpsvDocument::parse(content)?;
138    let schema = doc.schema();
139
140    // Pre-compute field indices for direct access
141    let name_idx = schema
142        .get_field("Name")
143        .ok_or_else(|| Error::InvalidManifest {
144            line: 0,
145            reason: "Missing Name field".to_string(),
146        })?
147        .index;
148    let path_idx = schema
149        .get_field("Path")
150        .ok_or_else(|| Error::InvalidManifest {
151            line: 0,
152            reason: "Missing Path field".to_string(),
153        })?
154        .index;
155    let hosts_idx = schema
156        .get_field("Hosts")
157        .ok_or_else(|| Error::InvalidManifest {
158            line: 0,
159            reason: "Missing Hosts field".to_string(),
160        })?
161        .index;
162    let servers_idx = schema.get_field("Servers").map(|f| f.index);
163    let config_path_idx = schema
164        .get_field("ConfigPath")
165        .ok_or_else(|| Error::InvalidManifest {
166            line: 0,
167            reason: "Missing ConfigPath field".to_string(),
168        })?
169        .index;
170
171    let mut entries = Vec::with_capacity(doc.rows().len());
172
173    for row in doc.rows() {
174        // Parse hosts as space-separated list
175        let hosts_str = row.get_raw(hosts_idx).unwrap();
176        let hosts = if hosts_str.is_empty() {
177            Vec::new()
178        } else {
179            hosts_str
180                .split_whitespace()
181                .map(|s| s.to_string())
182                .collect()
183        };
184
185        // Parse servers as optional space-separated list
186        let servers = servers_idx
187            .and_then(|idx| row.get_raw(idx))
188            .filter(|s| !s.is_empty())
189            .map(|s| {
190                s.split_whitespace()
191                    .map(|s| s.to_string())
192                    .collect::<Vec<_>>()
193            })
194            .unwrap_or_default();
195
196        entries.push(CdnEntry {
197            name: row.get_raw(name_idx).unwrap().to_string(),
198            path: row.get_raw(path_idx).unwrap().to_string(),
199            hosts,
200            servers,
201            config_path: row.get_raw(config_path_idx).unwrap().to_string(),
202        });
203    }
204
205    Ok(entries)
206}
207
208/// Parse BGDL manifest into typed entries
209pub fn parse_bgdl(content: &str) -> Result<Vec<BgdlEntry>> {
210    let doc = BpsvDocument::parse(content)?;
211    let schema = doc.schema();
212
213    // Pre-compute field indices for direct access
214    let region_idx = schema
215        .get_field("Region")
216        .ok_or_else(|| Error::InvalidManifest {
217            line: 0,
218            reason: "Missing Region field".to_string(),
219        })?
220        .index;
221    let build_config_idx = schema
222        .get_field("BuildConfig")
223        .ok_or_else(|| Error::InvalidManifest {
224            line: 0,
225            reason: "Missing BuildConfig field".to_string(),
226        })?
227        .index;
228    let cdn_config_idx = schema
229        .get_field("CDNConfig")
230        .ok_or_else(|| Error::InvalidManifest {
231            line: 0,
232            reason: "Missing CDNConfig field".to_string(),
233        })?
234        .index;
235    let install_bgdl_idx = schema.get_field("InstallBGDLConfig").map(|f| f.index);
236    let game_bgdl_idx = schema.get_field("GameBGDLConfig").map(|f| f.index);
237
238    let mut entries = Vec::with_capacity(doc.rows().len());
239
240    for row in doc.rows() {
241        entries.push(BgdlEntry {
242            region: row.get_raw(region_idx).unwrap().to_string(),
243            build_config: row.get_raw(build_config_idx).unwrap().to_string(),
244            cdn_config: row.get_raw(cdn_config_idx).unwrap().to_string(),
245            install_bgdl_config: install_bgdl_idx.and_then(|idx| {
246                row.get_raw(idx).and_then(|s| {
247                    if s.is_empty() {
248                        None
249                    } else {
250                        Some(s.to_string())
251                    }
252                })
253            }),
254            game_bgdl_config: game_bgdl_idx.and_then(|idx| {
255                row.get_raw(idx).and_then(|s| {
256                    if s.is_empty() {
257                        None
258                    } else {
259                        Some(s.to_string())
260                    }
261                })
262            }),
263        });
264    }
265
266    Ok(entries)
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn test_parse_cdns_with_servers() {
275        let content = r#"Name!STRING:0|Path!STRING:0|Hosts!STRING:0|Servers!STRING:0|ConfigPath!STRING:0
276us|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
277eu|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"#;
278
279        let entries = parse_cdns(content).unwrap();
280        assert_eq!(entries.len(), 2);
281
282        let us_cdn = &entries[0];
283        assert_eq!(us_cdn.name, "us");
284        assert_eq!(us_cdn.path, "tpr/wow");
285        assert_eq!(us_cdn.hosts.len(), 2);
286        assert_eq!(us_cdn.hosts[0], "blzddist1-a.akamaihd.net");
287        assert_eq!(us_cdn.hosts[1], "level3.blizzard.com");
288        assert_eq!(us_cdn.servers.len(), 2);
289        assert_eq!(
290            us_cdn.servers[0],
291            "http://blzddist1-a.akamaihd.net/?maxhosts=4"
292        );
293        assert_eq!(us_cdn.servers[1], "http://level3.blizzard.com/?maxhosts=4");
294    }
295
296    #[test]
297    fn test_parse_cdns_without_servers() {
298        let content = r#"Name!STRING:0|Path!STRING:0|Hosts!STRING:0|Servers!STRING:0|ConfigPath!STRING:0
299us|tpr/wow|host1.com host2.com||tpr/configs/data"#;
300
301        let entries = parse_cdns(content).unwrap();
302        assert_eq!(entries.len(), 1);
303
304        let entry = &entries[0];
305        assert_eq!(entry.hosts, vec!["host1.com", "host2.com"]);
306        assert_eq!(entry.servers, Vec::<String>::new());
307    }
308
309    #[test]
310    fn test_parse_versions() {
311        let content = r#"Region!STRING:0|BuildConfig!STRING:0|CDNConfig!STRING:0|KeyRing!STRING:0|BuildId!DEC:4|VersionsName!STRING:0|ProductConfig!STRING:0
312us|abcd1234|efgh5678||12345|1.0.0.12345|ijkl9012
313eu|abcd1234|efgh5678|mnop3456|12345|1.0.0.12345|ijkl9012"#;
314
315        let entries = parse_versions(content).unwrap();
316        assert_eq!(entries.len(), 2);
317
318        let us_version = &entries[0];
319        assert_eq!(us_version.region, "us");
320        assert_eq!(us_version.build_id, 12345);
321        assert!(us_version.key_ring.is_none());
322
323        let eu_version = &entries[1];
324        assert_eq!(eu_version.key_ring, Some("mnop3456".to_string()));
325    }
326
327    #[test]
328    fn test_parse_bgdl() {
329        let content = r#"Region!STRING:0|BuildConfig!STRING:0|CDNConfig!STRING:0|InstallBGDLConfig!STRING:0|GameBGDLConfig!STRING:0
330us|abcd1234|efgh5678|install123|game456
331eu|abcd1234|efgh5678||game789"#;
332
333        let entries = parse_bgdl(content).unwrap();
334        assert_eq!(entries.len(), 2);
335
336        let us_bgdl = &entries[0];
337        assert_eq!(us_bgdl.install_bgdl_config, Some("install123".to_string()));
338        assert_eq!(us_bgdl.game_bgdl_config, Some("game456".to_string()));
339
340        let eu_bgdl = &entries[1];
341        assert!(eu_bgdl.install_bgdl_config.is_none());
342        assert_eq!(eu_bgdl.game_bgdl_config, Some("game789".to_string()));
343    }
344
345    #[test]
346    fn test_ribbit_vs_http_compatibility() {
347        // This test verifies that both Ribbit and HTTP endpoints return
348        // CDN data in the same format with hosts and servers fields
349        let ribbit_format = r#"Name!STRING:0|Path!STRING:0|Hosts!STRING:0|Servers!STRING:0|ConfigPath!STRING:0
350us|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"#;
351
352        let entries = parse_cdns(ribbit_format).unwrap();
353        let entry = &entries[0];
354
355        // Verify that both hosts and servers are parsed as lists
356        assert_eq!(entry.hosts.len(), 2);
357        assert_eq!(entry.servers.len(), 2);
358
359        // Verify the data matches what we see in reference implementations
360        assert_eq!(entry.hosts[0], "level3.blizzard.com");
361        assert_eq!(entry.hosts[1], "us.cdn.blizzard.com");
362        assert_eq!(entry.servers[0], "http://level3.blizzard.com/?maxhosts=4");
363        assert_eq!(entry.servers[1], "http://us.cdn.blizzard.com/?maxhosts=4");
364    }
365}