use crate::domain::DomainName;
use crate::error::{Error, Result};
use crate::parser::{parse_datetime, Contact, RecordParser, WhoisRecord};
use crate::rdap::{JCard, RdapEntity, RdapResponse};
use crate::transport::{RawResponse, ResponseKind};
#[derive(Debug, Clone, Copy, Default)]
pub struct RdapParser {
_private: (),
}
impl RdapParser {
pub fn new() -> Self {
RdapParser { _private: () }
}
pub fn parse_json(&self, json: &str) -> Result<WhoisRecord> {
let response = RdapResponse::parse(json).map_err(|source| Error::Rdap {
url: "<in-memory>".to_string(),
source,
})?;
Ok(self.map(&response))
}
pub fn map(&self, response: &RdapResponse) -> WhoisRecord {
let mut record = WhoisRecord::new();
if response.is_error() {
return record;
}
record.domain = response
.ldh_name
.as_deref()
.or(response.unicode_name.as_deref())
.and_then(|name| DomainName::parse(name).ok());
record.registry_id = response.handle.clone();
record.statuses = response
.status
.iter()
.map(|status| status.to_lowercase())
.collect();
record.statuses.dedup();
record.name_servers = response.nameserver_names();
record.name_servers.sort();
record.name_servers.dedup();
record.dnssec = response.is_signed();
record.created = response.event_date("registration").and_then(parse_datetime);
record.expires = response.event_date("expiration").and_then(parse_datetime);
record.updated = response
.event_date("last changed")
.or_else(|| response.event_date("last update of RDAP database"))
.and_then(parse_datetime);
if let Some(registrar) = response.entity_with_role("registrar") {
record.registrar = registrar.display_name();
record.registrar_iana_id = registrar
.public_id("IANA Registrar ID")
.map(str::to_string)
.or_else(|| registrar.handle.clone());
let card = registrar.card();
record.registrar_url = card
.get("url")
.map(str::to_string)
.or_else(|| card.get("fburl").map(str::to_string));
if let Some(abuse) = registrar.find_role("abuse") {
let abuse_card = abuse.card();
record.abuse_contact_email = abuse_card.email().map(str::to_string);
record.abuse_contact_phone = abuse_card.phone().map(strip_tel_uri);
}
}
for (role, slot) in [
("registrant", &mut record.registrant),
("administrative", &mut record.admin),
("technical", &mut record.tech),
("billing", &mut record.billing),
] {
if let Some(entity) = response.entity_with_role(role) {
let contact = contact_from(entity);
if !contact.is_empty() {
*slot = Some(contact);
}
}
}
record
}
}
impl RecordParser for RdapParser {
fn name(&self) -> &'static str {
"rdap"
}
fn can_parse(&self, response: &RawResponse) -> bool {
response.kind() == ResponseKind::RdapJson
}
fn parse(&self, response: &RawResponse) -> Result<WhoisRecord> {
let parsed = RdapResponse::parse(response.text()).map_err(|source| Error::Rdap {
url: response.endpoint().address(),
source,
})?;
Ok(self.map(&parsed))
}
}
fn contact_from(entity: &RdapEntity) -> Contact {
let card = entity.card();
Contact {
handle: entity.handle.clone(),
name: card.formatted_name().map(str::to_string),
organization: card.organization().map(str::to_string),
street: street_lines(&card),
city: card.address_part(3),
state: card.address_part(4),
postal_code: card.address_part(5),
country: card.address_part(6).map(|code| code.to_uppercase()),
phone: card.phone().map(strip_tel_uri),
fax: fax_number(&card),
email: card.email().map(str::to_string),
}
}
fn street_lines(card: &JCard) -> Vec<String> {
[card.address_part(1), card.address_part(2)]
.into_iter()
.flatten()
.collect()
}
fn fax_number(card: &JCard) -> Option<String> {
let numbers = card.get_all("tel");
numbers.get(1).map(|number| strip_tel_uri(number))
}
fn strip_tel_uri(value: &str) -> String {
value
.strip_prefix("tel:")
.unwrap_or(value)
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
const FULL: &str = r#"{
"objectClassName": "domain",
"handle": "2336799_DOMAIN_COM-VRSN",
"ldhName": "EXAMPLE.COM",
"status": ["client delete prohibited", "CLIENT TRANSFER PROHIBITED"],
"events": [
{"eventAction": "registration", "eventDate": "1995-08-14T04:00:00Z"},
{"eventAction": "expiration", "eventDate": "2027-08-13T04:00:00Z"},
{"eventAction": "last changed", "eventDate": "2026-08-14T07:01:44Z"}
],
"nameservers": [
{"ldhName": "B.IANA-SERVERS.NET"},
{"ldhName": "A.IANA-SERVERS.NET."}
],
"secureDNS": {"delegationSigned": false},
"entities": [
{
"handle": "376",
"roles": ["registrar"],
"publicIds": [{"type": "IANA Registrar ID", "identifier": "376"}],
"vcardArray": ["vcard", [
["fn", {}, "text", "Example Registrar, LLC"],
["url", {}, "uri", "https://www.example-registrar.com"]
]],
"entities": [{
"roles": ["abuse"],
"vcardArray": ["vcard", [
["email", {}, "text", "abuse@example-registrar.com"],
["tel", {}, "uri", "tel:+1.5555555555"]
]]
}]
},
{
"handle": "EX123",
"roles": ["registrant"],
"vcardArray": ["vcard", [
["fn", {}, "text", "Ada Lovelace"],
["org", {}, "text", "Example Ltd"],
["adr", {}, "text", ["", "Flat 1", "1 Example Street", "London", "Greater London", "EC1A 1AA", "gb"]],
["email", {}, "text", "ada@example.com"],
["tel", {}, "uri", "tel:+44.2000000000"],
["tel", {}, "uri", "tel:+44.2000000001"]
]]
},
{
"roles": ["administrative"],
"vcardArray": ["vcard", [["fn", {}, "text", "REDACTED FOR PRIVACY"]]]
}
]
}"#;
fn parse(json: &str) -> WhoisRecord {
RdapParser::new().parse_json(json).unwrap()
}
#[test]
fn maps_a_full_domain_object() {
let record = parse(FULL);
assert_eq!(record.domain.as_ref().unwrap().as_ascii(), "example.com");
assert_eq!(
record.registry_id.as_deref(),
Some("2336799_DOMAIN_COM-VRSN")
);
assert_eq!(record.dnssec, Some(false));
assert_eq!(
record.statuses,
["client delete prohibited", "client transfer prohibited"],
"statuses must be lower-cased"
);
assert!(record.is_transfer_locked());
}
#[test]
fn maps_events_onto_the_record_dates() {
let record = parse(FULL);
assert_eq!(
record.created.unwrap().format("%Y-%m-%d").to_string(),
"1995-08-14"
);
assert_eq!(
record.expires.unwrap().format("%Y-%m-%d").to_string(),
"2027-08-13"
);
assert_eq!(
record.updated.unwrap().format("%Y-%m-%d").to_string(),
"2026-08-14"
);
}
#[test]
fn name_servers_are_normalised_and_sorted() {
let record = parse(FULL);
assert_eq!(
record.name_servers,
["a.iana-servers.net", "b.iana-servers.net"]
);
}
#[test]
fn maps_the_registrar_and_its_nested_abuse_contact() {
let record = parse(FULL);
assert_eq!(record.registrar.as_deref(), Some("Example Registrar, LLC"));
assert_eq!(record.registrar_iana_id.as_deref(), Some("376"));
assert_eq!(
record.registrar_url.as_deref(),
Some("https://www.example-registrar.com")
);
assert_eq!(
record.abuse_contact_email.as_deref(),
Some("abuse@example-registrar.com")
);
assert_eq!(
record.abuse_contact_phone.as_deref(),
Some("+1.5555555555"),
"the tel: URI scheme should be stripped"
);
}
#[test]
fn maps_a_registrant_from_its_jcard() {
let record = parse(FULL);
let registrant = record.registrant.as_ref().unwrap();
assert_eq!(registrant.handle.as_deref(), Some("EX123"));
assert_eq!(registrant.name.as_deref(), Some("Ada Lovelace"));
assert_eq!(registrant.organization.as_deref(), Some("Example Ltd"));
assert_eq!(registrant.street, ["Flat 1", "1 Example Street"]);
assert_eq!(registrant.city.as_deref(), Some("London"));
assert_eq!(registrant.state.as_deref(), Some("Greater London"));
assert_eq!(registrant.postal_code.as_deref(), Some("EC1A 1AA"));
assert_eq!(registrant.country.as_deref(), Some("GB"));
assert_eq!(registrant.email.as_deref(), Some("ada@example.com"));
assert_eq!(registrant.phone.as_deref(), Some("+44.2000000000"));
assert_eq!(registrant.fax.as_deref(), Some("+44.2000000001"));
}
#[test]
fn redacted_contacts_are_still_mapped_and_flagged() {
let record = parse(FULL);
let admin = record.admin.as_ref().unwrap();
assert!(admin.is_redacted());
assert_eq!(admin.name.as_deref(), Some("REDACTED FOR PRIVACY"));
}
#[test]
fn an_error_object_maps_to_an_empty_record() {
let record = parse(r#"{"errorCode":404,"title":"Not Found"}"#);
assert!(record.is_empty(), "{record:?}");
}
#[test]
fn a_minimal_domain_object_maps_what_it_has() {
let record = parse(r#"{"objectClassName":"domain","ldhName":"example.com"}"#);
assert_eq!(record.domain.as_ref().unwrap().as_ascii(), "example.com");
assert!(record.registrar.is_none());
assert!(record.statuses.is_empty());
assert!(!record.is_empty());
}
#[test]
fn a_registrar_without_a_public_id_falls_back_to_its_handle() {
let record = parse(
r#"{"objectClassName":"domain","ldhName":"a.com","entities":[
{"handle":"9999","roles":["registrar"],
"vcardArray":["vcard",[["fn",{},"text","Some Registrar"]]]}
]}"#,
);
assert_eq!(record.registrar_iana_id.as_deref(), Some("9999"));
}
#[test]
fn an_entity_with_an_empty_card_is_not_recorded_as_a_contact() {
let record = parse(
r#"{"objectClassName":"domain","ldhName":"a.com","entities":[
{"roles":["registrant"]}
]}"#,
);
assert!(record.registrant.is_none());
}
#[test]
fn an_idn_domain_is_stored_in_ascii_form() {
let record = parse(r#"{"objectClassName":"domain","ldhName":"xn--mnchen-3ya.de"}"#);
assert_eq!(
record.domain.as_ref().unwrap().as_ascii(),
"xn--mnchen-3ya.de"
);
assert_eq!(record.domain.as_ref().unwrap().as_unicode(), "münchen.de");
}
#[test]
fn a_unicode_only_name_still_maps() {
let record = parse(r#"{"objectClassName":"domain","unicodeName":"münchen.de"}"#);
assert_eq!(
record.domain.as_ref().unwrap().as_ascii(),
"xn--mnchen-3ya.de"
);
}
#[test]
fn non_json_is_an_error_not_an_empty_record() {
let error = RdapParser::new()
.parse_json("<html>Gateway Timeout</html>")
.unwrap_err();
assert!(matches!(error, Error::Rdap { .. }), "got {error:?}");
}
}