reserve-core 0.2.0

Core lookup, catalog, and rate-limiting engine behind the reserve domain finder
Documentation
//! Reading the published detail out of a registry record.

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))
        })
}

/// @docgen A contact card is `["vcard", [[key, {}, "text", value], ...]]`, which is where the index juggling comes from.
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());
    }
}