use adk_core::{AdkError, ErrorCategory, ErrorComponent, Result};
use async_trait::async_trait;
use futures::stream::BoxStream;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use super::event_source::{EventSource, TriggerEvent};
pub trait WebhookVerifier: Send + Sync + std::fmt::Debug {
fn verify(&self, request: &WebhookRequest<'_>) -> std::result::Result<String, String>;
}
pub struct WebhookRequest<'a> {
headers: &'a axum::http::HeaderMap,
body: &'a [u8],
}
impl<'a> WebhookRequest<'a> {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers.get(name).and_then(|value| value.to_str().ok())
}
pub fn body(&self) -> &'a [u8] {
self.body
}
}
impl std::fmt::Debug for WebhookRequest<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebhookRequest")
.field("header_count", &self.headers.len())
.field("body_bytes", &self.body.len())
.finish()
}
}
pub struct WebhookTrigger {
path: String,
name: String,
bind_address: SocketAddr,
verifier: Option<Arc<dyn WebhookVerifier>>,
accept_non_json: bool,
max_body_bytes: usize,
}
const DEFAULT_MAX_BODY_BYTES: usize = 1024 * 1024;
impl WebhookTrigger {
pub fn new(port: u16, path: &str) -> Self {
let path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") };
Self {
name: format!("webhook:{path}"),
path,
bind_address: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
verifier: None,
accept_non_json: false,
max_body_bytes: DEFAULT_MAX_BODY_BYTES,
}
}
pub fn with_bind_address(mut self, address: SocketAddr) -> Self {
self.bind_address = address;
self
}
pub fn with_verifier(mut self, verifier: Arc<dyn WebhookVerifier>) -> Self {
self.verifier = Some(verifier);
self
}
pub fn accept_non_json(mut self) -> Self {
self.accept_non_json = true;
self
}
pub fn with_max_body_bytes(mut self, max_body_bytes: usize) -> Self {
self.max_body_bytes = max_body_bytes;
self
}
pub fn bind_address(&self) -> SocketAddr {
self.bind_address
}
}
#[async_trait]
impl EventSource for WebhookTrigger {
fn name(&self) -> &str {
&self.name
}
async fn subscribe(&self) -> Result<BoxStream<'static, TriggerEvent>> {
if !self.bind_address.ip().is_loopback() && self.verifier.is_none() {
return Err(AdkError::new(
ErrorComponent::Agent,
ErrorCategory::InvalidInput,
"agent.ambient.webhook_unauthenticated",
format!(
"WebhookTrigger is set to serve {}, which is reachable beyond this host, \
with no verifier. Any caller able to reach the port could start agent \
work. Call `with_verifier`, or leave the default loopback bind.",
self.bind_address
),
));
}
let (tx, mut rx) = mpsc::channel::<TriggerEvent>(256);
let source_name = self.name.clone();
let path = self.path.clone();
let bind_address = self.bind_address;
let verifier = self.verifier.clone();
let accept_non_json = self.accept_non_json;
let max_body_bytes = self.max_body_bytes;
let listener = tokio::net::TcpListener::bind(bind_address).await.map_err(|e| {
AdkError::new(
ErrorComponent::Agent,
ErrorCategory::Unavailable,
"agent.ambient.webhook_bind_failed",
format!("WebhookTrigger could not bind {bind_address}: {e}"),
)
})?;
let shutdown = CancellationToken::new();
let server_shutdown = shutdown.clone();
tokio::spawn(async move {
use axum::Router;
use axum::body::Bytes;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::post;
let app = Router::new().route(
&path,
post(move |headers: HeaderMap, body: Bytes| {
let tx = tx.clone();
let source = source_name.clone();
let verifier = verifier.clone();
async move {
if body.len() > max_body_bytes {
tracing::warn!(
body.bytes = body.len(),
limit = max_body_bytes,
"webhook body rejected as oversized"
);
return StatusCode::PAYLOAD_TOO_LARGE;
}
let principal = match &verifier {
Some(verifier) => {
let request =
WebhookRequest { headers: &headers, body: body.as_ref() };
match verifier.verify(&request) {
Ok(principal) => Some(principal),
Err(reason) => {
tracing::warn!(
reason = %reason,
"webhook request rejected by verifier"
);
return StatusCode::UNAUTHORIZED;
}
}
}
None => None,
};
let payload = match serde_json::from_slice::<serde_json::Value>(&body) {
Ok(value) => value,
Err(_) if accept_non_json => serde_json::Value::String(
String::from_utf8_lossy(&body).to_string(),
),
Err(e) => {
tracing::warn!(
error = %e,
"webhook body rejected as malformed JSON"
);
return StatusCode::BAD_REQUEST;
}
};
let event = TriggerEvent { source, payload, principal };
if tx.send(event).await.is_err() {
tracing::debug!("webhook subscriber dropped, refusing the request");
return StatusCode::SERVICE_UNAVAILABLE;
}
StatusCode::OK
}
}),
);
tracing::info!(address = %bind_address, path = %path, "webhook trigger listening");
let served = axum::serve(listener, app)
.with_graceful_shutdown(async move { server_shutdown.cancelled().await })
.await;
if let Err(e) = served {
tracing::warn!(error = %e, "webhook trigger server error");
}
tracing::debug!(address = %bind_address, "webhook trigger stopped");
});
let guard = shutdown.drop_guard();
let stream = async_stream::stream! {
let _guard = guard;
while let Some(event) = rx.recv().await {
yield event;
}
};
Ok(Box::pin(stream))
}
}
impl std::fmt::Debug for WebhookTrigger {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebhookTrigger")
.field("bind_address", &self.bind_address)
.field("path", &self.path)
.field("verifier", &self.verifier)
.field("accept_non_json", &self.accept_non_json)
.field("max_body_bytes", &self.max_body_bytes)
.finish()
}
}