Skip to main content

reserve_core/lookup/
registration.rs

1//! Reading the published detail out of a registry record.
2
3use 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
120/// @docgen A contact card is `["vcard", [[key, {}, "text", value], ...]]`, which is where the index juggling comes from.
121fn 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}