1use crate::{Error, Response, error::Result};
7use ngdp_bpsv::BpsvDocument;
8
9pub trait TypedResponse: Sized {
11 fn from_response(response: &Response) -> Result<Self>;
16}
17
18pub trait TypedBpsvResponse: Sized {
20 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 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#[derive(Debug, Clone, PartialEq)]
43pub struct ProductVersionsResponse {
44 pub sequence_number: Option<u32>,
46 pub entries: Vec<VersionEntry>,
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub struct VersionEntry {
53 pub region: String,
55 pub build_config: String,
57 pub cdn_config: String,
59 pub key_ring: Option<String>,
61 pub build_id: u32,
63 pub versions_name: String,
65 pub product_config: String,
67}
68
69#[derive(Debug, Clone, PartialEq)]
71pub struct ProductCdnsResponse {
72 pub sequence_number: Option<u32>,
74 pub entries: Vec<CdnEntry>,
76}
77
78#[derive(Debug, Clone, PartialEq)]
80pub struct CdnEntry {
81 pub name: String,
83 pub path: String,
85 pub hosts: Vec<String>,
87 pub servers: Vec<String>,
89 pub config_path: String,
91}
92
93#[derive(Debug, Clone, PartialEq)]
95pub struct ProductBgdlResponse {
96 pub sequence_number: Option<u32>,
98 pub entries: Vec<BgdlEntry>,
100}
101
102#[derive(Debug, Clone, PartialEq)]
104pub struct BgdlEntry {
105 pub region: String,
107 pub build_config: String,
109 pub cdn_config: String,
111 pub install_bgdl_config: Option<String>,
113 pub game_bgdl_config: Option<String>,
115}
116
117#[derive(Debug, Clone, PartialEq)]
119pub struct SummaryResponse {
120 pub sequence_number: Option<u32>,
122 pub products: Vec<ProductSummary>,
124}
125
126#[derive(Debug, Clone, PartialEq)]
128pub struct ProductSummary {
129 pub product: String,
131 pub seqn: u32,
133 pub flags: Option<String>,
135}
136
137struct 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
185impl 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
290impl ProductVersionsResponse {
292 #[must_use]
294 pub fn get_region(&self, region: &str) -> Option<&VersionEntry> {
295 self.entries.iter().find(|e| e.region == region)
296 }
297
298 #[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 #[must_use]
311 pub fn get_cdn(&self, name: &str) -> Option<&CdnEntry> {
312 self.entries.iter().find(|e| e.name == name)
313 }
314
315 #[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 #[must_use]
331 pub fn get_product(&self, product: &str) -> Option<&ProductSummary> {
332 self.products.iter().find(|p| p.product == product)
333 }
334
335 #[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 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 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 let result = ProductVersionsResponse::from_response(&response);
424
425 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}