use std::time::Instant;
use crate::error::{Error, Refusal, Result};
use crate::registry::Endpoint;
use crate::transport::{Query, RawResponse, ResponseKind, TransportConfig};
const USER_AGENT: &str = concat!(
"monovm-whois/",
env!("CARGO_PKG_VERSION"),
" (+https://github.com/monovm/whois-rs)"
);
const RDAP_MEDIA_TYPE: &str = "application/rdap+json, application/json;q=0.9, */*;q=0.1";
fn refusal_for(status: u16) -> Option<Refusal> {
match status {
429 => Some(Refusal::RateLimited),
401 | 403 => Some(Refusal::AccessRestricted),
451 => Some(Refusal::Blocked),
503 => Some(Refusal::Unavailable),
_ => None,
}
}
fn is_answer(status: u16) -> bool {
matches!(status, 200..=299 | 404 | 422)
}
#[cfg(all(feature = "rdap", feature = "blocking"))]
#[derive(Debug, Clone)]
pub struct RdapTransport {
client: reqwest::blocking::Client,
config: TransportConfig,
}
#[cfg(all(feature = "rdap", feature = "blocking"))]
impl RdapTransport {
pub fn new() -> Result<Self> {
RdapTransport::with_config(TransportConfig::default())
}
pub fn with_config(config: TransportConfig) -> Result<Self> {
let client = build_blocking_client(&config)?;
Ok(RdapTransport { client, config })
}
pub fn with_client(client: reqwest::blocking::Client, config: TransportConfig) -> Self {
RdapTransport { client, config }
}
pub fn config(&self) -> &TransportConfig {
&self.config
}
}
#[cfg(all(feature = "rdap", feature = "blocking"))]
impl crate::transport::Transport for RdapTransport {
fn supports(&self, endpoint: &Endpoint) -> bool {
endpoint.is_rdap()
}
fn fetch(&self, query: &Query) -> Result<RawResponse> {
let Endpoint::Rdap(endpoint) = &query.endpoint else {
return Err(Error::Definitions(format!(
"{} is not an RDAP endpoint",
query.endpoint
)));
};
let url = endpoint.query_url(&query.wire_name);
let started = Instant::now();
let response = self
.client
.get(&url)
.header(reqwest::header::ACCEPT, RDAP_MEDIA_TYPE)
.send()
.map_err(|error| classify_reqwest(&url, error, started))?;
let status = response.status().as_u16();
if let Some(reason) = refusal_for(status) {
return Err(Error::Refused {
server: url,
reason,
});
}
if !is_answer(status) {
return Err(Error::Http { url, status });
}
let body = response.text().map_err(|error| Error::Io {
server: url.clone(),
source: std::io::Error::other(error),
})?;
finish(query, body, status, started, &url)
}
fn name(&self) -> String {
"rdap".to_string()
}
}
#[cfg(all(feature = "rdap", feature = "async"))]
#[derive(Debug, Clone)]
pub struct AsyncRdapTransport {
client: reqwest::Client,
config: TransportConfig,
}
#[cfg(all(feature = "rdap", feature = "async"))]
impl AsyncRdapTransport {
pub fn new() -> Result<Self> {
AsyncRdapTransport::with_config(TransportConfig::default())
}
pub fn with_config(config: TransportConfig) -> Result<Self> {
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.connect_timeout(config.connect_timeout)
.timeout(config.connect_timeout + config.read_timeout)
.redirect(reqwest::redirect::Policy::limited(5))
.build()
.map_err(|error| Error::Definitions(format!("could not build HTTP client: {error}")))?;
Ok(AsyncRdapTransport { client, config })
}
pub fn with_client(client: reqwest::Client, config: TransportConfig) -> Self {
AsyncRdapTransport { client, config }
}
pub fn config(&self) -> &TransportConfig {
&self.config
}
async fn fetch_inner(&self, query: &Query) -> Result<RawResponse> {
let Endpoint::Rdap(endpoint) = &query.endpoint else {
return Err(Error::Definitions(format!(
"{} is not an RDAP endpoint",
query.endpoint
)));
};
let url = endpoint.query_url(&query.wire_name);
let started = Instant::now();
let response = self
.client
.get(&url)
.header(reqwest::header::ACCEPT, RDAP_MEDIA_TYPE)
.send()
.await
.map_err(|error| classify_reqwest(&url, error, started))?;
let status = response.status().as_u16();
if let Some(reason) = refusal_for(status) {
return Err(Error::Refused {
server: url,
reason,
});
}
if !is_answer(status) {
return Err(Error::Http { url, status });
}
let body = response.text().await.map_err(|error| Error::Io {
server: url.clone(),
source: std::io::Error::other(error),
})?;
finish(query, body, status, started, &url)
}
}
#[cfg(all(feature = "rdap", feature = "async"))]
impl crate::transport::AsyncTransport for AsyncRdapTransport {
fn supports(&self, endpoint: &Endpoint) -> bool {
endpoint.is_rdap()
}
fn fetch<'a>(
&'a self,
query: &'a Query,
) -> crate::transport::BoxFuture<'a, Result<RawResponse>> {
Box::pin(self.fetch_inner(query))
}
fn name(&self) -> String {
"async-rdap".to_string()
}
}
#[cfg(all(feature = "rdap", feature = "blocking"))]
fn build_blocking_client(config: &TransportConfig) -> Result<reqwest::blocking::Client> {
reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.connect_timeout(config.connect_timeout)
.timeout(config.connect_timeout + config.read_timeout)
.redirect(reqwest::redirect::Policy::limited(5))
.build()
.map_err(|error| Error::Definitions(format!("could not build HTTP client: {error}")))
}
#[cfg(feature = "rdap")]
fn classify_reqwest(url: &str, error: reqwest::Error, started: Instant) -> Error {
if error.is_timeout() {
return Error::Timeout {
server: url.to_string(),
elapsed: started.elapsed(),
};
}
if error.is_connect() {
return Error::Connect {
server: url.to_string(),
source: std::io::Error::other(error),
};
}
Error::Io {
server: url.to_string(),
source: std::io::Error::other(error),
}
}
#[cfg(feature = "rdap")]
fn finish(
query: &Query,
body: String,
status: u16,
started: Instant,
url: &str,
) -> Result<RawResponse> {
let text = if body.trim().is_empty() {
if status == 404 {
r#"{"errorCode":404,"title":"Not Found"}"#.to_string()
} else {
return Err(Error::EmptyResponse {
server: url.to_string(),
});
}
} else {
body
};
Ok(RawResponse::new(
query.endpoint.clone(),
ResponseKind::RdapJson,
text,
started.elapsed(),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refusals_are_separated_from_answers() {
assert_eq!(refusal_for(429), Some(Refusal::RateLimited));
assert_eq!(refusal_for(403), Some(Refusal::AccessRestricted));
assert_eq!(refusal_for(503), Some(Refusal::Unavailable));
assert_eq!(refusal_for(404), None);
assert_eq!(refusal_for(200), None);
}
#[test]
fn only_meaningful_statuses_count_as_answers() {
assert!(is_answer(200));
assert!(is_answer(404));
assert!(is_answer(422));
assert!(!is_answer(500));
assert!(!is_answer(429));
assert!(!is_answer(301));
}
#[test]
fn an_empty_404_still_produces_a_readable_body() {
let query = Query::new(
Endpoint::rdap("https://rdap.example/"),
"example.com",
crate::domain::Tld::parse("com").unwrap(),
);
let response = finish(&query, String::new(), 404, Instant::now(), "url").unwrap();
assert_eq!(response.kind(), ResponseKind::RdapJson);
assert!(response.text().contains("\"errorCode\":404"));
}
#[test]
fn an_empty_success_is_an_error() {
let query = Query::new(
Endpoint::rdap("https://rdap.example/"),
"example.com",
crate::domain::Tld::parse("com").unwrap(),
);
let error = finish(&query, " ".into(), 200, Instant::now(), "url").unwrap_err();
assert!(matches!(error, Error::EmptyResponse { .. }));
}
}