use serde::de::DeserializeOwned;
use serde::{Deserialize, Deserializer};
use super::Error;
#[derive(Debug, Deserialize)]
pub struct ApiResponse<T> {
#[serde(rename = "@Status")]
pub status: String,
#[serde(rename = "Errors", default)]
pub errors: ApiErrors,
#[serde(rename = "CommandResponse")]
pub command_response: Option<T>,
}
#[derive(Debug, Default, Deserialize)]
pub struct ApiErrors {
#[serde(rename = "Error", default)]
pub errors: Vec<ApiError>,
}
#[derive(Debug, Deserialize)]
pub struct ApiError {
#[serde(rename = "@Number", default)]
pub number: String,
#[serde(rename = "$text", default)]
pub message: String,
}
pub fn parse<T: DeserializeOwned>(body: &str) -> Result<T, Error> {
let envelope: ApiResponse<serde::de::IgnoredAny> =
quick_xml::de::from_str(body).map_err(|e| Error::Parse(format!("malformed XML: {e}")))?;
if envelope.status.eq_ignore_ascii_case("ok") {
let resp: ApiResponse<T> = quick_xml::de::from_str(body)
.map_err(|e| Error::Parse(format!("malformed XML: {e}")))?;
resp.command_response
.ok_or_else(|| Error::Parse("Status=OK but no CommandResponse element".into()))
} else {
let resp = envelope;
let (code, mut message) = resp
.errors
.errors
.first()
.map(|e| (e.number.clone(), e.message.trim().to_owned()))
.unwrap_or_else(|| ("unknown".into(), "no error detail in response".into()));
if code == "1011150" {
message.push_str(
" — this often accompanies IP-whitelist problems: check the client_ip \
config value, your current outbound IPv4, and the Namecheap whitelist",
);
}
if code == "500000" {
return Err(Error::RateLimited(format!("API error 500000: {message}")));
}
Err(Error::Api { code, message })
}
}
pub fn de_date<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
let s = String::deserialize(d)?;
Ok(to_iso_date(&s))
}
pub fn to_iso_date(s: &str) -> String {
let date_part = s.split_whitespace().next().unwrap_or(s);
let parts: Vec<&str> = date_part.split('/').collect();
if parts.len() == 3
&& let (Ok(m), Ok(d), Ok(y)) = (
parts[0].parse::<u32>(),
parts[1].parse::<u32>(),
parts[2].parse::<u32>(),
)
&& (1..=12).contains(&m)
&& (1..=31).contains(&d)
&& y >= 1000
{
return format!("{y:04}-{m:02}-{d:02}");
}
s.to_owned()
}
pub fn de_bool<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
let s = String::deserialize(d)?;
match s.to_ascii_lowercase().as_str() {
"true" | "1" => Ok(true),
"false" | "0" => Ok(false),
other => Err(serde::de::Error::custom(format!(
"expected boolean, got {other:?}"
))),
}
}
#[cfg(test)]
mod tests {
use super::to_iso_date;
#[test]
fn dates_normalize_to_iso_8601() {
assert_eq!(to_iso_date("08/20/2026"), "2026-08-20");
assert_eq!(to_iso_date("4/30/2021"), "2021-04-30");
assert_eq!(to_iso_date("4/30/2021 11:31:13 AM"), "2021-04-30");
}
#[test]
fn unrecognized_dates_pass_through_verbatim() {
for odd in ["", "2026-08-20", "13/40/2026", "soon", "1/2"] {
assert_eq!(to_iso_date(odd), odd, "must not mangle {odd:?}");
}
}
}