use crate::{Error, Response, error::Result};
use ngdp_bpsv::BpsvDocument;
pub trait TypedResponse: Sized {
fn from_response(response: &Response) -> Result<Self>;
}
pub trait TypedBpsvResponse: Sized {
fn from_bpsv(doc: &BpsvDocument) -> Result<Self>;
}
impl<T: TypedBpsvResponse> TypedResponse for T {
fn from_response(response: &Response) -> Result<Self> {
match &response.data {
Some(data) => {
let doc = BpsvDocument::parse(data)
.map_err(|e| Error::ParseError(format!("BPSV parse error: {e}")))?;
Self::from_bpsv(&doc)
}
None => Err(Error::ParseError("No data in response".to_string())),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProductVersionsResponse {
pub sequence_number: Option<u32>,
pub entries: Vec<VersionEntry>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct VersionEntry {
pub region: String,
pub build_config: String,
pub cdn_config: String,
pub key_ring: Option<String>,
pub build_id: u32,
pub versions_name: String,
pub product_config: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProductCdnsResponse {
pub sequence_number: Option<u32>,
pub entries: Vec<CdnEntry>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CdnEntry {
pub name: String,
pub path: String,
pub hosts: Vec<String>,
pub servers: Vec<String>,
pub config_path: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProductBgdlResponse {
pub sequence_number: Option<u32>,
pub entries: Vec<BgdlEntry>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BgdlEntry {
pub region: String,
pub build_config: String,
pub cdn_config: String,
pub install_bgdl_config: Option<String>,
pub game_bgdl_config: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SummaryResponse {
pub sequence_number: Option<u32>,
pub products: Vec<ProductSummary>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProductSummary {
pub product: String,
pub seqn: u32,
pub flags: Option<String>,
}
struct FieldAccessor<'a> {
row: &'a ngdp_bpsv::document::BpsvRow<'a>,
schema: &'a ngdp_bpsv::BpsvSchema,
}
impl<'a> FieldAccessor<'a> {
fn new(row: &'a ngdp_bpsv::document::BpsvRow, schema: &'a ngdp_bpsv::BpsvSchema) -> Self {
Self { row, schema }
}
fn get_string(&self, field: &str) -> Result<String> {
self.row
.get_raw_by_name(field, self.schema)
.map(std::string::ToString::to_string)
.ok_or_else(|| Error::ParseError(format!("Missing field: {field}")))
}
fn get_string_optional(&self, field: &str) -> Option<String> {
self.row.get_raw_by_name(field, self.schema).and_then(|s| {
if s.is_empty() {
None
} else {
Some(s.to_string())
}
})
}
fn get_u32(&self, field: &str) -> Result<u32> {
let value = self.get_string(field)?;
value
.parse()
.map_err(|_| Error::ParseError(format!("Invalid u32 for {field}: {value}")))
}
fn get_string_list(&self, field: &str, separator: char) -> Result<Vec<String>> {
let value = self.get_string(field)?;
if value.is_empty() {
Ok(Vec::new())
} else {
Ok(value
.split(separator)
.map(|s| s.trim().to_string())
.collect())
}
}
}
impl TypedBpsvResponse for ProductVersionsResponse {
fn from_bpsv(doc: &BpsvDocument) -> Result<Self> {
let mut entries = Vec::new();
let schema = doc.schema();
for row in doc.rows() {
let accessor = FieldAccessor::new(row, schema);
entries.push(VersionEntry {
region: accessor.get_string("Region")?,
build_config: accessor.get_string("BuildConfig")?,
cdn_config: accessor.get_string("CDNConfig")?,
key_ring: accessor.get_string_optional("KeyRing"),
build_id: accessor.get_u32("BuildId")?,
versions_name: accessor.get_string("VersionsName")?,
product_config: accessor.get_string("ProductConfig")?,
});
}
Ok(Self {
sequence_number: doc.sequence_number(),
entries,
})
}
}
impl TypedBpsvResponse for ProductCdnsResponse {
fn from_bpsv(doc: &BpsvDocument) -> Result<Self> {
let mut entries = Vec::new();
let schema = doc.schema();
for row in doc.rows() {
let accessor = FieldAccessor::new(row, schema);
entries.push(CdnEntry {
name: accessor.get_string("Name")?,
path: accessor.get_string("Path")?,
hosts: accessor.get_string_list("Hosts", ' ')?,
servers: accessor
.get_string_optional("Servers")
.map(|s| {
s.split_whitespace()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default(),
config_path: accessor.get_string("ConfigPath")?,
});
}
Ok(Self {
sequence_number: doc.sequence_number(),
entries,
})
}
}
impl TypedBpsvResponse for ProductBgdlResponse {
fn from_bpsv(doc: &BpsvDocument) -> Result<Self> {
let mut entries = Vec::new();
let schema = doc.schema();
for row in doc.rows() {
let accessor = FieldAccessor::new(row, schema);
entries.push(BgdlEntry {
region: accessor.get_string("Region")?,
build_config: accessor.get_string("BuildConfig")?,
cdn_config: accessor.get_string("CDNConfig")?,
install_bgdl_config: accessor.get_string_optional("InstallBGDLConfig"),
game_bgdl_config: accessor.get_string_optional("GameBGDLConfig"),
});
}
Ok(Self {
sequence_number: doc.sequence_number(),
entries,
})
}
}
impl TypedBpsvResponse for SummaryResponse {
fn from_bpsv(doc: &BpsvDocument) -> Result<Self> {
let mut products = Vec::new();
let schema = doc.schema();
for row in doc.rows() {
let accessor = FieldAccessor::new(row, schema);
products.push(ProductSummary {
product: accessor.get_string("Product")?,
seqn: accessor.get_u32("Seqn")?,
flags: accessor.get_string_optional("Flags"),
});
}
Ok(Self {
sequence_number: doc.sequence_number(),
products,
})
}
}
impl ProductVersionsResponse {
#[must_use]
pub fn get_region(&self, region: &str) -> Option<&VersionEntry> {
self.entries.iter().find(|e| e.region == region)
}
#[must_use]
pub fn build_ids(&self) -> Vec<u32> {
let mut ids: Vec<_> = self.entries.iter().map(|e| e.build_id).collect();
ids.sort_unstable();
ids.dedup();
ids
}
}
impl ProductCdnsResponse {
#[must_use]
pub fn get_cdn(&self, name: &str) -> Option<&CdnEntry> {
self.entries.iter().find(|e| e.name == name)
}
#[must_use]
pub fn all_hosts(&self) -> Vec<String> {
let mut hosts = Vec::new();
for entry in &self.entries {
hosts.extend(entry.hosts.clone());
}
hosts.sort();
hosts.dedup();
hosts
}
}
impl SummaryResponse {
#[must_use]
pub fn get_product(&self, product: &str) -> Option<&ProductSummary> {
self.products.iter().find(|p| p.product == product)
}
#[must_use]
pub fn product_codes(&self) -> Vec<&str> {
self.products.iter().map(|p| p.product.as_str()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_product_versions() {
let bpsv_data = concat!(
"Region!STRING:0|BuildConfig!HEX:16|CDNConfig!HEX:16|BuildId!DEC:4|VersionsName!STRING:0|ProductConfig!HEX:16\n",
"## seqn = 12345\n",
"us|abcdef1234567890abcdef1234567890|fedcba0987654321fedcba0987654321|123456|10.2.5.53040|1234567890abcdef1234567890abcdef\n",
"eu|abcdef1234567890abcdef1234567890|fedcba0987654321fedcba0987654321|123456|10.2.5.53040|1234567890abcdef1234567890abcdef\n"
);
let doc = BpsvDocument::parse(bpsv_data).unwrap();
let response = ProductVersionsResponse::from_bpsv(&doc).unwrap();
assert_eq!(response.sequence_number, Some(12345));
assert_eq!(response.entries.len(), 2);
assert_eq!(response.entries[0].region, "us");
assert_eq!(response.entries[0].build_id, 123_456);
assert_eq!(response.entries[0].versions_name, "10.2.5.53040");
}
#[test]
fn test_parse_product_cdns() {
let bpsv_data = concat!(
"Name!STRING:0|Path!STRING:0|Hosts!STRING:0|ConfigPath!STRING:0\n",
"## seqn = 54321\n",
"us|tpr/wow|level3.blizzard.com edgecast.blizzard.com|tpr/configs/data\n",
"eu|tpr/wow|level3.blizzard.com|tpr/configs/data\n"
);
let doc = BpsvDocument::parse(bpsv_data).unwrap();
let response = ProductCdnsResponse::from_bpsv(&doc).unwrap();
assert_eq!(response.sequence_number, Some(54321));
assert_eq!(response.entries.len(), 2);
assert_eq!(response.entries[0].name, "us");
assert_eq!(response.entries[0].hosts.len(), 2);
assert_eq!(response.entries[0].hosts[0], "level3.blizzard.com");
}
#[test]
fn test_parse_summary() {
let bpsv_data = concat!(
"Product!STRING:0|Seqn!DEC:4|Flags!STRING:0\n",
"## seqn = 99999\n",
"wow|12345|installed\n",
"d3|54321|\n",
"hero|11111|beta\n"
);
let doc = BpsvDocument::parse(bpsv_data).unwrap();
let response = SummaryResponse::from_bpsv(&doc).unwrap();
assert_eq!(response.sequence_number, Some(99999));
assert_eq!(response.products.len(), 3);
assert_eq!(response.products[0].product, "wow");
assert_eq!(response.products[0].seqn, 12345);
assert_eq!(response.products[0].flags, Some("installed".to_string()));
assert_eq!(response.products[1].flags, None);
}
#[test]
fn test_from_response_with_hex_adjustment() {
let data = concat!(
"Region!STRING:0|BuildConfig!HEX:16\n",
"## seqn = 12345\n",
"us|e359107662e72559b4e1ab721b157cb0\n"
);
let response = Response {
raw: data.as_bytes().to_vec(),
data: Some(data.to_string()),
mime_parts: None,
};
let result = ProductVersionsResponse::from_response(&response);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Missing field") || err_msg.contains("Parse error"));
assert!(!err_msg.contains("Invalid value for field 'BuildConfig'"));
}
}