rdap 0.2.0

A modern RDAP (Registration Data Access Protocol) client
Documentation
//! RDAP request types and builders

use crate::bootstrap::{KNOWN_ENTITY_TAGS, extract_entity_tag};
use crate::error::Result;
use std::fmt;
use url::Url;

/// RDAP query types
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueryType {
    /// Domain name query
    Domain,
    /// IP address query
    Ip,
    /// Autonomous System Number query
    Autnum,
    /// Entity query
    Entity,
    /// Nameserver query
    Nameserver,
    /// Help query
    Help,
    /// Domain search
    DomainSearch,
    /// Domain search by nameserver
    DomainSearchByNameserver,
    /// Domain search by nameserver IP
    DomainSearchByNameserverIp,
    /// Nameserver search
    NameserverSearch,
    /// Nameserver search by IP
    NameserverSearchByIp,
    /// Entity search
    EntitySearch,
    /// Entity search by handle
    EntitySearchByHandle,
}

impl fmt::Display for QueryType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = match self {
            QueryType::Domain => "domain",
            QueryType::Ip => "ip",
            QueryType::Autnum => "autnum",
            QueryType::Entity => "entity",
            QueryType::Nameserver => "nameserver",
            QueryType::Help => "help",
            QueryType::DomainSearch => "domain-search",
            QueryType::DomainSearchByNameserver => "domain-search-by-nameserver",
            QueryType::DomainSearchByNameserverIp => "domain-search-by-nameserver-ip",
            QueryType::NameserverSearch => "nameserver-search",
            QueryType::NameserverSearchByIp => "nameserver-search-by-ip",
            QueryType::EntitySearch => "entity-search",
            QueryType::EntitySearchByHandle => "entity-search-by-handle",
        };
        write!(f, "{}", s)
    }
}

/// A prepared RDAP query, combining the query type, query string, and
/// optional server override.
///
/// Use [`RdapRequest::new`] to create a request and [`with_server`](RdapRequest::with_server)
/// to pin it to a specific RDAP server. Pass it to [`RdapClient::query`](crate::RdapClient::query)
/// to execute.
#[derive(Debug, Clone)]
pub struct RdapRequest {
    /// The type of RDAP query to perform (domain, IP, ASN, entity, etc.).
    pub query_type: QueryType,
    /// The query string (e.g. `"example.com"`, `"192.0.2.1"`, `"AS15169"`).
    pub query: String,
    /// Optional RDAP server URL. When `None`, bootstrap discovery is used.
    pub server: Option<Url>,
}

impl RdapRequest {
    /// Create a new RDAP request
    pub fn new(query_type: QueryType, query: impl Into<String>) -> Self {
        Self {
            query_type,
            query: query.into(),
            server: None,
        }
    }

    /// Set the RDAP server URL
    pub fn with_server(mut self, server: Url) -> Self {
        self.server = Some(server);
        self
    }

    /// Build the full RDAP URL
    pub fn build_url(&self, base_url: &Url) -> Result<Url> {
        let path = match self.query_type {
            QueryType::Domain => format!("domain/{}", urlencoding::encode(&self.query)),
            QueryType::Ip => format!("ip/{}", self.query),
            QueryType::Autnum => {
                let asn = self.query.trim_start_matches("AS").trim_start_matches("as");
                format!("autnum/{}", asn)
            }
            QueryType::Entity => format!("entity/{}", urlencoding::encode(&self.query)),
            QueryType::Nameserver => format!("nameserver/{}", urlencoding::encode(&self.query)),
            QueryType::Help => "help".to_string(),
            QueryType::DomainSearch => {
                return Ok(base_url.join(&format!(
                    "domains?name={}",
                    urlencoding::encode(&self.query)
                ))?);
            }
            QueryType::DomainSearchByNameserver => {
                return Ok(base_url.join(&format!(
                    "domains?nsLdhName={}",
                    urlencoding::encode(&self.query)
                ))?);
            }
            QueryType::DomainSearchByNameserverIp => {
                return Ok(base_url.join(&format!("domains?nsIp={}", self.query))?);
            }
            QueryType::NameserverSearch => {
                return Ok(base_url.join(&format!(
                    "nameservers?name={}",
                    urlencoding::encode(&self.query)
                ))?);
            }
            QueryType::NameserverSearchByIp => {
                return Ok(base_url.join(&format!("nameservers?ip={}", self.query))?);
            }
            QueryType::EntitySearch => {
                return Ok(
                    base_url.join(&format!("entities?fn={}", urlencoding::encode(&self.query)))?
                );
            }
            QueryType::EntitySearchByHandle => {
                return Ok(base_url.join(&format!(
                    "entities?handle={}",
                    urlencoding::encode(&self.query)
                ))?);
            }
        };

        Ok(base_url.join(&path)?)
    }

    /// Auto-detect the [`QueryType`] from a raw query string.
    ///
    /// Detection heuristics (checked in order):
    /// 1. Starts with `AS` followed by digits, or is all digits → [`QueryType::Autnum`]
    /// 2. Contains `:` or is all digits/dots → [`QueryType::Ip`]
    /// 3. Contains `-`, no dots, ends with a known RIR tag → [`QueryType::Entity`]
    /// 4. Otherwise → [`QueryType::Domain`]
    pub fn detect_type(query: &str) -> Result<QueryType> {
        // Check for AS number
        if query.to_uppercase().starts_with("AS") && query[2..].chars().all(|c| c.is_ascii_digit())
        {
            return Ok(QueryType::Autnum);
        }

        // Check for pure number (AS number without AS prefix)
        if query.chars().all(|c| c.is_ascii_digit()) {
            return Ok(QueryType::Autnum);
        }

        // Check for IP address (simple heuristic)
        if query.contains(':') || query.chars().all(|c| c.is_ascii_digit() || c == '.') {
            return Ok(QueryType::Ip);
        }

        // Check for entity handle with known object tag suffix (RFC 8521)
        // e.g. ORG-LA1994-RIPE, YANGY12-ARIN, APNIC-HM, etc.
        if is_entity_handle(query) {
            return Ok(QueryType::Entity);
        }

        // Default to domain
        Ok(QueryType::Domain)
    }
}

/// Check if a query string looks like an entity handle with a known object tag
///
/// Entity handles are uppercase alphanumeric identifiers with hyphens,
/// ending with a known IANA-registered service provider tag (RFC 8521).
/// Examples: ORG-LA1994-RIPE, YANGY12-ARIN, APNIC-HM, IRT-APNIC-AP
fn is_entity_handle(query: &str) -> bool {
    // Must contain at least one hyphen
    if !query.contains('-') {
        return false;
    }

    // Must not look like a domain name (no dots)
    if query.contains('.') {
        return false;
    }

    // Extract the tag after the last hyphen
    if let Some(tag) = extract_entity_tag(query) {
        // Check against known IANA-registered tags
        return KNOWN_ENTITY_TAGS
            .iter()
            .any(|&known| known.eq_ignore_ascii_case(&tag));
    }

    false
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_detect_type() {
        assert_eq!(
            RdapRequest::detect_type("example.com").unwrap(),
            QueryType::Domain
        );
        assert_eq!(
            RdapRequest::detect_type("192.0.2.1").unwrap(),
            QueryType::Ip
        );
        assert_eq!(
            RdapRequest::detect_type("2001:db8::1").unwrap(),
            QueryType::Ip
        );
        assert_eq!(
            RdapRequest::detect_type("AS15169").unwrap(),
            QueryType::Autnum
        );
        assert_eq!(
            RdapRequest::detect_type("15169").unwrap(),
            QueryType::Autnum
        );
    }

    #[test]
    fn test_detect_entity_ripe() {
        assert_eq!(
            RdapRequest::detect_type("ORG-LA1994-RIPE").unwrap(),
            QueryType::Entity
        );
    }

    #[test]
    fn test_detect_entity_arin() {
        assert_eq!(
            RdapRequest::detect_type("YANGY12-ARIN").unwrap(),
            QueryType::Entity
        );
    }

    #[test]
    fn test_detect_entity_apnic() {
        assert_eq!(
            RdapRequest::detect_type("IRT-APNIC-AP-APNIC").unwrap(),
            QueryType::Entity
        );
    }

    #[test]
    fn test_detect_entity_lacnic() {
        assert_eq!(
            RdapRequest::detect_type("IB1234-LACNIC").unwrap(),
            QueryType::Entity
        );
    }

    #[test]
    fn test_detect_entity_lowercase() {
        assert_eq!(
            RdapRequest::detect_type("org-la1994-ripe").unwrap(),
            QueryType::Entity
        );
    }

    #[test]
    fn test_detect_domain_not_entity() {
        // Domain names with dots should not be detected as entity
        assert_eq!(
            RdapRequest::detect_type("example-ripe.com").unwrap(),
            QueryType::Domain
        );
    }

    #[test]
    fn test_detect_unknown_tag_is_domain() {
        // Handle with unknown tag suffix defaults to domain
        assert_eq!(
            RdapRequest::detect_type("HANDLE-UNKNOWN").unwrap(),
            QueryType::Domain
        );
    }

    #[test]
    fn test_is_entity_handle() {
        assert!(is_entity_handle("ORG-LA1994-RIPE"));
        assert!(is_entity_handle("YANGY12-ARIN"));
        assert!(is_entity_handle("TEST-APNIC"));
        assert!(is_entity_handle("IB1234-LACNIC"));
        assert!(is_entity_handle("AS1234-AFRINIC"));
        assert!(is_entity_handle("X-FRNIC"));
        assert!(is_entity_handle("TEST-GLAUCA"));
        assert!(is_entity_handle("NORID-HDL-NORID"));

        assert!(!is_entity_handle("example.com"));
        assert!(!is_entity_handle("SOMEHANDLE"));
        assert!(!is_entity_handle("192.168.1.1"));
        assert!(!is_entity_handle("HANDLE-UNKNOWN"));
    }
}