use std::collections::BTreeMap;
use futures::future::BoxFuture;
#[derive(Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct HttpRequest {
pub method: &'static str,
pub url: String,
pub headers: BTreeMap<String, String>,
pub body: String,
}
const SECRET_HEADERS: [&str; 2] = ["authorization", "x-amz-security-token"];
impl std::fmt::Debug for HttpRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let headers: BTreeMap<&str, &str> = self
.headers
.iter()
.map(|(name, value)| {
let redact = SECRET_HEADERS.contains(&name.to_ascii_lowercase().as_str());
(
name.as_str(),
if redact { "<redacted>" } else { value.as_str() },
)
})
.collect();
f.debug_struct("HttpRequest")
.field("method", &self.method)
.field("url", &self.url)
.field("headers", &headers)
.field("body", &self.body)
.finish()
}
}
impl HttpRequest {
#[must_use]
pub fn new(method: &'static str, url: impl Into<String>) -> Self {
Self {
method,
url: url.into(),
headers: BTreeMap::new(),
body: String::new(),
}
}
#[must_use]
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(name.into(), value.into());
self
}
#[must_use]
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = body.into();
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct HttpResponse {
pub status: u16,
pub body: String,
}
impl HttpResponse {
#[must_use]
pub const fn is_success(&self) -> bool {
self.status >= 200 && self.status < 300
}
}
pub trait HttpTransport: Send + Sync {
fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, String>>;
}
pub struct ReqwestTransport {
client: reqwest::Client,
}
impl ReqwestTransport {
pub fn new(timeout: std::time::Duration) -> Result<Self, String> {
let client = reqwest::Client::builder()
.timeout(timeout)
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| format!("failed to build the DNS provider HTTP client: {e}"))?;
Ok(Self { client })
}
}
impl HttpTransport for ReqwestTransport {
fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, String>> {
Box::pin(async move {
let method = reqwest::Method::from_bytes(request.method.as_bytes())
.map_err(|e| format!("invalid HTTP method {}: {e}", request.method))?;
let mut builder = self.client.request(method, &request.url);
for (name, value) in &request.headers {
builder = builder.header(name, value);
}
if !request.body.is_empty() {
builder = builder.body(request.body.clone());
}
let response = builder.send().await.map_err(|e| {
format!("DNS provider request to {} failed: {e}", request.url)
})?;
let status = response.status().as_u16();
let body = read_bounded(response).await?;
Ok(HttpResponse { status, body })
})
}
}
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
async fn read_bounded(mut response: reqwest::Response) -> Result<String, String> {
let too_large =
|| format!("the DNS provider answered with a body larger than {MAX_RESPONSE_BYTES} bytes");
if response
.content_length()
.is_some_and(|len| len > MAX_RESPONSE_BYTES as u64)
{
return Err(too_large());
}
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|e| format!("could not read the DNS provider response body: {e}"))?
{
if bytes.len() + chunk.len() > MAX_RESPONSE_BYTES {
return Err(too_large());
}
bytes.extend_from_slice(&chunk);
}
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
#[cfg(test)]
pub(crate) struct RecordingTransport {
script: std::sync::Mutex<std::collections::HashMap<String, Vec<HttpResponse>>>,
sent: std::sync::Mutex<Vec<HttpRequest>>,
}
#[cfg(test)]
impl RecordingTransport {
pub(crate) fn new(script: &[(&str, &str)]) -> std::sync::Arc<Self> {
let mut map: std::collections::HashMap<String, Vec<HttpResponse>> =
std::collections::HashMap::new();
for (key, body) in script {
map.entry((*key).to_owned())
.or_default()
.push(HttpResponse {
status: 200,
body: (*body).to_owned(),
});
}
std::sync::Arc::new(Self {
script: std::sync::Mutex::new(map),
sent: std::sync::Mutex::new(Vec::new()),
})
}
pub(crate) fn then(
self: &std::sync::Arc<Self>,
key: &str,
status: u16,
body: &str,
) -> std::sync::Arc<Self> {
self.script
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.entry(key.to_owned())
.or_default()
.push(HttpResponse {
status,
body: body.to_owned(),
});
std::sync::Arc::clone(self)
}
pub(crate) fn sent(&self) -> Vec<HttpRequest> {
self.sent
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub(crate) fn calls(&self) -> Vec<String> {
self.sent().iter().map(request_key).collect()
}
}
#[cfg(test)]
pub(crate) fn request_key(request: &HttpRequest) -> String {
let path = request
.url
.split_once("://")
.map_or(request.url.as_str(), |(_, rest)| rest);
let path = path.find('/').map_or("/", |i| &path[i..]);
let path = path.split('?').next().unwrap_or(path);
format!("{} {path}", request.method)
}
#[cfg(test)]
impl HttpTransport for RecordingTransport {
fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, String>> {
let key = request_key(&request);
self.sent
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(request);
let mut script = self
.script
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let response = match script.get_mut(&key) {
Some(responses) if responses.len() > 1 => responses.remove(0),
Some(responses) => responses[0].clone(),
None => HttpResponse {
status: 404,
body: format!(
"{{\"success\":false,\"errors\":[{{\"message\":\"unscripted {key}\"}}]}}"
),
},
};
drop(script);
Box::pin(async move { Ok(response) })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_sets_method_headers_and_body() {
let request = HttpRequest::new("POST", "https://api.example/x")
.header("content-type", "application/json")
.body("{}");
assert_eq!(request.method, "POST");
assert_eq!(request.headers["content-type"], "application/json");
assert_eq!(request.body, "{}");
}
#[test]
fn debug_redacts_credential_headers_but_keeps_the_rest() {
let request = HttpRequest::new("POST", "https://api.example/x")
.header("authorization", "Bearer cf-live-token-DO-NOT-LEAK")
.header("x-amz-security-token", "sts-session-secret")
.header("content-type", "application/json")
.body("{\"type\":\"TXT\"}");
let rendered = format!("{request:?}");
assert!(!rendered.contains("DO-NOT-LEAK"), "leaked: {rendered}");
assert!(
!rendered.contains("sts-session-secret"),
"leaked: {rendered}"
);
assert_eq!(rendered.matches("<redacted>").count(), 2, "{rendered}");
assert!(rendered.contains("application/json"), "{rendered}");
assert!(rendered.contains("https://api.example/x"), "{rendered}");
assert!(rendered.contains("POST"), "{rendered}");
}
#[test]
fn success_is_2xx_only() {
for (status, expected) in [
(200, true),
(201, true),
(299, true),
(300, false),
(403, false),
(500, false),
] {
let response = HttpResponse {
status,
body: String::new(),
};
assert_eq!(response.is_success(), expected, "status {status}");
}
}
}