reserve_core/lookup/
registration.rs1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::lookup::outcome::scrub;
7
8#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
9pub struct Registration {
10 #[serde(skip_serializing_if = "Option::is_none")]
11 pub registrar: Option<String>,
12 #[serde(skip_serializing_if = "Option::is_none")]
13 pub registrar_id: Option<String>,
14 #[serde(skip_serializing_if = "Option::is_none")]
15 pub created_at: Option<String>,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub updated_at: Option<String>,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub expires_at: Option<String>,
20 #[serde(skip_serializing_if = "Vec::is_empty")]
21 pub statuses: Vec<String>,
22 #[serde(skip_serializing_if = "Vec::is_empty")]
23 pub nameservers: Vec<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub has_dnssec: Option<bool>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub abuse_email: Option<String>,
28}
29
30impl Registration {
31 #[must_use]
32 pub fn is_empty(&self) -> bool {
33 *self == Self::default()
34 }
35}
36
37#[must_use]
38pub(crate) fn parse(body: &Value) -> Registration {
39 let mut record = Registration::default();
40
41 if let Some(statuses) = body.get("status").and_then(Value::as_array) {
42 record.statuses = statuses
43 .iter()
44 .filter_map(Value::as_str)
45 .map(scrub)
46 .collect();
47 }
48
49 if let Some(events) = body.get("events").and_then(Value::as_array) {
50 for event in events {
51 let action = event
52 .get("eventAction")
53 .and_then(Value::as_str)
54 .unwrap_or_default()
55 .to_lowercase();
56 let date = event.get("eventDate").and_then(Value::as_str).map(scrub);
57 match action.as_str() {
58 "registration" => record.created_at = date,
59 "expiration" => record.expires_at = date,
60 "last changed" => record.updated_at = date,
61 _ => {}
62 }
63 }
64 }
65
66 if let Some(nameservers) = body.get("nameservers").and_then(Value::as_array) {
67 record.nameservers = nameservers
68 .iter()
69 .filter_map(|ns| ns.get("ldhName").and_then(Value::as_str))
70 .map(|ns| scrub(&ns.to_lowercase()))
71 .collect();
72 }
73
74 record.has_dnssec = body
75 .get("secureDNS")
76 .and_then(|dns| dns.get("delegationSigned"))
77 .and_then(Value::as_bool);
78
79 if let Some(entities) = body.get("entities").and_then(Value::as_array) {
80 for entity in entities {
81 if !has_role(entity, "registrar") {
82 continue;
83 }
84 record.registrar = vcard(entity, "fn");
85 record.registrar_id = entity
86 .get("publicIds")
87 .and_then(Value::as_array)
88 .and_then(|ids| ids.first())
89 .and_then(|id| id.get("identifier"))
90 .and_then(Value::as_str)
91 .map(scrub);
92 record.abuse_email =
93 entity
94 .get("entities")
95 .and_then(Value::as_array)
96 .and_then(|nested| {
97 nested
98 .iter()
99 .find(|inner| has_role(inner, "abuse"))
100 .and_then(|inner| vcard(inner, "email"))
101 });
102 }
103 }
104
105 record
106}
107
108fn has_role(entity: &Value, role: &str) -> bool {
109 entity
110 .get("roles")
111 .and_then(Value::as_array)
112 .is_some_and(|roles| {
113 roles
114 .iter()
115 .filter_map(Value::as_str)
116 .any(|found| found.eq_ignore_ascii_case(role))
117 })
118}
119
120fn vcard(entity: &Value, key: &str) -> Option<String> {
122 let items = entity
123 .get("vcardArray")
124 .and_then(Value::as_array)?
125 .get(1)?
126 .as_array()?;
127 for item in items {
128 let Some(parts) = item.as_array() else {
129 continue;
130 };
131 if parts.first().and_then(Value::as_str) == Some(key)
132 && let Some(value) = parts.get(3).and_then(Value::as_str)
133 && !value.is_empty()
134 {
135 return Some(scrub(value));
136 }
137 }
138 None
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use serde_json::json;
145
146 fn sample() -> Value {
147 json!({
148 "objectClassName": "domain",
149 "ldhName": "apple.com",
150 "status": ["client transfer prohibited"],
151 "events": [
152 {"eventAction": "registration", "eventDate": "1987-02-19T05:00:00Z"},
153 {"eventAction": "expiration", "eventDate": "2027-02-20T05:00:00Z"},
154 {"eventAction": "last changed", "eventDate": "2026-02-09T15:41:53Z"},
155 {"eventAction": "last update of RDAP database", "eventDate": "2026-08-15T00:00:00Z"}
156 ],
157 "nameservers": [{"ldhName": "A.NS.APPLE.COM"}],
158 "secureDNS": {"delegationSigned": false},
159 "entities": [{
160 "roles": ["registrar"],
161 "publicIds": [{"identifier": "470"}],
162 "vcardArray": ["vcard", [["version", {}, "text", "4.0"], ["fn", {}, "text", "COM LAUDE"]]],
163 "entities": [{
164 "roles": ["abuse"],
165 "vcardArray": ["vcard", [["email", {}, "text", "abuse@example.com"]]]
166 }]
167 }]
168 })
169 }
170
171 #[test]
172 fn it_reads_the_published_detail() {
173 let record = parse(&sample());
174 assert_eq!(record.registrar.as_deref(), Some("COM LAUDE"));
175 assert_eq!(record.registrar_id.as_deref(), Some("470"));
176 assert_eq!(record.created_at.as_deref(), Some("1987-02-19T05:00:00Z"));
177 assert_eq!(record.expires_at.as_deref(), Some("2027-02-20T05:00:00Z"));
178 assert_eq!(record.nameservers, vec!["a.ns.apple.com"]);
179 assert_eq!(record.has_dnssec, Some(false));
180 assert_eq!(record.abuse_email.as_deref(), Some("abuse@example.com"));
181 }
182
183 #[test]
184 fn the_last_changed_event_wins_over_a_database_stamp() {
185 assert_eq!(
186 parse(&sample()).updated_at.as_deref(),
187 Some("2026-02-09T15:41:53Z")
188 );
189 }
190
191 #[test]
192 fn an_empty_body_yields_an_empty_record() {
193 assert!(parse(&json!({})).is_empty());
194 assert!(!parse(&sample()).is_empty());
195 }
196
197 #[test]
198 fn a_missing_contact_card_is_not_an_error() {
199 let body = json!({"entities": [{"roles": ["registrar"]}]});
200 let record = parse(&body);
201 assert!(record.registrar.is_none());
202 assert!(record.abuse_email.is_none());
203 }
204}