use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::lookup::outcome::scrub;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Registration {
#[serde(skip_serializing_if = "Option::is_none")]
pub registrar: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registrar_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub statuses: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub nameservers: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_dnssec: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub abuse_email: Option<String>,
}
impl Registration {
#[must_use]
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
}
#[must_use]
pub(crate) fn parse(body: &Value) -> Registration {
let mut record = Registration::default();
if let Some(statuses) = body.get("status").and_then(Value::as_array) {
record.statuses = statuses
.iter()
.filter_map(Value::as_str)
.map(scrub)
.collect();
}
if let Some(events) = body.get("events").and_then(Value::as_array) {
for event in events {
let action = event
.get("eventAction")
.and_then(Value::as_str)
.unwrap_or_default()
.to_lowercase();
let date = event.get("eventDate").and_then(Value::as_str).map(scrub);
match action.as_str() {
"registration" => record.created_at = date,
"expiration" => record.expires_at = date,
"last changed" => record.updated_at = date,
_ => {}
}
}
}
if let Some(nameservers) = body.get("nameservers").and_then(Value::as_array) {
record.nameservers = nameservers
.iter()
.filter_map(|ns| ns.get("ldhName").and_then(Value::as_str))
.map(|ns| scrub(&ns.to_lowercase()))
.collect();
}
record.has_dnssec = body
.get("secureDNS")
.and_then(|dns| dns.get("delegationSigned"))
.and_then(Value::as_bool);
if let Some(entities) = body.get("entities").and_then(Value::as_array) {
for entity in entities {
if !has_role(entity, "registrar") {
continue;
}
record.registrar = vcard(entity, "fn");
record.registrar_id = entity
.get("publicIds")
.and_then(Value::as_array)
.and_then(|ids| ids.first())
.and_then(|id| id.get("identifier"))
.and_then(Value::as_str)
.map(scrub);
record.abuse_email =
entity
.get("entities")
.and_then(Value::as_array)
.and_then(|nested| {
nested
.iter()
.find(|inner| has_role(inner, "abuse"))
.and_then(|inner| vcard(inner, "email"))
});
}
}
record
}
fn has_role(entity: &Value, role: &str) -> bool {
entity
.get("roles")
.and_then(Value::as_array)
.is_some_and(|roles| {
roles
.iter()
.filter_map(Value::as_str)
.any(|found| found.eq_ignore_ascii_case(role))
})
}
fn vcard(entity: &Value, key: &str) -> Option<String> {
let items = entity
.get("vcardArray")
.and_then(Value::as_array)?
.get(1)?
.as_array()?;
for item in items {
let Some(parts) = item.as_array() else {
continue;
};
if parts.first().and_then(Value::as_str) == Some(key)
&& let Some(value) = parts.get(3).and_then(Value::as_str)
&& !value.is_empty()
{
return Some(scrub(value));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sample() -> Value {
json!({
"objectClassName": "domain",
"ldhName": "apple.com",
"status": ["client transfer prohibited"],
"events": [
{"eventAction": "registration", "eventDate": "1987-02-19T05:00:00Z"},
{"eventAction": "expiration", "eventDate": "2027-02-20T05:00:00Z"},
{"eventAction": "last changed", "eventDate": "2026-02-09T15:41:53Z"},
{"eventAction": "last update of RDAP database", "eventDate": "2026-08-15T00:00:00Z"}
],
"nameservers": [{"ldhName": "A.NS.APPLE.COM"}],
"secureDNS": {"delegationSigned": false},
"entities": [{
"roles": ["registrar"],
"publicIds": [{"identifier": "470"}],
"vcardArray": ["vcard", [["version", {}, "text", "4.0"], ["fn", {}, "text", "COM LAUDE"]]],
"entities": [{
"roles": ["abuse"],
"vcardArray": ["vcard", [["email", {}, "text", "abuse@example.com"]]]
}]
}]
})
}
#[test]
fn it_reads_the_published_detail() {
let record = parse(&sample());
assert_eq!(record.registrar.as_deref(), Some("COM LAUDE"));
assert_eq!(record.registrar_id.as_deref(), Some("470"));
assert_eq!(record.created_at.as_deref(), Some("1987-02-19T05:00:00Z"));
assert_eq!(record.expires_at.as_deref(), Some("2027-02-20T05:00:00Z"));
assert_eq!(record.nameservers, vec!["a.ns.apple.com"]);
assert_eq!(record.has_dnssec, Some(false));
assert_eq!(record.abuse_email.as_deref(), Some("abuse@example.com"));
}
#[test]
fn the_last_changed_event_wins_over_a_database_stamp() {
assert_eq!(
parse(&sample()).updated_at.as_deref(),
Some("2026-02-09T15:41:53Z")
);
}
#[test]
fn an_empty_body_yields_an_empty_record() {
assert!(parse(&json!({})).is_empty());
assert!(!parse(&sample()).is_empty());
}
#[test]
fn a_missing_contact_card_is_not_an_error() {
let body = json!({"entities": [{"roles": ["registrar"]}]});
let record = parse(&body);
assert!(record.registrar.is_none());
assert!(record.abuse_email.is_none());
}
}