use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[non_exhaustive]
pub struct RdapResponse {
pub object_class_name: Option<String>,
pub handle: Option<String>,
pub ldh_name: Option<String>,
pub unicode_name: Option<String>,
pub status: Vec<String>,
pub events: Vec<RdapEvent>,
pub nameservers: Vec<RdapNameserver>,
#[serde(rename = "secureDNS")]
pub secure_dns: Option<SecureDns>,
pub entities: Vec<RdapEntity>,
pub rdap_conformance: Vec<String>,
pub notices: Vec<RdapNotice>,
pub error_code: Option<u16>,
pub title: Option<String>,
pub description: Vec<String>,
}
impl RdapResponse {
pub fn parse(json: &str) -> serde_json::Result<Self> {
serde_json::from_str(json)
}
pub fn is_error(&self) -> bool {
self.error_code.is_some()
}
pub fn is_domain(&self) -> bool {
!self.is_error()
&& (self
.object_class_name
.as_deref()
.is_some_and(|class| class.eq_ignore_ascii_case("domain"))
|| self.ldh_name.is_some())
}
pub fn event_date(&self, action: &str) -> Option<&str> {
self.events
.iter()
.find(|event| {
event
.event_action
.as_deref()
.is_some_and(|value| value.eq_ignore_ascii_case(action))
})
.and_then(|event| event.event_date.as_deref())
}
pub fn entity_with_role(&self, role: &str) -> Option<&RdapEntity> {
self.entities.iter().find(|entity| entity.has_role(role))
}
pub fn nameserver_names(&self) -> Vec<String> {
self.nameservers
.iter()
.filter_map(|server| server.ldh_name.as_deref())
.map(|name| name.trim_end_matches('.').to_lowercase())
.collect()
}
pub fn is_signed(&self) -> Option<bool> {
self.secure_dns
.as_ref()
.and_then(|dns| dns.delegation_signed)
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[non_exhaustive]
pub struct RdapEvent {
pub event_action: Option<String>,
pub event_date: Option<String>,
pub event_actor: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[non_exhaustive]
pub struct RdapNameserver {
pub object_class_name: Option<String>,
pub ldh_name: Option<String>,
pub unicode_name: Option<String>,
pub ip_addresses: Option<IpAddresses>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct IpAddresses {
pub v4: Vec<String>,
pub v6: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[non_exhaustive]
pub struct SecureDns {
pub delegation_signed: Option<bool>,
pub zone_signed: Option<bool>,
pub ds_data: Vec<Value>,
pub key_data: Vec<Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[non_exhaustive]
pub struct RdapEntity {
pub object_class_name: Option<String>,
pub handle: Option<String>,
pub roles: Vec<String>,
pub vcard_array: Option<Value>,
pub public_ids: Vec<PublicId>,
pub entities: Vec<RdapEntity>,
pub status: Vec<String>,
pub events: Vec<RdapEvent>,
}
impl RdapEntity {
pub fn has_role(&self, role: &str) -> bool {
self.roles
.iter()
.any(|held| held.eq_ignore_ascii_case(role))
}
pub fn card(&self) -> JCard {
JCard::from_value(self.vcard_array.as_ref())
}
pub fn public_id(&self, kind: &str) -> Option<&str> {
self.public_ids
.iter()
.find(|id| {
id.id_type
.as_deref()
.is_some_and(|value| value.eq_ignore_ascii_case(kind))
})
.and_then(|id| id.identifier.as_deref())
}
pub fn display_name(&self) -> Option<String> {
let card = self.card();
card.formatted_name()
.or_else(|| card.organization())
.map(str::to_string)
.or_else(|| self.handle.clone())
}
pub fn find_role(&self, role: &str) -> Option<&RdapEntity> {
if self.has_role(role) {
return Some(self);
}
self.entities
.iter()
.find_map(|nested| nested.find_role(role))
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PublicId {
#[serde(rename = "type", default)]
pub id_type: Option<String>,
#[serde(default)]
pub identifier: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct RdapNotice {
pub title: Option<String>,
pub description: Vec<String>,
pub links: Vec<Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct JCardProperty {
pub name: String,
pub value: String,
pub components: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct JCard {
properties: Vec<JCardProperty>,
}
impl JCard {
pub fn parse(json: &str) -> serde_json::Result<Self> {
let value: Value = serde_json::from_str(json)?;
Ok(JCard::from_value(Some(&value)))
}
pub fn from_value(value: Option<&Value>) -> Self {
let mut properties = Vec::new();
let entries = value
.and_then(Value::as_array)
.and_then(|outer| outer.get(1))
.and_then(Value::as_array);
for entry in entries.into_iter().flatten() {
let Some(parts) = entry.as_array() else {
continue;
};
let Some(name) = parts.first().and_then(Value::as_str) else {
continue;
};
let Some(raw_value) = parts.get(3) else {
continue;
};
let (value, components) = flatten_value(raw_value);
properties.push(JCardProperty {
name: name.to_lowercase(),
value,
components,
});
}
JCard { properties }
}
pub fn properties(&self) -> &[JCardProperty] {
&self.properties
}
pub fn is_empty(&self) -> bool {
self.properties.is_empty()
}
pub fn get(&self, name: &str) -> Option<&str> {
self.properties
.iter()
.find(|property| property.name == name)
.map(|property| property.value.as_str())
.filter(|value| !value.is_empty())
}
pub fn get_all(&self, name: &str) -> Vec<&str> {
self.properties
.iter()
.filter(|property| property.name == name)
.map(|property| property.value.as_str())
.filter(|value| !value.is_empty())
.collect()
}
pub fn formatted_name(&self) -> Option<&str> {
self.get("fn")
}
pub fn organization(&self) -> Option<&str> {
self.get("org")
}
pub fn email(&self) -> Option<&str> {
self.get("email")
}
pub fn phone(&self) -> Option<&str> {
self.get("tel")
}
pub fn address_components(&self) -> Vec<String> {
self.properties
.iter()
.find(|property| property.name == "adr")
.map(|property| property.components.clone())
.unwrap_or_default()
}
pub fn address_part(&self, index: usize) -> Option<String> {
self.address_components()
.get(index)
.map(|part| part.trim().to_string())
.filter(|part| !part.is_empty())
}
}
fn flatten_value(value: &Value) -> (String, Vec<String>) {
match value {
Value::String(text) => (text.clone(), Vec::new()),
Value::Number(number) => (number.to_string(), Vec::new()),
Value::Bool(flag) => (flag.to_string(), Vec::new()),
Value::Array(items) => {
let components: Vec<String> = items
.iter()
.map(|item| match item {
Value::String(text) => text.clone(),
Value::Array(nested) => nested
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(" "),
other => other.as_str().unwrap_or_default().to_string(),
})
.collect();
let joined = components
.iter()
.filter(|part| !part.trim().is_empty())
.cloned()
.collect::<Vec<_>>()
.join(", ");
(joined, components)
}
_ => (String::new(), Vec::new()),
}
}
#[cfg(test)]
mod tests {
use super::*;
const VERISIGN_STYLE: &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": [
{"objectClassName": "nameserver", "ldhName": "A.IANA-SERVERS.NET"},
{"objectClassName": "nameserver", "ldhName": "B.IANA-SERVERS.NET."}
],
"secureDNS": {"delegationSigned": true},
"entities": [{
"objectClassName": "entity",
"handle": "376",
"roles": ["registrar"],
"publicIds": [{"type": "IANA Registrar ID", "identifier": "376"}],
"vcardArray": ["vcard", [
["version", {}, "text", "4.0"],
["fn", {}, "text", "Example Registrar, LLC"]
]],
"entities": [{
"objectClassName": "entity",
"roles": ["abuse"],
"vcardArray": ["vcard", [
["version", {}, "text", "4.0"],
["email", {}, "text", "abuse@example-registrar.com"],
["tel", {}, "text", "+1.5555555555"]
]]
}]
}],
"rdapConformance": ["rdap_level_0", "icann_rdap_response_profile_0"]
}"#;
#[test]
fn reads_a_registry_domain_object() {
let response = RdapResponse::parse(VERISIGN_STYLE).unwrap();
assert!(response.is_domain());
assert!(!response.is_error());
assert_eq!(response.ldh_name.as_deref(), Some("EXAMPLE.COM"));
assert_eq!(response.handle.as_deref(), Some("2336799_DOMAIN_COM-VRSN"));
assert_eq!(response.status.len(), 2);
assert_eq!(response.is_signed(), Some(true));
assert_eq!(
response.nameserver_names(),
["a.iana-servers.net", "b.iana-servers.net"],
"trailing dots and case must be normalised"
);
}
#[test]
fn finds_events_by_action_case_insensitively() {
let response = RdapResponse::parse(VERISIGN_STYLE).unwrap();
assert_eq!(
response.event_date("registration"),
Some("1995-08-14T04:00:00Z")
);
assert_eq!(
response.event_date("EXPIRATION"),
Some("2027-08-13T04:00:00Z")
);
assert_eq!(
response.event_date("last changed"),
Some("2026-08-14T07:01:44Z")
);
assert_eq!(response.event_date("transfer"), None);
}
#[test]
fn finds_the_registrar_and_its_identifiers() {
let response = RdapResponse::parse(VERISIGN_STYLE).unwrap();
let registrar = response.entity_with_role("registrar").unwrap();
assert_eq!(
registrar.display_name().as_deref(),
Some("Example Registrar, LLC")
);
assert_eq!(registrar.public_id("IANA Registrar ID"), Some("376"));
assert_eq!(registrar.public_id("nonexistent"), None);
assert!(registrar.has_role("REGISTRAR"));
}
#[test]
fn finds_a_nested_abuse_contact() {
let response = RdapResponse::parse(VERISIGN_STYLE).unwrap();
let registrar = response.entity_with_role("registrar").unwrap();
let abuse = registrar.find_role("abuse").unwrap();
assert_eq!(abuse.card().email(), Some("abuse@example-registrar.com"));
assert_eq!(abuse.card().phone(), Some("+1.5555555555"));
}
#[test]
fn reads_the_error_object() {
let response = RdapResponse::parse(
r#"{"errorCode":404,"title":"Not Found","description":["The domain does not exist"]}"#,
)
.unwrap();
assert!(response.is_error());
assert!(!response.is_domain());
assert_eq!(response.error_code, Some(404));
assert_eq!(response.title.as_deref(), Some("Not Found"));
assert_eq!(response.description, ["The domain does not exist"]);
}
#[test]
fn unknown_members_are_ignored_not_rejected() {
let response = RdapResponse::parse(
r#"{"objectClassName":"domain","ldhName":"a.com","somethingNew":{"x":1}}"#,
)
.unwrap();
assert!(response.is_domain());
}
#[test]
fn an_empty_object_is_neither_a_domain_nor_an_error() {
let response = RdapResponse::parse("{}").unwrap();
assert!(!response.is_domain());
assert!(!response.is_error());
}
#[test]
fn a_domain_object_without_object_class_name_still_counts() {
let response = RdapResponse::parse(r#"{"ldhName":"example.com"}"#).unwrap();
assert!(response.is_domain());
}
#[test]
fn jcard_reads_the_flat_properties() {
let card = JCard::parse(
r#"["vcard",[
["version",{},"text","4.0"],
["fn",{},"text","Ada Lovelace"],
["org",{},"text","Example Ltd"],
["email",{},"text","ada@example.com"],
["tel",{"type":["voice"]},"uri","tel:+44.2000000000"]
]]"#,
)
.unwrap();
assert_eq!(card.formatted_name(), Some("Ada Lovelace"));
assert_eq!(card.organization(), Some("Example Ltd"));
assert_eq!(card.email(), Some("ada@example.com"));
assert_eq!(card.phone(), Some("tel:+44.2000000000"));
assert!(!card.is_empty());
}
#[test]
fn jcard_reads_a_structured_address() {
let card = JCard::parse(
r#"["vcard",[
["version",{},"text","4.0"],
["adr",{},"text",["","","1 Example Street","London","Greater London","EC1A 1AA","GB"]]
]]"#,
)
.unwrap();
let parts = card.address_components();
assert_eq!(parts.len(), 7);
assert_eq!(card.address_part(2).as_deref(), Some("1 Example Street"));
assert_eq!(card.address_part(3).as_deref(), Some("London"));
assert_eq!(card.address_part(5).as_deref(), Some("EC1A 1AA"));
assert_eq!(card.address_part(6).as_deref(), Some("GB"));
assert_eq!(card.address_part(0), None);
assert_eq!(card.address_part(99), None);
}
#[test]
fn jcard_flattens_a_structured_value_for_display() {
let card = JCard::parse(
r#"["vcard",[["adr",{},"text",["","","1 Example Street","London","","EC1A 1AA","GB"]]]]"#,
)
.unwrap();
assert_eq!(
card.get("adr"),
Some("1 Example Street, London, EC1A 1AA, GB")
);
}
#[test]
fn jcard_handles_multi_valued_components() {
let card = JCard::parse(
r#"["vcard",[["adr",{},"text",["","",["Flat 1","1 Example Street"],"London","","","GB"]]]]"#,
)
.unwrap();
assert_eq!(
card.address_part(2).as_deref(),
Some("Flat 1 1 Example Street")
);
}
#[test]
fn jcard_returns_every_value_of_a_repeated_property() {
let card = JCard::parse(
r#"["vcard",[
["email",{},"text","one@example.com"],
["email",{},"text","two@example.com"]
]]"#,
)
.unwrap();
assert_eq!(
card.get_all("email"),
["one@example.com", "two@example.com"]
);
assert_eq!(card.email(), Some("one@example.com"));
}
#[test]
fn a_malformed_jcard_costs_only_the_contact_details() {
for json in [
"null",
"{}",
"[]",
r#"["vcard"]"#,
r#"["vcard", "not an array"]"#,
r#"["vcard", [["fn"]]]"#,
r#"["vcard", [null, 42]]"#,
] {
let card = JCard::parse(json).unwrap();
assert!(card.is_empty(), "for {json}");
assert_eq!(card.formatted_name(), None);
assert!(card.address_components().is_empty());
}
}
#[test]
fn an_entity_with_no_vcard_falls_back_to_its_handle() {
let entity = RdapEntity {
handle: Some("EX123".into()),
roles: vec!["registrant".into()],
..RdapEntity::default()
};
assert_eq!(entity.display_name().as_deref(), Some("EX123"));
assert!(entity.card().is_empty());
}
#[test]
fn round_trips_through_json() {
let response = RdapResponse::parse(VERISIGN_STYLE).unwrap();
let json = serde_json::to_string(&response).unwrap();
assert_eq!(RdapResponse::parse(&json).unwrap(), response);
}
}