use crate::error::DomainCheckError;
use crate::protocols::registry::{extract_tld, get_rdap_endpoint};
use crate::types::{CheckMethod, DomainInfo, DomainResult};
use reqwest::StatusCode;
use std::time::{Duration, Instant};
#[derive(Clone)]
pub struct RdapClient {
http_client: reqwest::Client,
timeout: Duration,
use_bootstrap: bool,
}
impl RdapClient {
pub fn new() -> Result<Self, DomainCheckError> {
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.map_err(|e| {
DomainCheckError::network_with_source(
"Failed to create RDAP HTTP client",
e.to_string(),
)
})?;
Ok(Self {
http_client,
timeout: Duration::from_secs(3),
use_bootstrap: false,
})
}
pub fn with_config(timeout: Duration, use_bootstrap: bool) -> Result<Self, DomainCheckError> {
let http_client = reqwest::Client::builder()
.timeout(timeout + Duration::from_secs(2)) .build()
.map_err(|e| {
DomainCheckError::network_with_source(
"Failed to create RDAP HTTP client",
e.to_string(),
)
})?;
Ok(Self {
http_client,
timeout,
use_bootstrap,
})
}
pub async fn check_domain(&self, domain: &str) -> Result<DomainResult, DomainCheckError> {
let start_time = Instant::now();
let tld = extract_tld(domain)?;
let endpoint = get_rdap_endpoint(&tld, self.use_bootstrap).await?;
let rdap_url = format!("{}{}", endpoint, domain);
if std::env::var("DOMAIN_CHECK_DEBUG_RDAP").is_ok() {
println!("🔍 Attempting RDAP request to: {}", rdap_url);
}
let result =
tokio::time::timeout(self.timeout, self.make_rdap_request(&rdap_url, domain)).await;
let check_duration = start_time.elapsed();
match result {
Ok(Ok((available, info))) => Ok(DomainResult {
domain: domain.to_string(),
available: Some(available),
info,
check_duration: Some(check_duration),
method_used: if self.use_bootstrap {
CheckMethod::Bootstrap
} else {
CheckMethod::Rdap
},
error_message: None,
}),
Ok(Err(e)) => {
if std::env::var("DOMAIN_CHECK_DEBUG_RDAP").is_ok() {
println!("🔍 RDAP Error for {}: {}", domain, e);
}
Err(e)
}
Err(_) => {
if std::env::var("DOMAIN_CHECK_DEBUG_RDAP").is_ok() {
println!("🔍 RDAP Timeout for {} after {:?}", domain, self.timeout);
}
Err(DomainCheckError::timeout("RDAP request", self.timeout))
}
}
}
async fn make_rdap_request(
&self,
rdap_url: &str,
domain: &str,
) -> Result<(bool, Option<DomainInfo>), DomainCheckError> {
let response = self.http_client.get(rdap_url).send().await.map_err(|e| {
if std::env::var("DOMAIN_CHECK_DEBUG_RDAP").is_ok() {
println!("🔍 HTTP Request failed for {}: {}", rdap_url, e);
if e.is_timeout() {
println!(" └─ Timeout error");
} else if e.is_connect() {
println!(" └─ Connection error");
} else if e.is_request() {
println!(" └─ Request error");
}
}
DomainCheckError::rdap(domain, format!("Request failed: {}", e))
})?;
if std::env::var("DOMAIN_CHECK_DEBUG_RDAP").is_ok() {
println!("🔍 HTTP Response for {}: {}", domain, response.status());
}
match response.status() {
StatusCode::OK => {
let json = response.json::<serde_json::Value>().await.map_err(|e| {
DomainCheckError::rdap(domain, format!("Failed to parse JSON: {}", e))
})?;
if std::env::var("DOMAIN_CHECK_DEBUG_RDAP").is_ok() {
println!("🔍 RDAP Response for {}:", domain);
println!(
"{}",
serde_json::to_string_pretty(&json).unwrap_or_default()
);
println!("--- End RDAP Response ---\n");
}
let domain_info = extract_domain_info(&json);
if std::env::var("DOMAIN_CHECK_DEBUG_RDAP").is_ok() {
println!("🔍 Extracted Info for {}:", domain);
println!(" Registrar: {:?}", domain_info.registrar);
println!(" Created: {:?}", domain_info.creation_date);
println!(" Expires: {:?}", domain_info.expiration_date);
println!(" Status: {:?}", domain_info.status);
println!("--- End Extracted Info ---\n");
}
Ok((false, Some(domain_info)))
}
StatusCode::NOT_FOUND => {
if std::env::var("DOMAIN_CHECK_DEBUG_RDAP").is_ok() {
println!(
"🔍 RDAP 404 for {} — deferring to WHOIS for verification",
domain
);
}
Err(DomainCheckError::rdap_with_status(
domain,
"RDAP returned 404 (domain may or may not be registered)",
404,
))
}
StatusCode::TOO_MANY_REQUESTS => {
if std::env::var("DOMAIN_CHECK_DEBUG_RDAP").is_ok() {
println!("🔍 Rate limited for {}, retrying after 500ms...", domain);
}
tokio::time::sleep(Duration::from_millis(500)).await;
let retry_response = self.http_client.get(rdap_url).send().await.map_err(|e| {
DomainCheckError::rdap(domain, format!("Retry request failed: {}", e))
})?;
match retry_response.status() {
StatusCode::OK => {
let json =
retry_response
.json::<serde_json::Value>()
.await
.map_err(|e| {
DomainCheckError::rdap(
domain,
format!("Failed to parse retry JSON: {}", e),
)
})?;
let domain_info = extract_domain_info(&json);
Ok((false, Some(domain_info)))
}
StatusCode::NOT_FOUND => Err(DomainCheckError::rdap_with_status(
domain,
"RDAP returned 404 after retry (domain may or may not be registered)",
404,
)),
code => {
if std::env::var("DOMAIN_CHECK_DEBUG_RDAP").is_ok() {
println!("🔍 Retry failed for {} with status: {}", domain, code);
}
Err(DomainCheckError::rdap_with_status(
domain,
format!("RDAP server error after retry: {}", code),
code.as_u16(),
))
}
}
}
code => {
if std::env::var("DOMAIN_CHECK_DEBUG_RDAP").is_ok() {
println!("🔍 RDAP server error for {} with status: {}", domain, code);
}
Err(DomainCheckError::rdap_with_status(
domain,
format!("RDAP server returned error: {}", code),
code.as_u16(),
))
}
}
}
}
impl Default for RdapClient {
fn default() -> Self {
Self::new().expect("Failed to create default RDAP client")
}
}
pub fn extract_domain_info(json: &serde_json::Value) -> DomainInfo {
let mut info = DomainInfo::default();
if let Some(entities) = json.get("entities").and_then(|e| e.as_array()) {
for entity in entities {
if let Some(roles) = entity.get("roles").and_then(|r| r.as_array()) {
let is_registrar = roles.iter().any(|role| role.as_str() == Some("registrar"));
if is_registrar {
if let Some(name) = extract_vcard_name(entity) {
info.registrar = Some(name);
break;
}
else if let Some(name) = extract_entity_identifier(entity) {
info.registrar = Some(name);
break;
}
}
}
}
}
if let Some(events) = json.get("events").and_then(|e| e.as_array()) {
for event in events {
if let (Some(event_action), Some(event_date)) = (
event.get("eventAction").and_then(|a| a.as_str()),
event.get("eventDate").and_then(|d| d.as_str()),
) {
match event_action {
"registration" => info.creation_date = Some(event_date.to_string()),
"expiration" => info.expiration_date = Some(event_date.to_string()),
"last update of RDAP database" | "last changed" => {
info.updated_date = Some(event_date.to_string())
}
_ => {}
}
}
}
}
if let Some(statuses) = json.get("status").and_then(|s| s.as_array()) {
for status in statuses {
if let Some(status_str) = status.as_str() {
info.status.push(status_str.to_string());
}
}
}
if let Some(nameservers) = json.get("nameservers").and_then(|ns| ns.as_array()) {
for nameserver in nameservers {
if let Some(ldh_name) = nameserver.get("ldhName").and_then(|name| name.as_str()) {
info.nameservers.push(ldh_name.to_string());
}
}
}
info
}
fn extract_vcard_name(entity: &serde_json::Value) -> Option<String> {
entity
.get("vcardArray")
.and_then(|v| v.as_array())
.and_then(|a| a.get(1))
.and_then(|a| a.as_array())
.and_then(|items| {
for item in items {
if let Some(item_array) = item.as_array() {
if item_array.len() >= 4 {
if let Some(first) = item_array.first().and_then(|f| f.as_str()) {
if first == "fn" {
return item_array
.get(3)
.and_then(|n| n.as_str())
.map(String::from);
}
}
}
}
}
None
})
}
fn extract_entity_identifier(entity: &serde_json::Value) -> Option<String> {
if let Some(public_ids) = entity.get("publicIds").and_then(|p| p.as_array()) {
if let Some(id) = public_ids
.first()
.and_then(|id| id.get("identifier"))
.and_then(|i| i.as_str())
{
return Some(id.to_string());
}
}
if let Some(handle) = entity.get("handle").and_then(|h| h.as_str()) {
return Some(handle.to_string());
}
entity
.get("name")
.and_then(|n| n.as_str())
.map(String::from)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_rdap_client_new() {
let client = RdapClient::new();
assert!(client.is_ok());
let client = client.unwrap();
assert_eq!(client.timeout, Duration::from_secs(3));
assert!(!client.use_bootstrap);
}
#[tokio::test]
async fn test_rdap_client_with_config() {
let client = RdapClient::with_config(Duration::from_secs(10), true).unwrap();
assert_eq!(client.timeout, Duration::from_secs(10));
assert!(client.use_bootstrap);
}
#[test]
fn test_rdap_client_default() {
let client = RdapClient::default();
assert_eq!(client.timeout, Duration::from_secs(3));
}
#[test]
fn test_extract_domain_info_dates_and_status() {
let json = serde_json::json!({
"events": [
{"eventAction": "registration", "eventDate": "1995-08-14T04:00:00Z"},
{"eventAction": "expiration", "eventDate": "2025-08-13T04:00:00Z"}
],
"status": ["client delete prohibited", "client transfer prohibited"]
});
let info = extract_domain_info(&json);
assert_eq!(info.creation_date, Some("1995-08-14T04:00:00Z".to_string()));
assert_eq!(
info.expiration_date,
Some("2025-08-13T04:00:00Z".to_string())
);
assert_eq!(info.status.len(), 2);
assert!(info
.status
.contains(&"client delete prohibited".to_string()));
}
#[test]
fn test_extract_domain_info_updated_date() {
let json = serde_json::json!({
"events": [
{"eventAction": "last changed", "eventDate": "2024-01-01T00:00:00Z"}
]
});
let info = extract_domain_info(&json);
assert_eq!(info.updated_date, Some("2024-01-01T00:00:00Z".to_string()));
}
#[test]
fn test_extract_domain_info_rdap_database_update() {
let json = serde_json::json!({
"events": [
{"eventAction": "last update of RDAP database", "eventDate": "2024-06-15T00:00:00Z"}
]
});
let info = extract_domain_info(&json);
assert_eq!(info.updated_date, Some("2024-06-15T00:00:00Z".to_string()));
}
#[test]
fn test_extract_domain_info_unknown_event_ignored() {
let json = serde_json::json!({
"events": [
{"eventAction": "transfer", "eventDate": "2024-01-01T00:00:00Z"}
]
});
let info = extract_domain_info(&json);
assert!(info.creation_date.is_none());
assert!(info.expiration_date.is_none());
assert!(info.updated_date.is_none());
}
#[test]
fn test_extract_domain_info_nameservers() {
let json = serde_json::json!({
"nameservers": [
{"ldhName": "ns1.example.com"},
{"ldhName": "ns2.example.com"}
]
});
let info = extract_domain_info(&json);
assert_eq!(info.nameservers.len(), 2);
assert!(info.nameservers.contains(&"ns1.example.com".to_string()));
assert!(info.nameservers.contains(&"ns2.example.com".to_string()));
}
#[test]
fn test_extract_domain_info_registrar_from_vcard() {
let json = serde_json::json!({
"entities": [{
"roles": ["registrar"],
"vcardArray": ["vcard", [
["fn", {}, "text", "GoDaddy LLC"]
]]
}]
});
let info = extract_domain_info(&json);
assert_eq!(info.registrar, Some("GoDaddy LLC".to_string()));
}
#[test]
fn test_extract_domain_info_registrar_from_public_id() {
let json = serde_json::json!({
"entities": [{
"roles": ["registrar"],
"publicIds": [{"identifier": "292", "type": "IANA Registrar ID"}]
}]
});
let info = extract_domain_info(&json);
assert_eq!(info.registrar, Some("292".to_string()));
}
#[test]
fn test_extract_domain_info_registrar_from_handle() {
let json = serde_json::json!({
"entities": [{
"roles": ["registrar"],
"handle": "REG-123"
}]
});
let info = extract_domain_info(&json);
assert_eq!(info.registrar, Some("REG-123".to_string()));
}
#[test]
fn test_extract_domain_info_non_registrar_entity_skipped() {
let json = serde_json::json!({
"entities": [{
"roles": ["technical"],
"vcardArray": ["vcard", [["fn", {}, "text", "Tech Contact"]]]
}]
});
let info = extract_domain_info(&json);
assert!(info.registrar.is_none());
}
#[test]
fn test_extract_domain_info_empty_json() {
let json = serde_json::json!({});
let info = extract_domain_info(&json);
assert!(info.registrar.is_none());
assert!(info.creation_date.is_none());
assert!(info.expiration_date.is_none());
assert!(info.status.is_empty());
assert!(info.nameservers.is_empty());
}
#[test]
fn test_extract_domain_info_full_response() {
let json = serde_json::json!({
"entities": [{
"roles": ["registrar"],
"vcardArray": ["vcard", [["fn", {}, "text", "MarkMonitor Inc."]]]
}],
"events": [
{"eventAction": "registration", "eventDate": "1997-09-15T04:00:00Z"},
{"eventAction": "expiration", "eventDate": "2028-09-14T04:00:00Z"},
{"eventAction": "last changed", "eventDate": "2024-01-15T00:00:00Z"}
],
"status": ["client delete prohibited", "server transfer prohibited"],
"nameservers": [
{"ldhName": "ns1.google.com"},
{"ldhName": "ns2.google.com"},
{"ldhName": "ns3.google.com"}
]
});
let info = extract_domain_info(&json);
assert_eq!(info.registrar, Some("MarkMonitor Inc.".to_string()));
assert_eq!(info.creation_date, Some("1997-09-15T04:00:00Z".to_string()));
assert_eq!(
info.expiration_date,
Some("2028-09-14T04:00:00Z".to_string())
);
assert_eq!(info.updated_date, Some("2024-01-15T00:00:00Z".to_string()));
assert_eq!(info.status.len(), 2);
assert_eq!(info.nameservers.len(), 3);
}
#[test]
fn test_extract_vcard_name_standard() {
let entity = serde_json::json!({
"vcardArray": ["vcard", [["fn", {}, "text", "Example Registrar Inc."]]]
});
assert_eq!(
extract_vcard_name(&entity),
Some("Example Registrar Inc.".to_string())
);
}
#[test]
fn test_extract_vcard_name_no_fn_field() {
let entity = serde_json::json!({
"vcardArray": ["vcard", [["org", {}, "text", "Some Org"]]]
});
assert_eq!(extract_vcard_name(&entity), None);
}
#[test]
fn test_extract_vcard_name_no_vcard() {
let entity = serde_json::json!({"handle": "test"});
assert_eq!(extract_vcard_name(&entity), None);
}
#[test]
fn test_extract_vcard_name_empty_vcard_array() {
let entity = serde_json::json!({"vcardArray": ["vcard", []]});
assert_eq!(extract_vcard_name(&entity), None);
}
#[test]
fn test_extract_vcard_name_short_item_array() {
let entity = serde_json::json!({
"vcardArray": ["vcard", [["fn", {}]]]
});
assert_eq!(extract_vcard_name(&entity), None);
}
#[test]
fn test_extract_entity_identifier_public_id() {
let entity = serde_json::json!({
"publicIds": [{"identifier": "292", "type": "IANA Registrar ID"}]
});
assert_eq!(extract_entity_identifier(&entity), Some("292".to_string()));
}
#[test]
fn test_extract_entity_identifier_handle_fallback() {
let entity = serde_json::json!({"handle": "REG-123"});
assert_eq!(
extract_entity_identifier(&entity),
Some("REG-123".to_string())
);
}
#[test]
fn test_extract_entity_identifier_name_fallback() {
let entity = serde_json::json!({"name": "Some Registrar"});
assert_eq!(
extract_entity_identifier(&entity),
Some("Some Registrar".to_string())
);
}
#[test]
fn test_extract_entity_identifier_precedence() {
let entity = serde_json::json!({
"publicIds": [{"identifier": "292"}],
"handle": "REG-123",
"name": "Some Registrar"
});
assert_eq!(extract_entity_identifier(&entity), Some("292".to_string()));
}
#[test]
fn test_extract_entity_identifier_none() {
let entity = serde_json::json!({"roles": ["registrar"]});
assert_eq!(extract_entity_identifier(&entity), None);
}
#[test]
fn test_extract_entity_identifier_empty_public_ids() {
let entity = serde_json::json!({
"publicIds": [],
"handle": "FALLBACK"
});
assert_eq!(
extract_entity_identifier(&entity),
Some("FALLBACK".to_string())
);
}
}