Skip to main content

rdapify_client/
lib.rs

1//! High-level RDAP client with bootstrap, caching, and SSRF protection.
2//!
3//! # Feature flags
4//!
5//! | Feature        | Default | Description                              |
6//! |----------------|---------|------------------------------------------|
7//! | `memory-cache` | ✓       | In-memory response cache (DashMap)       |
8//! | `stream`       | ✓       | Async streaming query API (tokio-stream) |
9
10#![forbid(unsafe_code)]
11
12use std::collections::HashMap;
13use std::net::IpAddr;
14
15use idna::domain_to_ascii;
16
17use rdap_bootstrap::Bootstrap;
18use rdap_core::{Fetcher, FetcherConfig, Normalizer};
19use rdap_security::{SsrfConfig, SsrfGuard};
20use rdap_types::error::{RdapError, Result};
21use rdap_types::{
22    AsnResponse, AvailabilityResult, DomainResponse, EntityResponse, IpResponse, NameserverResponse,
23};
24
25#[cfg(feature = "memory-cache")]
26use rdap_cache::MemoryCache;
27
28#[cfg(feature = "stream")]
29use tokio::sync::mpsc;
30#[cfg(feature = "stream")]
31use tokio_stream::wrappers::ReceiverStream;
32
33#[cfg(feature = "stream")]
34pub use rdap_stream::{AsnEvent, DomainEvent, IpEvent, NameserverEvent, StreamConfig};
35
36// ── Client configuration ──────────────────────────────────────────────────────
37
38/// Configuration for [`RdapClient`].
39#[derive(Debug, Clone)]
40pub struct ClientConfig {
41    /// HTTP fetcher settings (timeout, retries, user-agent, validation limits).
42    pub fetcher: FetcherConfig,
43    /// SSRF protection settings.
44    pub ssrf: SsrfConfig,
45    /// Whether to cache query responses in memory.
46    ///
47    /// Has no effect when the `memory-cache` feature is disabled.
48    pub cache: bool,
49    /// Bootstrap base URL (defaults to the official IANA endpoint).
50    pub bootstrap_url: Option<String>,
51    /// Custom RDAP server overrides per TLD.
52    pub custom_bootstrap_servers: HashMap<String, String>,
53    /// Reuse TCP connections across requests.
54    pub reuse_connections: bool,
55    /// Maximum number of idle keep-alive connections per host.
56    pub max_connections_per_host: usize,
57}
58
59impl Default for ClientConfig {
60    fn default() -> Self {
61        Self {
62            fetcher: FetcherConfig::default(),
63            ssrf: SsrfConfig::default(),
64            cache: true,
65            bootstrap_url: None,
66            custom_bootstrap_servers: HashMap::new(),
67            reuse_connections: true,
68            max_connections_per_host: 10,
69        }
70    }
71}
72
73// ── Client ────────────────────────────────────────────────────────────────────
74
75/// The main RDAP client.
76///
77/// Cheap to clone — all inner state is behind `Arc`s.
78#[derive(Clone, Debug)]
79pub struct RdapClient {
80    fetcher: Fetcher,
81    bootstrap: Bootstrap,
82    normalizer: Normalizer,
83    #[cfg(feature = "memory-cache")]
84    cache: Option<MemoryCache>,
85}
86
87impl RdapClient {
88    /// Creates a client with the default configuration.
89    pub fn new() -> Result<Self> {
90        Self::with_config(ClientConfig::default())
91    }
92
93    /// Creates a client with custom configuration.
94    pub fn with_config(config: ClientConfig) -> Result<Self> {
95        let ssrf = SsrfGuard::with_config(config.ssrf);
96        let mut fetcher_config = config.fetcher;
97        fetcher_config.reuse_connections = config.reuse_connections;
98        fetcher_config.max_connections_per_host = config.max_connections_per_host;
99        let fetcher = Fetcher::with_config(ssrf, fetcher_config)?;
100        let reqwest_client = fetcher.reqwest_client();
101
102        let mut bootstrap = match config.bootstrap_url {
103            Some(url) => Bootstrap::with_base_url(url, reqwest_client),
104            None => Bootstrap::new(reqwest_client),
105        };
106
107        if !config.custom_bootstrap_servers.is_empty() {
108            bootstrap.set_custom_servers(config.custom_bootstrap_servers);
109        }
110
111        #[cfg(feature = "memory-cache")]
112        let cache = if config.cache {
113            Some(MemoryCache::new())
114        } else {
115            None
116        };
117
118        Ok(Self {
119            fetcher,
120            bootstrap,
121            normalizer: Normalizer::new(),
122            #[cfg(feature = "memory-cache")]
123            cache,
124        })
125    }
126
127    // ── Query methods ─────────────────────────────────────────────────────────
128
129    /// Queries RDAP information for a domain name.
130    pub async fn domain(&self, domain: &str) -> Result<DomainResponse> {
131        let domain = normalise_domain(domain)?;
132        let server = self.bootstrap.for_domain(&domain).await?;
133        let url = format!("{}/domain/{}", server.trim_end_matches('/'), domain);
134        let (raw, cached) = self.fetch_with_cache(&url).await?;
135        self.normalizer.domain(&domain, raw, &server, cached)
136    }
137
138    /// Queries RDAP information for an IP address (IPv4 or IPv6).
139    pub async fn ip(&self, ip: &str) -> Result<IpResponse> {
140        let addr: IpAddr = ip
141            .parse()
142            .map_err(|_| RdapError::InvalidInput(format!("Invalid IP address: {ip}")))?;
143
144        let server = match addr {
145            IpAddr::V4(_) => self.bootstrap.for_ipv4(ip).await?,
146            IpAddr::V6(_) => self.bootstrap.for_ipv6(ip).await?,
147        };
148
149        let url = format!("{}/ip/{}", server.trim_end_matches('/'), ip);
150        let (raw, cached) = self.fetch_with_cache(&url).await?;
151        self.normalizer.ip(ip, raw, &server, cached)
152    }
153
154    /// Queries RDAP information for an Autonomous System Number.
155    pub async fn asn(&self, asn: impl AsRef<str>) -> Result<AsnResponse> {
156        let asn_str = asn
157            .as_ref()
158            .trim_start_matches("AS")
159            .trim_start_matches("as");
160        let asn_num: u32 = asn_str
161            .parse()
162            .map_err(|_| RdapError::InvalidInput(format!("Invalid ASN: {}", asn.as_ref())))?;
163
164        let server = self.bootstrap.for_asn(asn_num).await?;
165        let url = format!("{}/autnum/{}", server.trim_end_matches('/'), asn_num);
166        let (raw, cached) = self.fetch_with_cache(&url).await?;
167        self.normalizer.asn(asn_num, raw, &server, cached)
168    }
169
170    /// Queries RDAP information for a nameserver.
171    pub async fn nameserver(&self, hostname: &str) -> Result<NameserverResponse> {
172        let hostname = normalise_domain(hostname)?;
173        let server = self.bootstrap.for_domain(&hostname).await?;
174        let url = format!("{}/nameserver/{}", server.trim_end_matches('/'), hostname);
175        let (raw, cached) = self.fetch_with_cache(&url).await?;
176        self.normalizer.nameserver(&hostname, raw, &server, cached)
177    }
178
179    /// Queries RDAP information for an entity (contact / registrar).
180    pub async fn entity(&self, handle: &str, server_url: &str) -> Result<EntityResponse> {
181        if handle.is_empty() {
182            return Err(RdapError::InvalidInput(
183                "Entity handle must not be empty".to_string(),
184            ));
185        }
186        if server_url.is_empty() {
187            return Err(RdapError::InvalidInput(
188                "Server URL must not be empty".to_string(),
189            ));
190        }
191
192        let url = format!("{}/entity/{}", server_url.trim_end_matches('/'), handle);
193        let (raw, cached) = self.fetch_with_cache(&url).await?;
194        self.normalizer.entity(handle, raw, server_url, cached)
195    }
196
197    /// Checks whether a domain is available for registration.
198    pub async fn domain_available(&self, name: &str) -> Result<AvailabilityResult> {
199        let domain_name = normalise_domain(name)?;
200        match self.domain(name).await {
201            Ok(response) => Ok(AvailabilityResult {
202                domain: domain_name,
203                available: false,
204                expires_at: response.expiration_date().map(|s| s.to_string()),
205            }),
206            Err(RdapError::HttpStatus { status: 404, .. }) => Ok(AvailabilityResult {
207                domain: domain_name,
208                available: true,
209                expires_at: None,
210            }),
211            Err(e) => Err(e),
212        }
213    }
214
215    /// Checks availability for multiple domains concurrently.
216    pub async fn domain_available_batch(
217        &self,
218        names: Vec<String>,
219        concurrency: Option<usize>,
220    ) -> Vec<Result<AvailabilityResult>> {
221        let limit = concurrency.unwrap_or(10).max(1);
222        let mut output: Vec<Option<Result<AvailabilityResult>>> =
223            (0..names.len()).map(|_| None).collect();
224
225        for (chunk_start, chunk) in names.chunks(limit).enumerate() {
226            let base = chunk_start * limit;
227            let mut set = tokio::task::JoinSet::new();
228
229            for (i, name) in chunk.iter().enumerate() {
230                let client = self.clone();
231                let name = name.clone();
232                let idx = base + i;
233                set.spawn(async move { (idx, client.domain_available(&name).await) });
234            }
235
236            while let Some(res) = set.join_next().await {
237                if let Ok((idx, result)) = res {
238                    output[idx] = Some(result);
239                }
240            }
241        }
242
243        output.into_iter().flatten().collect()
244    }
245
246    // ── Streaming API (requires `stream` feature) ─────────────────────────────
247
248    #[cfg(feature = "stream")]
249    pub fn stream_domain(
250        &self,
251        names: Vec<String>,
252        config: StreamConfig,
253    ) -> ReceiverStream<DomainEvent> {
254        let (tx, rx) = mpsc::channel(config.buffer_size);
255        let client = self.clone();
256
257        tokio::spawn(async move {
258            for name in names {
259                let event = match client.domain(&name).await {
260                    Ok(r) => DomainEvent::Result(Box::new(r)),
261                    Err(e) => DomainEvent::Error {
262                        query: name,
263                        error: e,
264                    },
265                };
266                if tx.send(event).await.is_err() {
267                    break;
268                }
269            }
270        });
271
272        ReceiverStream::new(rx)
273    }
274
275    #[cfg(feature = "stream")]
276    pub fn stream_ip(
277        &self,
278        addresses: Vec<String>,
279        config: StreamConfig,
280    ) -> ReceiverStream<IpEvent> {
281        let (tx, rx) = mpsc::channel(config.buffer_size);
282        let client = self.clone();
283
284        tokio::spawn(async move {
285            for addr in addresses {
286                let event = match client.ip(&addr).await {
287                    Ok(r) => IpEvent::Result(Box::new(r)),
288                    Err(e) => IpEvent::Error {
289                        query: addr,
290                        error: e,
291                    },
292                };
293                if tx.send(event).await.is_err() {
294                    break;
295                }
296            }
297        });
298
299        ReceiverStream::new(rx)
300    }
301
302    #[cfg(feature = "stream")]
303    pub fn stream_asn(&self, asns: Vec<String>, config: StreamConfig) -> ReceiverStream<AsnEvent> {
304        let (tx, rx) = mpsc::channel(config.buffer_size);
305        let client = self.clone();
306
307        tokio::spawn(async move {
308            for asn in asns {
309                let event = match client.asn(&asn).await {
310                    Ok(r) => AsnEvent::Result(Box::new(r)),
311                    Err(e) => AsnEvent::Error {
312                        query: asn,
313                        error: e,
314                    },
315                };
316                if tx.send(event).await.is_err() {
317                    break;
318                }
319            }
320        });
321
322        ReceiverStream::new(rx)
323    }
324
325    #[cfg(feature = "stream")]
326    pub fn stream_nameserver(
327        &self,
328        nameservers: Vec<String>,
329        config: StreamConfig,
330    ) -> ReceiverStream<NameserverEvent> {
331        let (tx, rx) = mpsc::channel(config.buffer_size);
332        let client = self.clone();
333
334        tokio::spawn(async move {
335            for ns in nameservers {
336                let event = match client.nameserver(&ns).await {
337                    Ok(r) => NameserverEvent::Result(Box::new(r)),
338                    Err(e) => NameserverEvent::Error {
339                        query: ns,
340                        error: e,
341                    },
342                };
343                if tx.send(event).await.is_err() {
344                    break;
345                }
346            }
347        });
348
349        ReceiverStream::new(rx)
350    }
351
352    // ── Cache management ──────────────────────────────────────────────────────
353
354    /// Clears the response cache and bootstrap cache.
355    pub async fn clear_cache(&self) {
356        #[cfg(feature = "memory-cache")]
357        if let Some(cache) = &self.cache {
358            cache.clear();
359        }
360        self.bootstrap.clear_cache().await;
361    }
362
363    /// Returns the number of entries in the response cache.
364    ///
365    /// Returns 0 when the `memory-cache` feature is disabled.
366    pub fn cache_size(&self) -> usize {
367        #[cfg(feature = "memory-cache")]
368        {
369            self.cache.as_ref().map(|c| c.len()).unwrap_or(0)
370        }
371        #[cfg(not(feature = "memory-cache"))]
372        {
373            0
374        }
375    }
376
377    // ── Private helpers ───────────────────────────────────────────────────────
378
379    async fn fetch_with_cache(&self, url: &str) -> Result<(serde_json::Value, bool)> {
380        #[cfg(feature = "memory-cache")]
381        if let Some(cache) = &self.cache {
382            if let Some(cached) = cache.get(url) {
383                return Ok((cached, true));
384            }
385        }
386
387        let value = self.fetcher.fetch(url).await?;
388
389        #[cfg(feature = "memory-cache")]
390        if let Some(cache) = &self.cache {
391            cache.set(url.to_string(), value.clone());
392        }
393
394        Ok((value, false))
395    }
396}
397
398impl Default for RdapClient {
399    fn default() -> Self {
400        Self::new().expect("Default RdapClient construction failed")
401    }
402}
403
404// ── Domain normalisation ──────────────────────────────────────────────────────
405
406fn normalise_domain(domain: &str) -> Result<String> {
407    let domain = domain.trim().trim_end_matches('.').to_lowercase();
408
409    if domain.is_empty() {
410        return Err(RdapError::InvalidInput(
411            "Domain name must not be empty".to_string(),
412        ));
413    }
414
415    if domain.is_ascii() {
416        return Ok(domain);
417    }
418
419    domain_to_ascii(&domain).map_err(|_| {
420        RdapError::InvalidInput(format!("Invalid internationalised domain name: {domain}"))
421    })
422}