monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation
//! Which registry serves which suffix, and how to reach it.
//!
//! Everything here is data and pure logic — no sockets are opened in this module.
//! A [`RegistryProvider`] answers "who serves `.uk`?" and hands back a
//! [`Registry`] describing the endpoints to try and how that registry words its
//! answers; the [`transport`](crate::transport) layer does the talking.
//!
//! # Providers
//!
//! | Provider | Source | Gives you |
//! |---|---|---|
//! | [`JsonRegistry`] | bundled `registries.json`, or your own file | WHOIS hosts, availability markers, per-registry quirks |
//! | [`BootstrapRegistry`] | bundled IANA snapshot, or a live download | RDAP endpoints for ~1200 suffixes |
//! | [`ConventionRegistry`] | the `whois.nic.<tld>` naming rule | a guessed WHOIS host for the long tail |
//! | [`LayeredRegistry`] | any of the above, stacked | one provider from several |
//!
//! # The default stack
//!
//! [`default_provider`] is what a [`WhoisClient`](crate::WhoisClient) uses unless
//! told otherwise: the curated list under IANA's RDAP snapshot, unioned so a
//! registry that publishes both protocols offers both endpoints.
//!
//! ```
//! use monovm_whois::registry::{default_provider, RegistryProvider};
//! use monovm_whois::DomainName;
//!
//! let registry = default_provider();
//! let resolved = registry.resolve(&DomainName::parse("www.example.co.uk").unwrap()).unwrap();
//!
//! assert_eq!(resolved.tld.ascii(), "co.uk");
//! assert_eq!(resolved.registrable.as_ascii(), "example.co.uk");
//! ```

mod bootstrap;
mod convention;
mod definition;
mod json;
mod layered;
mod provider;

pub use bootstrap::{BootstrapRegistry, IANA_BOOTSTRAP_URL};
pub use convention::{ConventionRegistry, DEFAULT_TEMPLATE};
pub use definition::{
    Endpoint, EndpointKind, IdnForm, RdapEndpoint, Registry, RegistryBuilder, WhoisEndpoint,
    WHOIS_PORT,
};
pub use json::JsonRegistry;
pub use layered::{LayerStrategy, LayeredRegistry};
pub use provider::{RegistryProvider, Resolution};

use std::sync::{Arc, OnceLock};

/// The provider stack used when a client is not given one.
///
/// Curated definitions with IANA's RDAP endpoints unioned on top: the curated
/// layer contributes port 43 hosts and the availability wording of each server,
/// the IANA layer contributes RDAP coverage for the suffixes the curated list
/// never had. Built once and shared.
///
/// The conventional-host provider is deliberately absent — it guesses, and a
/// guess belongs behind an explicit opt-in:
///
/// ```
/// use monovm_whois::registry::{
///     ConventionRegistry, LayerStrategy, LayeredRegistry, default_provider,
/// };
///
/// let with_guesses = LayeredRegistry::new(LayerStrategy::Union)
///     .layer(ConventionRegistry::from_iana())
///     .shared_layer(default_provider());
/// ```
pub fn default_provider() -> Arc<dyn RegistryProvider> {
    static DEFAULT: OnceLock<Arc<LayeredRegistry>> = OnceLock::new();

    let stack = DEFAULT.get_or_init(|| {
        Arc::new(
            LayeredRegistry::new(LayerStrategy::Union)
                .shared_layer(JsonRegistry::bundled())
                .shared_layer(BootstrapRegistry::bundled()),
        )
    });

    Arc::clone(stack) as Arc<dyn RegistryProvider>
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::{DomainName, Tld};

    #[test]
    fn default_stack_offers_both_protocols_for_com() {
        let registry = default_provider();
        let com = registry.get(&Tld::parse("com").unwrap()).unwrap();

        assert!(com.endpoints().iter().any(Endpoint::is_whois));
        assert!(com.endpoints().iter().any(Endpoint::is_rdap));
        // Curated data supplies the wording and the thin-registry hint; IANA has
        // neither, and unioning must not lose them.
        assert!(!com.available_markers().is_empty());
        assert!(com.is_thin());
    }

    #[test]
    fn default_stack_covers_more_than_either_layer_alone() {
        let curated = JsonRegistry::bundled().tlds().len();
        let iana = BootstrapRegistry::bundled().tlds().len();
        let combined = default_provider().tlds().len();

        assert!(
            combined > curated.max(iana),
            "{combined} <= max({curated}, {iana})"
        );
    }

    #[test]
    fn default_stack_resolves_multi_label_suffixes() {
        let registry = default_provider();
        for (input, expected_tld, expected_name) in [
            ("example.co.uk", "co.uk", "example.co.uk"),
            ("www.example.co.uk", "co.uk", "example.co.uk"),
            ("example.com", "com", "example.com"),
            ("example.com.au", "com.au", "example.com.au"),
        ] {
            let name = DomainName::parse(input).unwrap();
            let resolved = registry.resolve(&name).unwrap();
            assert_eq!(resolved.tld.ascii(), expected_tld, "for {input}");
            assert_eq!(
                resolved.registrable.as_ascii(),
                expected_name,
                "for {input}"
            );
        }
    }

    #[test]
    fn default_stack_is_shared_not_reparsed() {
        // Cheap enough to call per lookup; a regression here would mean parsing
        // 60 kB of JSON on every query.
        let first = default_provider();
        let second = default_provider();
        assert_eq!(first.tlds().len(), second.tlds().len());
    }
}