use std::time::Duration;
use reqwest::{Client, Url};
use serde::{Deserialize, Serialize};
use crate::error::{Result, SeerError};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebhookDelivery {
pub status: u16,
}
#[derive(Debug, Clone)]
pub struct WebhookClient {
timeout: Duration,
allow_private: bool,
}
impl Default for WebhookClient {
fn default() -> Self {
Self::new()
}
}
impl WebhookClient {
pub fn new() -> Self {
Self {
timeout: DEFAULT_TIMEOUT,
allow_private: false,
}
}
pub fn from_config(config: &crate::config::SeerConfig) -> Self {
Self::new().with_timeout(config.http_timeout())
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[cfg(test)]
pub(crate) fn allowing_private_hosts(mut self) -> Self {
self.allow_private = true;
self
}
pub async fn post_json<T: Serialize + ?Sized>(
&self,
url: &str,
payload: &T,
) -> Result<WebhookDelivery> {
let (host, port) = parse_webhook_url(url)?;
let body = serde_json::to_vec(payload)?;
let connect_timeout = CONNECT_TIMEOUT.min(self.timeout);
let mut builder = Client::builder()
.timeout(self.timeout)
.connect_timeout(connect_timeout)
.user_agent(concat!("Seer/", env!("CARGO_PKG_VERSION")))
.redirect(reqwest::redirect::Policy::none());
if !self.allow_private {
let resolved = crate::net::resolve_public_host(&host, port).await?;
builder = builder.resolve_to_addrs(&host, &resolved);
}
let client = builder
.build()
.map_err(|e| SeerError::HttpError(format!("failed to build HTTP client: {}", e)))?;
let response = client
.post(url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body)
.send()
.await?;
let status = response.status();
if !status.is_success() {
return Err(SeerError::HttpError(format!(
"webhook delivery failed: HTTP {}",
status
)));
}
Ok(WebhookDelivery {
status: status.as_u16(),
})
}
}
fn parse_webhook_url(url: &str) -> Result<(String, u16)> {
let parsed = Url::parse(url)
.map_err(|e| SeerError::InvalidInput(format!("invalid webhook URL '{}': {}", url, e)))?;
let scheme = parsed.scheme();
if scheme != "http" && scheme != "https" {
return Err(SeerError::InvalidInput(format!(
"webhook URL must use http or https, got '{}'",
scheme
)));
}
let host = match parsed.host() {
Some(url::Host::Domain(d)) => d.to_string(),
Some(url::Host::Ipv4(ip)) => ip.to_string(),
Some(url::Host::Ipv6(ip)) => ip.to_string(),
None => {
return Err(SeerError::InvalidInput(format!(
"webhook URL '{}' has no host",
url
)))
}
};
let port = parsed
.port_or_known_default()
.unwrap_or(if scheme == "http" { 80 } else { 443 });
Ok((host, port))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use wiremock::matchers::{body_json, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[derive(Serialize)]
struct Payload {
domain: String,
event: String,
}
struct Unserializable;
impl Serialize for Unserializable {
fn serialize<S: serde::Serializer>(
&self,
_serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
Err(serde::ser::Error::custom("cannot serialize"))
}
}
#[tokio::test]
async fn delivers_json_payload_and_returns_status() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/hook"))
.and(header("content-type", "application/json"))
.and(body_json(serde_json::json!({
"domain": "example.com",
"event": "expiry",
})))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
let client = WebhookClient::new().allowing_private_hosts();
let payload = Payload {
domain: "example.com".to_string(),
event: "expiry".to_string(),
};
let delivery = client
.post_json(&format!("{}/hook", server.uri()), &payload)
.await
.unwrap();
assert_eq!(delivery.status, 200);
server.verify().await;
}
#[tokio::test]
async fn non_2xx_is_error_naming_status_not_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/hook"))
.respond_with(ResponseTemplate::new(500).set_body_string("internal-secret-detail"))
.mount(&server)
.await;
let client = WebhookClient::new().allowing_private_hosts();
let err = client
.post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
.await
.unwrap_err();
assert!(matches!(err, SeerError::HttpError(_)), "got {err:?}");
let msg = err.to_string();
assert!(msg.contains("500"), "error must name the status: {msg}");
assert!(
!msg.contains("internal-secret-detail"),
"untrusted response body must not leak into the error: {msg}"
);
}
#[tokio::test]
async fn redirect_is_not_followed() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/hook"))
.respond_with(
ResponseTemplate::new(301)
.insert_header("location", format!("{}/elsewhere", server.uri()).as_str()),
)
.expect(1)
.mount(&server)
.await;
Mock::given(path("/elsewhere"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let client = WebhookClient::new().allowing_private_hosts();
let err = client
.post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
.await
.unwrap_err();
assert!(matches!(err, SeerError::HttpError(_)), "got {err:?}");
assert!(
err.to_string().contains("301"),
"error must carry the 3xx status: {err}"
);
server.verify().await;
}
#[tokio::test]
async fn production_client_refuses_loopback_url() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let client = WebhookClient::new(); let err = client
.post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
.await
.unwrap_err();
assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
server.verify().await;
}
#[tokio::test]
async fn production_client_refuses_private_ip_literal() {
let client = WebhookClient::new();
for url in [
"http://10.0.0.1/hook",
"https://169.254.169.254/latest/meta-data",
"http://[::1]/hook",
] {
let err = client
.post_json(url, &serde_json::json!({}))
.await
.unwrap_err();
assert!(
matches!(err, SeerError::InvalidInput(_)),
"{url} must be refused, got {err:?}"
);
}
}
#[tokio::test]
async fn rejects_non_http_scheme() {
let client = WebhookClient::new();
let err = client
.post_json("ftp://example.com/hook", &serde_json::json!({}))
.await
.unwrap_err();
assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
assert!(
err.to_string().contains("http"),
"error should point at the scheme requirement: {err}"
);
}
#[tokio::test]
async fn rejects_hostless_url() {
let client = WebhookClient::new();
let err = client
.post_json("http:///hook", &serde_json::json!({}))
.await
.unwrap_err();
assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
}
#[tokio::test]
async fn serialization_failure_fails_fast_before_any_network() {
let client = WebhookClient::new();
let err = client
.post_json("https://webhook.example.com/hook", &Unserializable)
.await
.unwrap_err();
assert!(matches!(err, SeerError::JsonError(_)), "got {err:?}");
}
#[tokio::test]
async fn timeout_bounds_delivery() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/hook"))
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(5)))
.mount(&server)
.await;
let client = WebhookClient::new()
.allowing_private_hosts()
.with_timeout(Duration::from_millis(100));
let err = client
.post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
.await
.unwrap_err();
match err {
SeerError::ReqwestError { transient, .. } => {
assert!(transient, "timeouts must classify as transient");
}
other => panic!("expected ReqwestError from timeout, got {other:?}"),
}
}
}