use super::http_stream::{
handle_streamable_request, stream_request_format, stream_response_format,
HttpReceiveStreamConfig, PublishResponseStreamError,
};
use crate::canonical_message::tracing_support::LazyMessageIds;
use crate::models::{HttpConfig, HttpServerProtocol, TlsConfig};
use crate::traits::{
BoxFuture, ConsumerError, MessageConsumer, MessagePublisher, ReceivedBatch, Sent,
};
use crate::traits::{CommitFunc, MessageDisposition, PublisherError, SentBatch};
use crate::CanonicalMessage;
use anyhow::{anyhow, Context};
use arc_swap::ArcSwap;
use async_trait::async_trait;
use base64::{engine::general_purpose, Engine as _};
use bytes::Bytes;
use http_body_util::BodyExt;
use http_body_util::StreamBody;
use hyper::{
body::{Frame, Incoming},
server::conn::{http1, http2},
Request, Response, StatusCode,
};
use hyper_rustls::HttpsConnectorBuilder;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder as AutoBuilder;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use std::any::Any;
use std::collections::HashMap;
use std::fs::File;
use std::io::BufReader;
use std::net::SocketAddr;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use tokio::net::{TcpListener, TcpSocket};
use tokio_rustls::TlsAcceptor;
use tracing::{debug, info, trace, warn};
use uuid::Uuid;
type HttpSourceMessage = (CanonicalMessage, CommitFunc);
#[derive(Clone, Default)]
struct HttpConnInfo {
cipher_suite: Option<String>,
protocol_version: Option<String>,
}
#[derive(Clone)]
struct ConcurrencyLimiter {
shards: Arc<Vec<Arc<tokio::sync::Semaphore>>>,
}
impl ConcurrencyLimiter {
fn new(total_permits: usize) -> Self {
let total = total_permits.max(1);
let shard_count = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.min(total)
.clamp(1, 16);
let base = total / shard_count;
let extra = total % shard_count;
let shards = (0..shard_count)
.map(|i| Arc::new(tokio::sync::Semaphore::new(base + usize::from(i < extra))))
.collect();
Self {
shards: Arc::new(shards),
}
}
async fn acquire(
&self,
) -> Result<tokio::sync::OwnedSemaphorePermit, tokio::sync::AcquireError> {
let shard_count = self.shards.len();
let local = shard_index() % shard_count;
for offset in 0..shard_count {
let idx = (local + offset) % shard_count;
if let Ok(permit) = self.shards[idx].clone().try_acquire_owned() {
return Ok(permit);
}
}
self.shards[local].clone().acquire_owned().await
}
}
fn shard_index() -> usize {
thread_local! {
static SHARD: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
}
static NEXT: AtomicU64 = AtomicU64::new(0);
SHARD.with(|shard| match shard.get() {
Some(idx) => idx,
None => {
let idx = NEXT.fetch_add(1, Ordering::Relaxed) as usize;
shard.set(Some(idx));
idx
}
})
}
struct RequestMetadataView<'a> {
method: &'a hyper::Method,
path: &'a str,
query: Option<&'a str>,
version: hyper::Version,
headers: &'a hyper::HeaderMap,
conn_info: &'a HttpConnInfo,
}
type BoxBody = http_body_util::combinators::BoxBody<Bytes, anyhow::Error>;
use hyper::service::Service;
fn full<T: Into<Bytes>>(chunk: T) -> BoxBody {
http_body_util::Full::new(chunk.into())
.map_err(|_| anyhow::anyhow!("Infallible"))
.boxed()
}
fn streamed<S>(stream: S) -> BoxBody
where
S: futures::Stream<Item = Result<Frame<Bytes>, anyhow::Error>> + Send + Sync + 'static,
{
StreamBody::new(stream).boxed()
}
pub struct HttpConsumer {
request_rx: tokio::sync::mpsc::Receiver<HttpSourceMessage>,
route_id: u64,
shared_server: Arc<SharedHttpServer>,
buffer_size: usize,
url: String,
bound_addr: Option<SocketAddr>,
}
impl HttpConsumer {
pub fn bound_addr(&self) -> Option<SocketAddr> {
self.bound_addr
}
}
impl Drop for HttpConsumer {
fn drop(&mut self) {
let Ok(mut registry) = http_server_registry().lock() else {
return;
};
let should_shutdown = self.shared_server.router.unregister_route(self.route_id);
if !should_shutdown {
return;
}
registry.retain(|_, server| !Arc::ptr_eq(server, &self.shared_server));
let _ = self.shared_server.shutdown_tx.send(());
}
}
#[derive(Clone)]
struct HttpConsumerState {
path: Option<String>,
tx: tokio::sync::mpsc::Sender<HttpSourceMessage>,
inline_publisher: Option<Arc<dyn MessagePublisher>>,
inline_echo: bool,
message_id_header: String,
request_timeout: std::time::Duration,
fire_and_forget: bool,
receive_streamable: bool,
basic_auth: Option<(String, String)>,
compression_enabled: bool,
compression_threshold_bytes: usize,
custom_headers: HashMap<String, String>,
concurrency_limit: ConcurrencyLimiter,
method: Option<hyper::Method>,
}
#[derive(Default)]
struct RouteTable {
routes: Vec<(u64, Arc<HttpConsumerState>)>,
}
struct SharedHttpRouter {
snapshot: ArcSwap<RouteTable>,
writers: Mutex<()>,
}
impl Default for SharedHttpRouter {
fn default() -> Self {
Self {
snapshot: ArcSwap::from_pointee(RouteTable::default()),
writers: Mutex::new(()),
}
}
}
impl SharedHttpRouter {
fn register_route(&self, route_id: u64, state: Arc<HttpConsumerState>) -> anyhow::Result<()> {
let _writers = self
.writers
.lock()
.map_err(|_| anyhow!("HTTP route registry lock poisoned"))?;
let current = self.snapshot.load();
for (_, existing) in current.routes.iter() {
if routes_conflict(existing, &state) {
return Err(anyhow!(
"Conflicting HTTP consumer registration for path {:?} and method {:?}",
state.path,
state.method
));
}
}
let mut routes = current.routes.clone();
routes.push((route_id, state));
self.snapshot.store(Arc::new(RouteTable { routes }));
Ok(())
}
fn unregister_route(&self, route_id: u64) -> bool {
let Ok(_writers) = self.writers.lock() else {
return false;
};
let mut routes = self.snapshot.load().routes.clone();
routes.retain(|(id, _)| *id != route_id);
let is_empty = routes.is_empty();
self.snapshot.store(Arc::new(RouteTable { routes }));
is_empty
}
fn match_route(&self, path: &str, method: &hyper::Method) -> anyhow::Result<RouteMatchResult> {
let table = self.snapshot.load();
let mut matched_path = false;
let mut best: Option<&Arc<HttpConsumerState>> = None;
let mut best_specificity = (0, 0);
for (_, state) in table.routes.iter() {
if !route_matches_path(state, path) {
continue;
}
matched_path = true;
if route_matches_method(state, method) {
let specificity = route_specificity(state);
if best.is_none() || specificity > best_specificity {
best_specificity = specificity;
best = Some(state);
}
}
}
Ok(match best {
Some(state) => RouteMatchResult::Matched(Arc::clone(state)),
None if matched_path => {
let mut methods = table
.routes
.iter()
.filter(|(_, state)| route_matches_path(state, path))
.filter_map(|(_, state)| state.method.clone())
.collect::<Vec<_>>();
methods.sort_by(|left, right| left.as_str().cmp(right.as_str()));
methods.dedup();
RouteMatchResult::MethodNotAllowed(methods)
}
None => RouteMatchResult::NotFound,
})
}
}
enum RouteMatchResult {
Matched(Arc<HttpConsumerState>),
MethodNotAllowed(Vec<hyper::Method>),
NotFound,
}
struct SharedHttpServer {
router: Arc<SharedHttpRouter>,
shutdown_tx: tokio::sync::watch::Sender<()>,
bound_addr: Option<SocketAddr>,
}
#[derive(Clone, Hash, PartialEq, Eq)]
struct HttpServerKey {
listen_addr: String,
tls: TlsConfig,
workers: usize,
server_protocol: HttpServerProtocol,
}
static HTTP_SERVER_REGISTRY: OnceLock<Mutex<HashMap<HttpServerKey, Arc<SharedHttpServer>>>> =
OnceLock::new();
static HTTP_ROUTE_ID: AtomicU64 = AtomicU64::new(1);
fn http_server_registry() -> &'static Mutex<HashMap<HttpServerKey, Arc<SharedHttpServer>>> {
HTTP_SERVER_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
fn normalize_http_path(path: Option<&str>) -> Option<String> {
path.map(str::trim)
.filter(|path| !path.is_empty())
.map(|path| {
if path.starts_with('/') {
path.to_string()
} else {
format!("/{}", path)
}
})
}
pub fn guess_content_type(path_or_extension: &str) -> &'static str {
let input = path_or_extension.trim();
let extension = Path::new(input)
.extension()
.and_then(|ext| ext.to_str())
.filter(|ext| !ext.is_empty())
.or_else(|| input.strip_prefix('.'))
.unwrap_or(input)
.trim()
.trim_start_matches('.')
.to_ascii_lowercase();
match extension.as_str() {
"html" | "htm" => "text/html; charset=utf-8",
"css" => "text/css; charset=utf-8",
"js" | "mjs" | "cjs" => "text/javascript; charset=utf-8",
"json" | "map" | "jsonld" => "application/json; charset=utf-8",
"xml" => "application/xml; charset=utf-8",
"yaml" | "yml" => "application/yaml; charset=utf-8",
"pdf" => "application/pdf",
"wasm" => "application/wasm",
"zip" => "application/zip",
"gz" => "application/gzip",
"tar" => "application/x-tar",
"7z" => "application/x-7z-compressed",
"rar" => "application/vnd.rar",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"png" => "image/png",
"apng" => "image/apng",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"avif" => "image/avif",
"bmp" => "image/bmp",
"tif" | "tiff" => "image/tiff",
"woff" => "font/woff",
"woff2" => "font/woff2",
"ttf" => "font/ttf",
"otf" => "font/otf",
"eot" => "application/vnd.ms-fontobject",
"txt" | "text" => "text/plain; charset=utf-8",
"md" => "text/markdown; charset=utf-8",
"csv" => "text/csv; charset=utf-8",
"tsv" => "text/tab-separated-values; charset=utf-8",
"ics" => "text/calendar; charset=utf-8",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"ogg" | "oga" => "audio/ogg",
"m4a" => "audio/mp4",
"mp4" | "m4v" => "video/mp4",
"webm" => "video/webm",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
"mpeg" | "mpg" => "video/mpeg",
"ogv" => "video/ogg",
_ => "application/octet-stream",
}
}
pub const HTTP_METHOD: &str = "http_method";
pub const HTTP_PATH: &str = "http_path";
pub const HTTP_QUERY: &str = "http_query";
pub const HTTP_VERSION: &str = "http_version";
pub const HTTP_STATUS_CODE: &str = "http_status_code";
pub trait HttpRequestExt {
fn http_method(&self) -> &str;
fn http_path(&self) -> &str;
fn http_query(&self) -> &str;
fn query_param(&self, key: &str) -> Option<&str>;
fn query_int(&self, key: &str) -> Option<i64>;
fn accepts_gzip(&self) -> bool;
}
impl HttpRequestExt for CanonicalMessage {
fn http_method(&self) -> &str {
self.metadata
.get(HTTP_METHOD)
.map(String::as_str)
.unwrap_or("")
}
fn http_path(&self) -> &str {
self.metadata
.get(HTTP_PATH)
.map(String::as_str)
.unwrap_or("")
}
fn http_query(&self) -> &str {
self.metadata
.get(HTTP_QUERY)
.map(String::as_str)
.unwrap_or("")
}
fn query_param(&self, key: &str) -> Option<&str> {
self.http_query().split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
(k == key).then_some(v)
})
}
fn query_int(&self, key: &str) -> Option<i64> {
self.query_param(key)?.parse().ok()
}
fn accepts_gzip(&self) -> bool {
self.metadata
.get("accept-encoding")
.is_some_and(|v| v.to_ascii_lowercase().contains("gzip"))
}
}
#[cfg(test)]
mod request_ext_tests {
use super::*;
fn request(method: &str, path: &str, query: &str) -> CanonicalMessage {
CanonicalMessage::new(Vec::new(), None)
.with_metadata_kv(HTTP_METHOD, method)
.with_metadata_kv(HTTP_PATH, path)
.with_metadata_kv(HTTP_QUERY, query)
}
#[test]
fn reads_request_line() {
let msg = request("GET", "/sum", "a=2&b=40");
assert_eq!(msg.http_method(), "GET");
assert_eq!(msg.http_path(), "/sum");
assert_eq!(msg.http_query(), "a=2&b=40");
}
#[test]
fn missing_metadata_reads_as_empty() {
let msg = CanonicalMessage::new(Vec::new(), None);
assert_eq!(msg.http_method(), "");
assert_eq!(msg.query_param("a"), None);
assert_eq!(msg.query_int("a"), None);
}
#[test]
fn parses_query_params_by_exact_key() {
let msg = request("GET", "/", "a=2&ab=9&b=40");
assert_eq!(msg.query_param("a"), Some("2"));
assert_eq!(msg.query_param("ab"), Some("9"));
assert_eq!(msg.query_int("b"), Some(40));
assert_eq!(msg.query_param("missing"), None);
assert_eq!(msg.query_param("c"), None);
}
#[test]
fn detects_gzip_support() {
let yes = request("GET", "/", "").with_metadata_kv("accept-encoding", "br, GZIP");
let no = request("GET", "/", "").with_metadata_kv("accept-encoding", "deflate");
assert!(yes.accepts_gzip());
assert!(!no.accepts_gzip());
assert!(!request("GET", "/", "").accepts_gzip());
}
}
fn routes_conflict(left: &HttpConsumerState, right: &HttpConsumerState) -> bool {
left.path == right.path
&& (left.method == right.method || left.method.is_none() || right.method.is_none())
}
fn route_matches_path(state: &HttpConsumerState, path: &str) -> bool {
match &state.path {
Some(route_path) => route_path == path,
None => true,
}
}
fn route_matches_method(state: &HttpConsumerState, method: &hyper::Method) -> bool {
match &state.method {
Some(route_method) => route_method == method,
None => true,
}
}
fn route_specificity(state: &HttpConsumerState) -> (u8, u8) {
(
u8::from(state.path.is_some()),
u8::from(state.method.is_some()),
)
}
fn request_accepts_text(headers: &hyper::HeaderMap) -> bool {
let accept_values = headers.get_all("accept");
if accept_values.iter().next().is_none() {
return true;
}
accept_values.iter().any(|value| {
value.to_str().ok().is_some_and(|raw| {
raw.split(',').any(|item| {
let media_type = item
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
matches!(media_type.as_str(), "*/*" | "text/*" | "text/plain")
})
})
})
}
fn request_accepts_gzip(headers: &hyper::HeaderMap) -> bool {
headers.get_all("accept-encoding").iter().any(|value| {
value.to_str().ok().is_some_and(|raw| {
raw.split(',').any(|item| {
let mut parts = item.split(';').map(str::trim);
let coding = parts.next().unwrap_or_default().to_ascii_lowercase();
if coding != "gzip" && coding != "*" {
return false;
}
!parts.any(|p| {
p.strip_prefix("q=")
.and_then(|q| q.parse::<f32>().ok())
.is_some_and(|q| q == 0.0)
})
})
})
})
}
fn http_version_str(version: hyper::Version) -> &'static str {
match version {
hyper::Version::HTTP_09 => "HTTP/0.9",
hyper::Version::HTTP_10 => "HTTP/1.0",
hyper::Version::HTTP_11 => "HTTP/1.1",
hyper::Version::HTTP_2 => "HTTP/2.0",
hyper::Version::HTTP_3 => "HTTP/3.0",
_ => "HTTP/?",
}
}
fn request_metadata_matches(request: &RequestMetadataView<'_>, key: &str, value: &str) -> bool {
match key {
HTTP_METHOD => request.method.as_str() == value,
HTTP_PATH => request.path == value,
HTTP_QUERY => request.query.unwrap_or("") == value,
HTTP_VERSION => http_version_str(request.version) == value,
"tls_cipher_suite" => request.conn_info.cipher_suite.as_deref() == Some(value),
"tls_protocol_version" => request.conn_info.protocol_version.as_deref() == Some(value),
_ => request
.headers
.get(key)
.and_then(|header| header.to_str().ok())
.is_some_and(|original| original == value),
}
}
fn has_content_type_header(headers: &HashMap<String, String>) -> bool {
headers
.keys()
.any(|key| key.eq_ignore_ascii_case("content-type"))
}
fn text_error_response(
status: StatusCode,
body: impl Into<Bytes>,
accepts_text: bool,
custom_headers: Option<&HashMap<String, String>>,
) -> Response<BoxBody> {
let mut builder = Response::builder().status(status);
if let Some(custom_headers) = custom_headers {
for (header_name, header_value) in custom_headers {
builder = builder.header(header_name.as_str(), header_value.as_str());
}
if accepts_text && !has_content_type_header(custom_headers) {
builder = builder.header("content-type", "text/plain; charset=utf-8");
}
} else if accepts_text {
builder = builder.header("content-type", "text/plain; charset=utf-8");
}
builder.body(full(body)).unwrap()
}
#[derive(Clone)]
pub struct HttpBridgeService {
router: Arc<SharedHttpRouter>,
conn_info: HttpConnInfo,
}
impl Service<Request<Incoming>> for HttpBridgeService {
type Response = Response<BoxBody>;
type Error = anyhow::Error;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
fn call(&self, req: Request<Incoming>) -> Self::Future {
let router = self.router.clone();
let conn_info = self.conn_info.clone();
Box::pin(handle_request(router, conn_info, req))
}
}
impl HttpConsumer {
pub async fn new(config: &HttpConfig) -> anyhow::Result<Self> {
Self::new_with_inline_publisher(config, None).await
}
pub async fn new_with_inline_publisher(
config: &HttpConfig,
inline_publisher: Option<Arc<dyn MessagePublisher>>,
) -> anyhow::Result<Self> {
let (request_rx, state, buffer_size) =
setup_http_state_and_channel(config, inline_publisher)?;
let listen_address = &config.url;
let addr: SocketAddr = listen_address
.parse()
.with_context(|| format!("Invalid listen address: {}", listen_address))?;
let tls_config = config.tls.clone();
let workers = if config.workers.unwrap_or(0) == 0 {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
} else {
config.workers.unwrap()
};
let route_id = HTTP_ROUTE_ID.fetch_add(1, Ordering::Relaxed);
let server_key = HttpServerKey {
listen_addr: addr.to_string(),
tls: tls_config.clone(),
workers,
server_protocol: config.server_protocol,
};
let shared_server =
get_or_create_shared_http_server(&server_key, &tls_config, route_id, Arc::new(state))
.await?;
Ok(Self {
request_rx,
route_id,
shared_server: shared_server.clone(),
buffer_size,
url: build_consumer_target_url(config, shared_server.bound_addr),
bound_addr: shared_server.bound_addr,
})
}
}
fn setup_http_state_and_channel(
config: &HttpConfig,
inline_publisher: Option<Arc<dyn MessagePublisher>>,
) -> anyhow::Result<(
tokio::sync::mpsc::Receiver<HttpSourceMessage>,
HttpConsumerState,
usize,
)> {
let buffer_size = config.internal_buffer_size.unwrap_or(1024).max(1);
let (request_tx, request_rx) = tokio::sync::mpsc::channel::<HttpSourceMessage>(buffer_size);
let message_id_header = config
.message_id_header
.clone()
.unwrap_or_else(|| "message-id".to_string());
let request_timeout =
std::time::Duration::from_millis(config.request_timeout_ms.unwrap_or(30000));
let compression_threshold_bytes = config.compression_threshold_bytes.unwrap_or(1024);
let method = config
.method
.as_deref()
.map(|m| {
hyper::Method::from_bytes(m.as_bytes())
.map_err(|_| anyhow::anyhow!("Invalid config.method: '{}'", m))
})
.transpose()?;
let inline_echo = inline_publisher.as_ref().is_some_and(|publisher| {
publisher
.as_any()
.is::<crate::endpoints::response::ResponsePublisher>()
});
let state = HttpConsumerState {
path: normalize_http_path(config.path.as_deref()),
tx: request_tx,
inline_publisher,
inline_echo,
message_id_header,
request_timeout,
fire_and_forget: config.fire_and_forget,
receive_streamable: config.receive_streamable,
basic_auth: config.basic_auth.clone(),
compression_enabled: config.compression_enabled,
compression_threshold_bytes,
custom_headers: config.custom_headers.clone(),
concurrency_limit: ConcurrencyLimiter::new(config.concurrency_limit.unwrap_or(100)),
method,
};
Ok((request_rx, state, buffer_size))
}
fn build_consumer_target_url(config: &HttpConfig, bound_addr: Option<SocketAddr>) -> String {
let base = config
.url
.parse::<SocketAddr>()
.ok()
.and_then(|configured_addr| {
if configured_addr.port() == 0 {
bound_addr.map(|bound_addr| {
SocketAddr::new(configured_addr.ip(), bound_addr.port()).to_string()
})
} else {
None
}
})
.unwrap_or_else(|| config.url.clone());
let mut url = config.tls.normalize_url(&base);
if let Some(path) = normalize_http_path(config.path.as_deref()) {
url.push_str(&path);
}
url
}
async fn get_or_create_shared_http_server(
key: &HttpServerKey,
tls_config: &TlsConfig,
route_id: u64,
state: Arc<HttpConsumerState>,
) -> anyhow::Result<Arc<SharedHttpServer>> {
let addr: SocketAddr = key
.listen_addr
.parse()
.with_context(|| format!("Invalid listen address: {}", key.listen_addr))?;
let uses_ephemeral_port = addr.port() == 0;
if !uses_ephemeral_port {
if let Ok(registry) = http_server_registry().lock() {
for (existing_key, server) in registry.iter() {
if existing_key.listen_addr != key.listen_addr {
continue;
}
if existing_key == key {
server.router.register_route(route_id, state.clone())?;
return Ok(server.clone());
}
return Err(anyhow!(
"HTTP consumer {} is already registered with different TLS or worker settings",
key.listen_addr
));
}
}
}
let (listeners, bound_addr) = bind_http_listeners(addr, key.workers).await?;
let registry_key = if uses_ephemeral_port {
let Some(bound_addr) = bound_addr else {
return Err(anyhow!("Failed to determine bound HTTP listener address"));
};
HttpServerKey {
listen_addr: bound_addr.to_string(),
tls: key.tls.clone(),
workers: key.workers,
server_protocol: key.server_protocol,
}
} else {
key.clone()
};
let listeners = Arc::new(listeners);
let router = Arc::new(SharedHttpRouter::default());
let service = HttpBridgeService {
router: router.clone(),
conn_info: HttpConnInfo::default(),
};
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(());
if tls_config.required {
if !tls_config.is_tls_server_configured() {
return Err(anyhow!(
"HTTP server TLS enabled but no cert/key provided in HttpConfig"
));
}
info!(
"Starting shared HTTPS source on {} with {} workers",
addr, key.workers
);
spawn_tls_server(
listeners,
service,
shutdown_rx,
tls_config,
key.workers,
key.server_protocol,
)
.await?;
} else {
info!(
"Starting shared HTTP source on {} with {} workers",
addr, key.workers
);
spawn_http_server(
listeners,
service,
shutdown_rx,
key.workers,
key.server_protocol,
)
.await?;
}
let server = Arc::new(SharedHttpServer {
router,
shutdown_tx,
bound_addr,
});
let mut registry = http_server_registry()
.lock()
.map_err(|_| anyhow!("HTTP server registry lock poisoned"))?;
for (existing_key, existing) in registry.iter() {
if existing_key.listen_addr != registry_key.listen_addr {
continue;
}
if existing_key == ®istry_key {
let _ = server.shutdown_tx.send(());
existing.router.register_route(route_id, state.clone())?;
return Ok(existing.clone());
}
let _ = server.shutdown_tx.send(());
return Err(anyhow!(
"HTTP consumer {} is already registered with different TLS or worker settings",
key.listen_addr
));
}
server.router.register_route(route_id, state)?;
registry.insert(registry_key, server.clone());
Ok(server)
}
fn http_reuseport_enabled() -> bool {
match std::env::var("MQ_BRIDGE_HTTP_REUSEPORT") {
Ok(value) => !matches!(
value.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
),
Err(_) => cfg!(unix),
}
}
fn bind_reuseport_listener(addr: SocketAddr) -> std::io::Result<TcpListener> {
let socket = if addr.is_ipv4() {
TcpSocket::new_v4()?
} else {
TcpSocket::new_v6()?
};
socket.set_reuseaddr(true)?;
#[cfg(all(unix, not(target_os = "solaris"), not(target_os = "illumos")))]
socket.set_reuseport(true)?;
socket.bind(addr)?;
socket.listen(1024)
}
async fn bind_http_listeners(
addr: SocketAddr,
workers: usize,
) -> anyhow::Result<(Vec<TcpListener>, Option<SocketAddr>)> {
let bind_shared = || async {
let listener = TcpListener::bind(&addr)
.await
.with_context(|| format!("Failed to bind to {}", addr))?;
let bound = listener.local_addr().ok();
anyhow::Ok((vec![listener], bound))
};
if workers <= 1 || !http_reuseport_enabled() {
return bind_shared().await;
}
let first = match bind_reuseport_listener(addr) {
Ok(listener) => listener,
Err(err) => {
warn!("SO_REUSEPORT bind failed ({err}); using a single shared HTTP listener");
return bind_shared().await;
}
};
let bound = first.local_addr().ok();
let sibling_addr = bound.unwrap_or(addr);
let mut listeners = Vec::with_capacity(workers);
listeners.push(first);
for _ in 1..workers {
match bind_reuseport_listener(sibling_addr) {
Ok(listener) => listeners.push(listener),
Err(err) => {
warn!(
"SO_REUSEPORT sibling bind failed ({err}); {} of {} workers will have a private listener",
listeners.len(),
workers
);
break;
}
}
}
info!(
"HTTP server using SO_REUSEPORT: {} listener(s) for {} workers",
listeners.len(),
workers
);
Ok((listeners, bound))
}
async fn spawn_http_server(
listeners: Arc<Vec<TcpListener>>,
service: HttpBridgeService,
shutdown_rx: tokio::sync::watch::Receiver<()>,
workers: usize,
server_protocol: HttpServerProtocol,
) -> anyhow::Result<()> {
for i in 0..workers {
let listeners = listeners.clone();
let listener_idx = i % listeners.len();
let service = service.clone();
let mut shutdown_rx = shutdown_rx.clone();
tokio::spawn(async move {
trace!("HTTP worker {} started", i);
let listener = &listeners[listener_idx];
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
trace!("HTTP worker {} shutting down", i);
break;
}
result = listener.accept() => {
match result {
Ok((socket, _)) => {
let _ = socket.set_nodelay(true);
let mut conn_service = service.clone();
conn_service.conn_info = HttpConnInfo::default();
tokio::spawn(async move {
let io = TokioIo::new(socket);
let conn = match server_protocol {
HttpServerProtocol::Auto => {
let mut builder = AutoBuilder::new(TokioExecutor::new());
builder.http1().keep_alive(true).pipeline_flush(true);
builder.http2().max_concurrent_streams(200);
builder
.serve_connection_with_upgrades(io, conn_service)
.await
}
HttpServerProtocol::Http1Only => {
let mut builder = http1::Builder::new();
builder.keep_alive(true).pipeline_flush(true);
builder
.serve_connection(io, conn_service)
.await
.map_err(|err| -> Box<dyn std::error::Error + Send + Sync> {
Box::new(err)
})
}
HttpServerProtocol::Http2Only => {
let mut builder = http2::Builder::new(TokioExecutor::new());
builder.max_concurrent_streams(200);
builder
.serve_connection(io, conn_service)
.await
.map_err(|err| -> Box<dyn std::error::Error + Send + Sync> {
Box::new(err)
})
}
};
if let Err(e) = conn {
trace!("Connection error: {}", e);
}
});
}
Err(e) => {
match e.kind() {
std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::Interrupted
| std::io::ErrorKind::TimedOut => {
trace!("Transient accept error in worker {}: {}", i, e);
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
_ if e.raw_os_error() == Some(24) => { warn!("HTTP worker {}: FD limit reached, cooling down...", i);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
_ => {
warn!("Accept error in worker {}: {}. Retrying...", i, e);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
}
}
}
}
}
});
}
Ok(())
}
async fn spawn_tls_server(
listeners: Arc<Vec<TcpListener>>,
service: HttpBridgeService,
shutdown_rx: tokio::sync::watch::Receiver<()>,
tls_config: &TlsConfig,
workers: usize,
server_protocol: HttpServerProtocol,
) -> anyhow::Result<()> {
let rustls_server_config = create_rustls_server_config(tls_config, server_protocol)
.context("Failed to create rustls server config")?;
let acceptor = TlsAcceptor::from(rustls_server_config);
for i in 0..workers {
let listeners = listeners.clone();
let listener_idx = i % listeners.len();
let service = service.clone();
let acceptor = acceptor.clone();
let mut shutdown_rx = shutdown_rx.clone();
tokio::spawn(async move {
trace!("TLS worker {} started", i);
let listener = &listeners[listener_idx];
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
trace!("TLS worker {} shutting down", i);
break;
}
result = listener.accept() => {
match result {
Ok((socket, _)) => {
let acceptor = acceptor.clone();
let mut conn_service = service.clone();
tokio::spawn(async move {
match acceptor.accept(socket).await {
Ok(stream) => {
let mut conn_info = HttpConnInfo::default();
let (_, session) = stream.get_ref();
conn_info.cipher_suite = session.negotiated_cipher_suite().map(|c| format!("{:?}", c.suite()));
conn_info.protocol_version = session.protocol_version().map(|v| format!("{:?}", v));
conn_service.conn_info = conn_info;
let io = TokioIo::new(stream);
let conn = match server_protocol {
HttpServerProtocol::Auto => {
let mut builder = AutoBuilder::new(TokioExecutor::new());
builder.http1().keep_alive(true).pipeline_flush(true);
builder.http2().max_concurrent_streams(200);
builder
.serve_connection_with_upgrades(io, conn_service)
.await
}
HttpServerProtocol::Http1Only => {
let mut builder = http1::Builder::new();
builder.keep_alive(true).pipeline_flush(true);
builder
.serve_connection(io, conn_service)
.await
.map_err(|err| -> Box<dyn std::error::Error + Send + Sync> {
Box::new(err)
})
}
HttpServerProtocol::Http2Only => {
let mut builder = http2::Builder::new(TokioExecutor::new());
builder.max_concurrent_streams(200);
builder
.serve_connection(io, conn_service)
.await
.map_err(|err| -> Box<dyn std::error::Error + Send + Sync> {
Box::new(err)
})
}
};
if let Err(e) = conn {
trace!("TLS Connection error: {}", e);
}
}
Err(e) => {
debug!("TLS handshake error: {}", e);
}
}
});
}
Err(e) => {
match e.kind() {
std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::Interrupted
| std::io::ErrorKind::TimedOut => {
trace!("Transient accept error in TLS worker {}: {}", i, e);
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
_ if e.raw_os_error() == Some(24) => { warn!("TLS worker {}: FD limit reached, cooling down...", i);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
_ => {
warn!("Accept error in TLS worker {}: {}. Retrying...", i, e);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
}
}
}
}
}
});
}
Ok(())
}
#[async_trait]
impl MessageConsumer for HttpConsumer {
fn commit_requires_order(&self) -> bool {
false
}
async fn receive_batch(&mut self, max_messages: usize) -> Result<ReceivedBatch, ConsumerError> {
let max_messages = max_messages.max(1);
let mut batch: Vec<HttpSourceMessage> = Vec::with_capacity(max_messages);
if self.request_rx.recv_many(&mut batch, max_messages).await == 0 {
return Err(anyhow!("HTTP source channel closed").into());
}
let (messages, commits): (Vec<_>, Vec<_>) = batch.into_iter().unzip();
let batch_commit: crate::traits::BatchCommitFunc =
Box::new(move |dispositions: Vec<MessageDisposition>| {
Box::pin(async move {
tracing::trace!(
count = dispositions.len(),
"Committing batch of HTTP messages"
);
let mut results = Vec::with_capacity(commits.len());
for (commit, disposition) in commits.into_iter().zip(dispositions) {
results.push(commit(disposition).await);
}
results.into_iter().collect::<anyhow::Result<()>>()
}) as crate::traits::BoxFuture<'static, anyhow::Result<()>>
});
Ok(ReceivedBatch {
messages,
commit: batch_commit,
})
}
async fn status(&self) -> crate::traits::EndpointStatus {
crate::traits::EndpointStatus {
healthy: true,
target: self.url.clone(),
pending: Some(self.request_rx.len()),
capacity: Some(self.buffer_size),
details: serde_json::json!({ "bound_addr": self.bound_addr }),
..Default::default()
}
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[tracing::instrument(level = "trace", skip_all)]
async fn handle_request(
router: Arc<SharedHttpRouter>,
conn_info: HttpConnInfo,
req: Request<Incoming>,
) -> anyhow::Result<Response<BoxBody>> {
let accepts_text = request_accepts_text(req.headers());
match handle_request_internal(router, conn_info, req, accepts_text).await {
Ok(res) => Ok(res),
Err(e) => {
tracing::error!("Internal error handling HTTP request: {}", e);
Ok(text_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Internal error: {}", e),
accepts_text,
None,
))
}
}
}
async fn handle_request_internal(
router: Arc<SharedHttpRouter>,
conn_info: HttpConnInfo,
req: Request<Incoming>,
accepts_text: bool,
) -> anyhow::Result<Response<BoxBody>> {
let state = match router.match_route(req.uri().path(), req.method())? {
RouteMatchResult::Matched(state) => state,
RouteMatchResult::MethodNotAllowed(allowed_methods) => {
let mut headers = HashMap::new();
if !allowed_methods.is_empty() {
headers.insert(
"Allow".to_string(),
allowed_methods
.iter()
.map(hyper::Method::as_str)
.collect::<Vec<_>>()
.join(", "),
);
}
return Ok(text_error_response(
StatusCode::METHOD_NOT_ALLOWED,
format!("Method {} not allowed", req.method()),
accepts_text,
Some(&headers),
));
}
RouteMatchResult::NotFound => {
return Ok(text_error_response(
StatusCode::NOT_FOUND,
"No HTTP consumer registered for this path",
accepts_text,
None,
));
}
};
let permit = state
.concurrency_limit
.acquire()
.await
.map_err(|e| anyhow!(e))?;
if let Some((expected_user, expected_pass)) = configured_basic_auth(state.basic_auth.as_ref()) {
if let Some(auth_header) = req.headers().get("authorization") {
match auth_header.to_str() {
Ok(auth_str) => {
if let Some(encoded) = auth_str.strip_prefix("Basic ") {
if let Ok(decoded) = general_purpose::STANDARD.decode(encoded) {
if let Ok(credentials) = String::from_utf8(decoded) {
let (user, pass) = if let Some(colon_pos) = credentials.find(':') {
(&credentials[..colon_pos], &credentials[colon_pos + 1..])
} else {
("", "")
};
if user == expected_user && pass == expected_pass {
} else {
return Ok(text_error_response(
StatusCode::UNAUTHORIZED,
"Invalid credentials",
accepts_text,
None,
));
}
} else {
return Ok(text_error_response(
StatusCode::BAD_REQUEST,
"Invalid authorization header encoding",
accepts_text,
None,
));
}
} else {
return Ok(text_error_response(
StatusCode::BAD_REQUEST,
"Invalid base64 encoding in authorization header",
accepts_text,
None,
));
}
} else {
return Ok(text_error_response(
StatusCode::UNAUTHORIZED,
"Missing Basic authentication scheme",
accepts_text,
None,
));
}
}
Err(_) => {
return Ok(text_error_response(
StatusCode::BAD_REQUEST,
"Invalid authorization header encoding",
accepts_text,
None,
));
}
}
} else {
return Ok(text_error_response(
StatusCode::UNAUTHORIZED,
"Missing authorization header",
accepts_text,
None,
));
}
}
let (parts, body) = req.into_parts();
if inline_echo_fast_path_applies(&state, &parts.headers) {
let client_accepts_gzip = request_accepts_gzip(&parts.headers);
return inline_echo_response(
&state,
&parts.headers,
body,
client_accepts_gzip,
accepts_text,
permit,
)
.await;
}
let request_metadata_view = RequestMetadataView {
method: &parts.method,
path: parts.uri.path(),
query: parts.uri.query(),
version: parts.version,
headers: &parts.headers,
conn_info: &conn_info,
};
let mut message_id = None;
if let Some(header_value) = parts.headers.get(state.message_id_header.as_str()) {
if let Ok(s) = header_value.to_str() {
if let Ok(uuid) = Uuid::parse_str(s) {
message_id = Some(uuid.as_u128());
} else if let Ok(n) = u128::from_str_radix(s.trim_start_matches("0x"), 16) {
message_id = Some(n);
} else if let Ok(n) = s.parse::<u128>() {
message_id = Some(n);
}
}
}
let client_accepts_gzip = request_accepts_gzip(&parts.headers);
let mut metadata = HashMap::with_capacity(parts.headers.len() + 6);
let mut content_encoding = None;
metadata.extend([
(HTTP_METHOD.to_string(), parts.method.to_string()),
(HTTP_PATH.to_string(), parts.uri.path().to_string()),
(
HTTP_QUERY.to_string(),
parts.uri.query().unwrap_or("").to_string(),
),
(
HTTP_VERSION.to_string(),
http_version_str(parts.version).to_string(),
),
]);
if let Some(cs) = conn_info.cipher_suite.as_ref() {
metadata.insert("tls_cipher_suite".to_string(), cs.clone());
}
if let Some(pv) = conn_info.protocol_version.as_ref() {
metadata.insert("tls_protocol_version".to_string(), pv.clone());
}
for (key, value) in &parts.headers {
if let Ok(v_str) = value.to_str() {
if key.as_str().eq_ignore_ascii_case("content-encoding") {
content_encoding = Some(v_str.to_string());
}
let k_str = key.as_str();
if k_str == HTTP_METHOD
|| k_str == HTTP_PATH
|| k_str == HTTP_QUERY
|| k_str == HTTP_VERSION
|| k_str.eq_ignore_ascii_case("tls_cipher_suite")
|| k_str.eq_ignore_ascii_case("tls_protocol_version")
{
continue;
}
metadata.insert(k_str.to_string(), v_str.to_string());
}
}
if state.receive_streamable {
if content_encoding.is_some() {
return Ok(text_error_response(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"Compressed streamable HTTP requests are not supported",
accepts_text,
Some(&state.custom_headers),
));
}
let request_format = stream_request_format(&parts.headers);
let response_format = stream_response_format(&parts.headers);
return handle_streamable_request(
body,
metadata,
HttpReceiveStreamConfig {
tx: state.tx.clone(),
inline_publisher: state.inline_publisher.clone(),
fire_and_forget: state.fire_and_forget,
request_timeout: state.request_timeout,
custom_headers: state.custom_headers.clone(),
},
request_format,
response_format,
accepts_text,
permit,
)
.await;
}
let body_collect_timeout = state.request_timeout;
let body_bytes = match tokio::time::timeout(body_collect_timeout, body.collect()).await {
Ok(Ok(b)) => b.to_bytes(),
Ok(Err(e)) => {
return Ok(text_error_response(
StatusCode::BAD_REQUEST,
format!("Failed to read body: {}", e),
accepts_text,
None,
));
}
Err(_) => {
return Ok(text_error_response(
StatusCode::REQUEST_TIMEOUT,
"Timed out reading request body",
accepts_text,
None,
));
}
};
let payload = decompress_if_needed(body_bytes, content_encoding.as_deref())
.map_err(|e| anyhow!("Failed to decompress request body: {}", e))?;
let mut message = CanonicalMessage::new_bytes(payload, message_id);
trace!(
message_id = format!("{:032x}", message.message_id),
"Received HTTP request"
);
message.metadata = metadata;
if let Some(inline_publisher) = state.inline_publisher.as_ref() {
let timeout_duration = state.request_timeout;
drop(permit);
tracing::trace!(
timeout_ms = timeout_duration.as_millis(),
"HTTP handler waiting for inline publisher response"
);
let disposition =
match tokio::time::timeout(timeout_duration, inline_publisher.send(message)).await {
Ok(Ok(Sent::Response(response))) => MessageDisposition::Reply(response),
Ok(Ok(Sent::Ack)) => MessageDisposition::Ack,
Ok(Err(err)) => {
tracing::warn!("HTTP inline publisher failed: {}", err);
MessageDisposition::Nack
}
Err(_) => {
tracing::warn!(
"HTTP handler: inline request timed out after {} ms",
timeout_duration.as_millis()
);
return Ok(text_error_response(
StatusCode::GATEWAY_TIMEOUT,
"Request timed out",
accepts_text,
Some(&state.custom_headers),
));
}
};
return make_response(
disposition,
state.compression_enabled,
client_accepts_gzip,
state.compression_threshold_bytes,
&state.custom_headers,
accepts_text,
Some(&request_metadata_view),
);
}
let fire_and_forget = state.fire_and_forget;
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::<MessageDisposition>();
let commit = Box::new(move |disposition: MessageDisposition| {
Box::pin(async move {
if ack_tx.send(disposition).is_err() && !fire_and_forget {
trace!("HTTP handler was no longer waiting for commit disposition");
}
Ok(())
}) as BoxFuture<'static, anyhow::Result<()>>
});
let send_timeout = std::time::Duration::from_millis(2000).min(state.request_timeout / 2);
match tokio::time::timeout(send_timeout, state.tx.send((message, commit))).await {
Ok(Ok(_)) => {}
Ok(Err(e)) => {
tracing::error!("Failed to send request to bridge (channel closed): {}", e);
return Ok(text_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal pipeline closed",
accepts_text,
None,
));
}
Err(_) => {
tracing::warn!("HTTP handler: internal channel full, request rejected");
return Ok(text_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"Server overloaded",
accepts_text,
Some(&state.custom_headers),
));
}
}
drop(permit);
if state.fire_and_forget {
let mut builder = Response::builder().status(StatusCode::ACCEPTED);
for (header_name, header_value) in &state.custom_headers {
builder = builder.header(header_name.as_str(), header_value.as_str());
}
return Ok(builder
.body(full("Message accepted for processing"))
.unwrap());
}
let timeout_duration = state.request_timeout;
tracing::trace!(
timeout_ms = timeout_duration.as_millis(),
"HTTP handler waiting for disposition"
);
match tokio::time::timeout(timeout_duration, ack_rx).await {
Ok(Ok(disposition)) => make_response(
disposition,
state.compression_enabled,
client_accepts_gzip,
state.compression_threshold_bytes,
&state.custom_headers,
accepts_text,
None,
),
Ok(Err(_)) => {
tracing::error!("HTTP handler: pipeline closed before disposition arrived");
Ok(text_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Pipeline closed",
accepts_text,
Some(&state.custom_headers),
))
}
Err(_) => {
tracing::warn!(
"HTTP handler: request timed out after {} ms",
timeout_duration.as_millis()
);
Ok(text_error_response(
StatusCode::GATEWAY_TIMEOUT,
"Request timed out",
accepts_text,
Some(&state.custom_headers),
))
}
}
}
fn inline_echo_fast_path_applies(state: &HttpConsumerState, headers: &hyper::HeaderMap) -> bool {
if !state.inline_echo || state.receive_streamable {
return false;
}
if headers.contains_key(hyper::header::CONTENT_ENCODING)
|| headers.contains_key(hyper::header::TRANSFER_ENCODING)
|| headers.contains_key(HTTP_STATUS_CODE)
{
return false;
}
if let Some(content_type) = headers.get(hyper::header::CONTENT_TYPE) {
if content_type
.to_str()
.is_ok_and(|value| value.contains("text/event-stream"))
{
return false;
}
}
true
}
async fn inline_echo_response(
state: &HttpConsumerState,
request_headers: &hyper::HeaderMap,
body: Incoming,
client_accepts_gzip: bool,
accepts_text: bool,
permit: tokio::sync::OwnedSemaphorePermit,
) -> anyhow::Result<Response<BoxBody>> {
let body_bytes = match tokio::time::timeout(state.request_timeout, body.collect()).await {
Ok(Ok(collected)) => collected.to_bytes(),
Ok(Err(e)) => {
return Ok(text_error_response(
StatusCode::BAD_REQUEST,
format!("Failed to read body: {}", e),
accepts_text,
None,
));
}
Err(_) => {
return Ok(text_error_response(
StatusCode::REQUEST_TIMEOUT,
"Timed out reading request body",
accepts_text,
None,
));
}
};
drop(permit);
let mut builder = Response::builder().status(StatusCode::OK);
if let Some(content_type) = request_headers.get(hyper::header::CONTENT_TYPE) {
builder = builder.header(hyper::header::CONTENT_TYPE, content_type);
} else {
builder = builder.header(hyper::header::CONTENT_TYPE, "application/octet-stream");
}
let (payload_out, was_compressed) = compress_if_needed(
body_bytes,
state.compression_enabled && client_accepts_gzip,
state.compression_threshold_bytes,
)?;
if was_compressed {
builder = builder.header("Content-Encoding", "gzip");
}
for (header_name, header_value) in &state.custom_headers {
builder = builder.header(header_name.as_str(), header_value.as_str());
}
Ok(builder.body(full(payload_out)).unwrap())
}
fn make_response(
disposition: MessageDisposition,
compression_enabled: bool,
client_accepts_gzip: bool,
compression_threshold_bytes: usize,
custom_headers: &HashMap<String, String>,
accepts_text: bool,
request_metadata: Option<&RequestMetadataView<'_>>,
) -> anyhow::Result<Response<BoxBody>> {
match disposition {
MessageDisposition::Reply(mut msg) => {
let status = msg
.metadata
.remove(HTTP_STATUS_CODE)
.and_then(|s| s.parse::<u16>().ok())
.and_then(|code| StatusCode::from_u16(code).ok())
.unwrap_or(StatusCode::OK);
let mut builder = Response::builder().status(status);
let mut has_content_type = false;
let mut is_streaming = false;
let mut preset_encoding: Option<String> = None;
for (key, value) in &msg.metadata {
let is_content_type = key.eq_ignore_ascii_case("content-type");
if !is_content_type
&& request_metadata
.is_some_and(|metadata| request_metadata_matches(metadata, key, value))
{
continue;
}
if is_content_type {
has_content_type = true;
if value.contains("text/event-stream") {
is_streaming = true;
}
} else if key.eq_ignore_ascii_case("transfer-encoding") && value.contains("chunked")
{
is_streaming = true;
} else if key.eq_ignore_ascii_case("content-encoding")
&& !value.trim().eq_ignore_ascii_case("identity")
{
preset_encoding = Some(value.clone());
}
if !key.eq_ignore_ascii_case("content-encoding")
&& !key.eq_ignore_ascii_case("transfer-encoding")
&& !key.eq_ignore_ascii_case("content-length")
{
builder = builder.header(key.as_str(), value.as_str());
}
}
if !has_content_type && status == StatusCode::OK {
builder = builder.header("content-type", "application/octet-stream");
}
let (payload_out, was_compressed) = if let Some(encoding) = preset_encoding {
builder = builder.header("Content-Encoding", encoding);
(msg.payload, false)
} else {
compress_if_needed(
msg.payload,
compression_enabled && client_accepts_gzip,
compression_threshold_bytes,
)?
};
if was_compressed {
builder = builder.header("Content-Encoding", "gzip");
}
for (header_name, header_value) in custom_headers {
builder = builder.header(header_name.as_str(), header_value.as_str());
}
if is_streaming {
let stream = futures::stream::once(async move {
Ok::<_, anyhow::Error>(Frame::data(payload_out))
});
Ok(builder.body(streamed(stream)).unwrap())
} else {
Ok(builder.body(full(payload_out)).unwrap())
}
}
MessageDisposition::Ack => {
let mut builder = Response::builder().status(StatusCode::ACCEPTED);
for (header_name, header_value) in custom_headers {
builder = builder.header(header_name.as_str(), header_value.as_str());
}
Ok(builder.body(full("Message processed")).unwrap())
}
MessageDisposition::Nack => Ok(text_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Message processing failed",
accepts_text,
Some(custom_headers),
)),
}
}
type HttpClient = hyper_util::client::legacy::Client<
hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>,
http_body_util::Full<Bytes>,
>;
fn build_http_client(config: &HttpConfig) -> anyhow::Result<HttpClient> {
let tls_client_config = create_rustls_client_config(&config.tls)
.context("Failed to create rustls client config")?;
let mut http_connector = HttpConnector::new();
http_connector.enforce_http(false);
http_connector.set_nodelay(true);
if let Some(keepalive) = config.tcp_keepalive_ms {
http_connector.set_keepalive(Some(std::time::Duration::from_millis(keepalive)));
}
let https_connector = HttpsConnectorBuilder::new()
.with_tls_config(tls_client_config)
.https_or_http()
.enable_http1()
.enable_http2()
.wrap_connector(http_connector);
let mut client_builder = hyper_util::client::legacy::Client::builder(TokioExecutor::new());
if let Some(timeout) = config.pool_idle_timeout_ms {
client_builder.pool_idle_timeout(std::time::Duration::from_millis(timeout));
}
Ok(client_builder.build(https_connector))
}
async fn create_shared_http_client(
config: &HttpConfig,
) -> anyhow::Result<std::sync::Arc<HttpClient>> {
let identity = crate::connection_registry::connection_identity((
config.tls.required,
&config.tls.ca_file,
&config.tls.cert_file,
&config.tls.key_file,
config.tls.accept_invalid_certs,
config.tcp_keepalive_ms,
config.pool_idle_timeout_ms,
));
let config_clone = config.clone();
crate::connection_registry::get_or_create(
"http-client",
identity,
config.shared.unwrap_or(true),
move || async move { build_http_client(&config_clone) },
)
.await
}
#[derive(Clone)]
pub struct HttpPublisher {
client: std::sync::Arc<HttpClient>,
url: String,
base_uri: hyper::Uri,
method: hyper::Method,
request_timeout: std::time::Duration,
batch_concurrency: usize,
compression_enabled: bool,
compression_threshold_bytes: usize,
basic_auth_header: Option<String>,
custom_headers: HashMap<String, String>,
stream_response_sink: Option<std::sync::Arc<dyn MessagePublisher>>,
}
impl HttpPublisher {
pub async fn new(config: &HttpConfig) -> anyhow::Result<Self> {
Self::new_with_stream_response_sink(config, None).await
}
pub async fn new_with_stream_response_sink(
config: &HttpConfig,
stream_response_sink: Option<std::sync::Arc<dyn MessagePublisher>>,
) -> anyhow::Result<Self> {
let batch_concurrency = config.batch_concurrency.unwrap_or(20).max(1);
let client = create_shared_http_client(config).await?;
let url = config.tls.normalize_url(&config.url);
let base_uri = url
.parse::<hyper::Uri>()
.map_err(|e| anyhow::anyhow!("Invalid configured URL '{}': {}", url, e))?;
let method = config
.method
.as_deref()
.map(|m| {
hyper::Method::from_bytes(m.as_bytes())
.map_err(|_| anyhow::anyhow!("Invalid config.method: '{}'", m))
})
.transpose()?
.unwrap_or(hyper::Method::POST);
let request_timeout =
std::time::Duration::from_millis(config.request_timeout_ms.unwrap_or(30000));
let compression_threshold_bytes = config.compression_threshold_bytes.unwrap_or(1024);
Ok(Self {
client,
url,
base_uri,
method,
request_timeout,
batch_concurrency,
compression_enabled: config.compression_enabled,
compression_threshold_bytes,
basic_auth_header: basic_auth_header_value(config.basic_auth.as_ref()),
custom_headers: config.custom_headers.clone(),
stream_response_sink,
})
}
}
#[async_trait]
impl MessagePublisher for HttpPublisher {
async fn send(&self, message: CanonicalMessage) -> Result<Sent, PublisherError> {
trace!(
message_id = %format!("{:032x}", message.message_id),
url = %self.url,
"Sending HTTP request"
);
let method = message
.metadata
.get(HTTP_METHOD)
.and_then(|m| hyper::Method::from_bytes(m.as_bytes()).ok())
.unwrap_or_else(|| self.method.clone());
let uri = if let Some(path) = message.metadata.get(HTTP_PATH) {
let mut path_and_query = path.clone();
if let Some(query) = message.metadata.get(HTTP_QUERY) {
if !query.is_empty() {
path_and_query.push('?');
path_and_query.push_str(query);
}
}
let mut builder = hyper::Uri::builder();
if let Some(scheme) = self.base_uri.scheme() {
builder = builder.scheme(scheme.clone());
}
if let Some(authority) = self.base_uri.authority() {
builder = builder.authority(authority.clone());
}
builder
.path_and_query(path_and_query)
.build()
.map_err(|e| {
PublisherError::NonRetryable(anyhow::anyhow!("Failed to build URI: {}", e))
})?
} else {
self.base_uri.clone()
};
let mut request_builder = Request::builder().method(method).uri(uri);
for (key, value) in &message.metadata {
if key == HTTP_METHOD
|| key == HTTP_PATH
|| key == HTTP_QUERY
|| key == HTTP_VERSION
|| key == "tls_cipher_suite"
|| key == "tls_protocol_version"
{
continue;
}
request_builder = request_builder.header(key, value);
}
if let Some(header_value) = self.basic_auth_header.as_deref() {
request_builder = request_builder.header("Authorization", header_value);
}
for (header_name, header_value) in &self.custom_headers {
request_builder = request_builder.header(header_name.as_str(), header_value.as_str());
}
let (payload_out, was_compressed) = compress_if_needed(
message.payload.clone(),
self.compression_enabled,
self.compression_threshold_bytes,
)
.map_err(|e| {
PublisherError::NonRetryable(anyhow::anyhow!("Failed to compress payload: {}", e))
})?;
if was_compressed {
request_builder = request_builder.header("Content-Encoding", "gzip");
}
let body = http_body_util::Full::from(payload_out);
let request = request_builder.body(body).map_err(|e| {
PublisherError::NonRetryable(anyhow::anyhow!("Failed to build request: {}", e))
})?;
let future = tokio::time::timeout(self.request_timeout, self.client.request(request));
let response: hyper::Response<Incoming> = match future.await {
Ok(Ok(resp)) => resp,
Ok(Err(e)) => {
let error = anyhow::anyhow!("Failed to send HTTP request to {}: {}", self.url, e);
return Err(PublisherError::Retryable(error));
}
Err(_) => {
return Err(PublisherError::Retryable(anyhow::anyhow!(
"HTTP request timeout"
)));
}
};
let response_status = response.status();
let stream_response_format = self.stream_response_sink.as_ref().and_then(|_| {
super::http_stream::streaming_response_format_from_headers(response.headers())
});
let mut response_metadata = HashMap::with_capacity(response.headers().len() + 1);
response_metadata.insert(
HTTP_VERSION.to_string(),
format!("{:?}", response.version()),
);
let mut content_encoding = None;
for (key, value) in response.headers() {
if let Ok(value_str) = value.to_str() {
if key.as_str().eq_ignore_ascii_case("content-encoding") {
content_encoding = Some(value_str.to_string());
}
response_metadata.insert(key.as_str().to_string(), value_str.to_string());
}
}
if response_status.is_success() {
if let (Some(stream_response_sink), Some(stream_response_format)) =
(&self.stream_response_sink, stream_response_format)
{
if content_encoding.is_some() {
return Err(PublisherError::Retryable(anyhow::anyhow!(
"Compressed HTTP response streams cannot be published to stream_response_to"
)));
}
let correlation_id = message
.metadata
.get("correlation_id")
.cloned()
.unwrap_or_else(|| format!("{:032x}", message.message_id));
match super::http_stream::publish_response_stream(
response.into_body(),
stream_response_sink.clone(),
response_metadata,
correlation_id,
stream_response_format,
self.request_timeout,
)
.await
{
Ok(()) => {}
Err(PublishResponseStreamError::Partial(error)) => {
tracing::warn!(
"HTTP response stream terminated after partial publish: {}",
error
);
}
Err(PublishResponseStreamError::BeforePublish(error)) => return Err(error),
}
return Ok(Sent::Ack);
}
}
let body_collect_timeout = self.request_timeout;
let response_bytes_raw = match tokio::time::timeout(
body_collect_timeout,
response.into_body().collect(),
)
.await
{
Ok(Ok(collected)) => collected.to_bytes(),
Ok(Err(e)) => {
return Err(PublisherError::Retryable(anyhow::anyhow!(
"Failed to read HTTP response body: {}",
e
)))
}
Err(_) => {
return Err(PublisherError::Retryable(anyhow::anyhow!(
"HTTP response body collection timeout"
)))
}
};
let response_bytes = decompress_if_needed(response_bytes_raw, content_encoding.as_deref())
.map_err(|e| {
PublisherError::Retryable(anyhow::anyhow!("Failed to decompress response: {}", e))
})?;
if !response_status.is_success() {
debug!(
message_id = %format!("{:032x}", message.message_id),
status = %response_status,
"HTTP request failed"
);
let error = anyhow::anyhow!(
"HTTP send request failed with status {}: {:?}",
response_status,
String::from_utf8_lossy(&response_bytes)
);
if response_status.is_client_error() {
return Err(PublisherError::NonRetryable(error));
} else if response_status.is_server_error() {
match response_status.as_u16() {
501 | 505 => return Err(PublisherError::NonRetryable(error)),
_ => return Err(PublisherError::Retryable(error)),
}
}
return Err(PublisherError::NonRetryable(error));
}
trace!(
message_id = %format!("{:032x}", message.message_id),
status = %response_status,
"HTTP request succeeded"
);
let mut response_message =
CanonicalMessage::new_bytes(response_bytes, Some(message.message_id));
response_message.metadata = response_metadata;
Ok(Sent::Response(response_message))
}
async fn send_batch(
&self,
messages: Vec<CanonicalMessage>,
) -> Result<SentBatch, PublisherError> {
use futures::StreamExt;
if messages.is_empty() {
return Ok(SentBatch::Ack);
}
if messages.len() == 1 {
let message = messages.into_iter().next().expect("checked len");
return match self.send(message.clone()).await {
Ok(Sent::Ack) => Ok(SentBatch::Ack),
Ok(Sent::Response(resp)) => Ok(SentBatch::Partial {
responses: Some(vec![resp]),
failed: Vec::new(),
}),
Err(e) => Ok(SentBatch::Partial {
responses: None,
failed: vec![(message, e)],
}),
};
}
trace!(
count = messages.len(),
url = %self.url,
message_ids = ?LazyMessageIds(&messages),
"Publishing batch of HTTP requests"
);
let send_futures = messages.into_iter().map(|message| {
let msg_for_error = message.clone();
async move { self.send(message).await.map_err(|e| (msg_for_error, e)) }
});
let mut stream = futures::stream::iter(send_futures).buffered(self.batch_concurrency);
let mut responses = Vec::new();
let mut failed = Vec::new();
while let Some(result) = stream.next().await {
match result {
Ok(Sent::Response(resp)) => responses.push(resp),
Ok(Sent::Ack) => {}
Err((msg, e)) => {
failed.push((msg, e));
}
}
}
if failed.is_empty() && responses.is_empty() {
Ok(SentBatch::Ack)
} else {
Ok(SentBatch::Partial {
responses: if responses.is_empty() {
None
} else {
Some(responses)
},
failed,
})
}
}
async fn status(&self) -> crate::traits::EndpointStatus {
crate::traits::EndpointStatus {
healthy: true,
target: self.url.clone(),
..Default::default()
}
}
fn as_any(&self) -> &dyn Any {
self
}
}
fn create_rustls_server_config(
tls_config: &TlsConfig,
server_protocol: HttpServerProtocol,
) -> anyhow::Result<Arc<rustls::ServerConfig>> {
let cert_file = tls_config
.cert_file
.as_ref()
.context("TLS cert_file not provided for server")?;
let key_file = tls_config
.key_file
.as_ref()
.context("TLS key_file not provided for server")?;
let certs = load_certs(cert_file)?;
let key = load_private_key(key_file)?;
let config_builder =
rustls::ServerConfig::builder_with_provider(crate::endpoints::get_crypto_provider()?)
.with_safe_default_protocol_versions()?;
let mut config = if let Some(ca_file) = &tls_config.ca_file {
let mut client_auth_roots = rustls::RootCertStore::empty();
let mut pem = BufReader::new(File::open(ca_file).with_context(|| {
format!(
"Failed to open CA file for client verification: {}",
ca_file
)
})?);
for cert in rustls_pemfile::certs(&mut pem) {
client_auth_roots.add(cert?)?;
}
let client_verifier =
rustls::server::WebPkiClientVerifier::builder(std::sync::Arc::new(client_auth_roots))
.build()
.context("Failed to build client certificate verifier")?;
config_builder
.with_client_cert_verifier(client_verifier)
.with_single_cert(certs, key)
.context("Failed to build rustls mTLS server config")?
} else {
config_builder
.with_no_client_auth()
.with_single_cert(certs, key)
.context("Failed to build rustls server config")?
};
config.alpn_protocols = match server_protocol {
HttpServerProtocol::Auto => vec![b"h2".to_vec(), b"http/1.1".to_vec()],
HttpServerProtocol::Http1Only => vec![b"http/1.1".to_vec()],
HttpServerProtocol::Http2Only => vec![b"h2".to_vec()],
};
Ok(Arc::new(config))
}
fn create_rustls_client_config(tls_config: &TlsConfig) -> anyhow::Result<rustls::ClientConfig> {
let mut root_cert_store = rustls::RootCertStore::empty();
if let Some(ca_file) = &tls_config.ca_file {
let mut pem = BufReader::new(
File::open(ca_file).with_context(|| format!("Failed to open CA file: {}", ca_file))?,
);
for cert in rustls_pemfile::certs(&mut pem) {
root_cert_store.add(cert?)?;
}
} else {
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
}
let config_builder =
rustls::ClientConfig::builder_with_provider(crate::endpoints::get_crypto_provider()?)
.with_safe_default_protocol_versions()?
.with_root_certificates(root_cert_store);
if let (Some(cert_file), Some(key_file)) = (&tls_config.cert_file, &tls_config.key_file) {
let certs = load_certs(cert_file)?;
let key = load_private_key(key_file)?;
config_builder
.with_client_auth_cert(certs, key)
.context("Failed to build mTLS client config")
} else {
Ok(config_builder.with_no_client_auth())
}
}
fn load_certs(path: &str) -> anyhow::Result<Vec<CertificateDer<'static>>> {
let mut cert_file = BufReader::new(
File::open(path).with_context(|| format!("Cannot open cert file {}", path))?,
);
let certs = rustls_pemfile::certs(&mut cert_file).collect::<Result<Vec<_>, _>>()?;
Ok(certs)
}
fn load_private_key(path: &str) -> anyhow::Result<PrivateKeyDer<'static>> {
let mut key_file =
BufReader::new(File::open(path).with_context(|| format!("Cannot open key file {}", path))?);
rustls_pemfile::private_key(&mut key_file)?.context("No private key found in file")
}
#[cfg(feature = "http")]
fn compress_if_needed(
data: Bytes,
compression_enabled: bool,
threshold: usize,
) -> anyhow::Result<(Bytes, bool)> {
if !compression_enabled || data.len() < threshold {
return Ok((data, false));
}
use flate2::Compression;
use std::io::Write;
let mut encoder =
flate2::write::GzEncoder::new(Vec::with_capacity(data.len() / 2 + 64), Compression::fast());
encoder.write_all(&data)?;
let compressed = encoder.finish()?;
if compressed.len() < data.len() {
Ok((Bytes::from(compressed), true))
} else {
Ok((data, false))
}
}
#[cfg(feature = "http")]
fn decompress_if_needed(data: Bytes, content_encoding: Option<&str>) -> anyhow::Result<Bytes> {
if let Some(encoding) = content_encoding {
if encoding.to_lowercase().contains("gzip") {
use std::io::Read;
let mut decoder = flate2::read::GzDecoder::new(&data[..]);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed)?;
return Ok(Bytes::from(decompressed));
}
}
Ok(data)
}
#[cfg(feature = "http")]
fn base64_encode(data: &[u8]) -> String {
general_purpose::STANDARD.encode(data)
}
fn configured_basic_auth(basic_auth: Option<&(String, String)>) -> Option<(&str, &str)> {
basic_auth.and_then(|(username, password)| {
if username.is_empty() && password.is_empty() {
None
} else {
Some((username.as_str(), password.as_str()))
}
})
}
fn basic_auth_header_value(basic_auth: Option<&(String, String)>) -> Option<String> {
configured_basic_auth(basic_auth).map(|(username, password)| {
let credentials = format!("{}:{}", username, password);
format!("Basic {}", base64_encode(credentials.as_bytes()))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::endpoints::{create_consumer_from_route, create_publisher_from_route};
use crate::models::{Config, Endpoint, EndpointType, StreamBufferConfig};
use hyper::header::{ACCEPT, ACCEPT_ENCODING, CONTENT_TYPE};
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
fn get_free_port() -> u16 {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
}
async fn wait_for_server_ready(addr: &str, timeout: Duration) -> bool {
let start = Instant::now();
while start.elapsed() < timeout {
if tokio::net::TcpStream::connect(addr).await.is_ok() {
return true;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
false
}
fn raw_text_static_endpoint(body: &str) -> Endpoint {
let mut metadata = HashMap::new();
metadata.insert("content-type".to_string(), "text/plain".to_string());
Endpoint::new(EndpointType::Static(crate::models::StaticConfig {
body: body.to_string(),
raw: true,
metadata,
}))
}
fn init_crypto() {
#[cfg(feature = "rustls-aws-lc")]
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
#[cfg(all(feature = "rustls-ring", not(feature = "rustls-aws-lc")))]
let _ = rustls::crypto::ring::default_provider().install_default();
}
#[test]
fn test_http_config_yaml() {
let yaml = r#"
http_route:
input:
http:
url: "127.0.0.1:8080"
output:
http:
url: "http://localhost:9090"
"#;
let config: Config = serde_yaml_ng::from_str(yaml).expect("Failed to parse YAML");
let route = config.get("http_route").expect("Route not found");
match &route.input.endpoint_type {
EndpointType::Http(cfg) => {
assert_eq!(cfg.url, "127.0.0.1:8080".to_string());
}
_ => panic!("Expected HTTP input"),
}
match &route.output.endpoint_type {
EndpointType::Http(cfg) => {
assert_eq!(cfg.url, "http://localhost:9090".to_string());
}
_ => panic!("Expected HTTP output"),
}
}
#[test]
fn test_http_config_yaml_server_protocol() {
let yaml = r#"
http_route:
input:
http:
url: "127.0.0.1:8080"
server_protocol: http2_only
output:
response: {}
"#;
let config: Config = serde_yaml_ng::from_str(yaml).expect("Failed to parse YAML");
let route = config.get("http_route").expect("Route not found");
match &route.input.endpoint_type {
EndpointType::Http(cfg) => {
assert_eq!(cfg.server_protocol, HttpServerProtocol::Http2Only);
}
_ => panic!("Expected HTTP input"),
}
}
#[test]
fn test_guess_content_type_from_path() {
assert_eq!(
guess_content_type("/assets/app.bundle.js"),
"text/javascript; charset=utf-8"
);
assert_eq!(guess_content_type("images/logo.SVG"), "image/svg+xml");
}
#[test]
fn test_guess_content_type_from_extension() {
assert_eq!(guess_content_type("html"), "text/html; charset=utf-8");
assert_eq!(guess_content_type(".woff2"), "font/woff2");
assert_eq!(
guess_content_type("JSON"),
"application/json; charset=utf-8"
);
}
#[test]
fn test_guess_content_type_unknown_defaults_to_octet_stream() {
assert_eq!(guess_content_type(""), "application/octet-stream");
assert_eq!(
guess_content_type("unknown-ext"),
"application/octet-stream"
);
assert_eq!(
guess_content_type("archive.custombin"),
"application/octet-stream"
);
}
#[test]
fn test_request_accepts_text_defaults_true_without_accept_header() {
let headers = hyper::HeaderMap::new();
assert!(request_accepts_text(&headers));
}
#[test]
fn test_request_accepts_text_matches_text_and_wildcards() {
let mut headers = hyper::HeaderMap::new();
headers.insert(ACCEPT, "application/json, text/plain".parse().unwrap());
assert!(request_accepts_text(&headers));
headers.insert(ACCEPT, "*/*".parse().unwrap());
assert!(request_accepts_text(&headers));
}
#[test]
fn test_request_accepts_text_rejects_binary_only_accept_header() {
let mut headers = hyper::HeaderMap::new();
headers.insert(ACCEPT, "application/octet-stream".parse().unwrap());
assert!(!request_accepts_text(&headers));
}
#[test]
fn test_request_accepts_gzip_false_without_header() {
let headers = hyper::HeaderMap::new();
assert!(!request_accepts_gzip(&headers));
}
#[test]
fn test_request_accepts_gzip_matches_gzip_and_wildcard() {
let mut headers = hyper::HeaderMap::new();
headers.insert(ACCEPT_ENCODING, "gzip, deflate, br".parse().unwrap());
assert!(request_accepts_gzip(&headers));
headers.insert(ACCEPT_ENCODING, "deflate, gzip;q=0.8".parse().unwrap());
assert!(request_accepts_gzip(&headers));
headers.insert(ACCEPT_ENCODING, "*".parse().unwrap());
assert!(request_accepts_gzip(&headers));
}
#[test]
fn test_request_accepts_gzip_honors_q_zero_and_other_codings() {
let mut headers = hyper::HeaderMap::new();
headers.insert(ACCEPT_ENCODING, "gzip;q=0".parse().unwrap());
assert!(!request_accepts_gzip(&headers));
headers.insert(ACCEPT_ENCODING, "*;q=0".parse().unwrap());
assert!(!request_accepts_gzip(&headers));
headers.insert(ACCEPT_ENCODING, "br, deflate".parse().unwrap());
assert!(!request_accepts_gzip(&headers));
}
#[test]
fn test_text_error_response_sets_text_content_type_when_accepted() {
let response = text_error_response(StatusCode::BAD_REQUEST, "bad request", true, None);
assert_eq!(
response.headers().get(CONTENT_TYPE).unwrap(),
"text/plain; charset=utf-8"
);
}
#[test]
fn test_text_error_response_skips_text_content_type_when_not_accepted() {
let response = text_error_response(StatusCode::BAD_REQUEST, "bad request", false, None);
assert!(response.headers().get(CONTENT_TYPE).is_none());
}
#[test]
fn test_text_error_response_preserves_custom_content_type() {
let mut headers = HashMap::new();
headers.insert(
"content-type".to_string(),
"application/problem+json".to_string(),
);
let response =
text_error_response(StatusCode::BAD_REQUEST, "bad request", true, Some(&headers));
assert_eq!(
response.headers().get(CONTENT_TYPE).unwrap(),
"application/problem+json"
);
}
#[test]
fn test_basic_auth_header_value_omits_empty_credentials() {
let empty = (String::new(), String::new());
assert_eq!(basic_auth_header_value(Some(&empty)), None);
assert_eq!(basic_auth_header_value(None), None);
}
#[test]
fn test_configured_basic_auth_omits_empty_credentials() {
let empty = (String::new(), String::new());
assert_eq!(configured_basic_auth(Some(&empty)), None);
assert_eq!(configured_basic_auth(None), None);
}
#[test]
fn test_configured_basic_auth_keeps_non_empty_credentials() {
let creds = ("user".to_string(), "pass".to_string());
assert_eq!(configured_basic_auth(Some(&creds)), Some(("user", "pass")));
}
#[test]
fn test_basic_auth_header_value_encodes_configured_credentials() {
let creds = ("user".to_string(), "pass".to_string());
assert_eq!(
basic_auth_header_value(Some(&creds)).as_deref(),
Some("Basic dXNlcjpwYXNz")
);
}
#[tokio::test]
async fn test_http_consumer_publisher_integration() {
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let url = format!("http://{}", addr);
let config = HttpConfig {
url: addr.clone(),
..Default::default()
};
let mut consumer = HttpConsumer::new(&config)
.await
.expect("Failed to create consumer");
let pub_config = HttpConfig {
url: url.clone(),
..Default::default()
};
let publisher = HttpPublisher::new(&pub_config)
.await
.expect("Failed to create publisher");
let msg_payload = b"test_payload".to_vec();
let msg = CanonicalMessage::new(msg_payload.clone(), None);
let receive_task = tokio::spawn(async move {
let received = consumer.receive().await.expect("Failed to receive");
let response_msg = CanonicalMessage::new(b"response_payload".to_vec(), None);
let _ = (received.commit)(crate::traits::MessageDisposition::Reply(response_msg)).await;
received.message
});
let response = publisher.send(msg).await.expect("Failed to send");
let received_msg = receive_task.await.expect("Receive task failed");
assert_eq!(received_msg.payload, msg_payload);
let response = match response {
Sent::Response(msg) => msg,
_ => panic!("Expected response"),
};
assert_eq!(response.payload, b"response_payload".to_vec());
}
#[tokio::test]
async fn test_http_receive_streamable_sse_items_share_correlation_id() {
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let url = format!("http://{}", addr);
let config = HttpConfig {
url: addr.clone(),
receive_streamable: true,
..Default::default()
};
let mut consumer = HttpConsumer::new(&config)
.await
.expect("Failed to create consumer");
let publisher = HttpPublisher::new(&HttpConfig {
url,
..Default::default()
})
.await
.expect("Failed to create publisher");
let receive_task = tokio::spawn(async move {
let first = consumer.receive().await.expect("first stream item");
let second = consumer.receive().await.expect("second stream item");
assert_eq!(first.message.get_payload_str(), "first");
assert_eq!(second.message.get_payload_str(), "second");
assert_ne!(first.message.message_id, second.message.message_id);
let first_correlation = first
.message
.metadata
.get("correlation_id")
.cloned()
.expect("first correlation_id");
let second_correlation = second
.message
.metadata
.get("correlation_id")
.cloned()
.expect("second correlation_id");
assert_eq!(first_correlation, second_correlation);
assert_eq!(
first
.message
.metadata
.get("http_stream_index")
.map(String::as_str),
Some("0")
);
assert_eq!(
second
.message
.metadata
.get("http_stream_index")
.map(String::as_str),
Some("1")
);
assert_eq!(
second.message.metadata.get("sse_id").map(String::as_str),
Some("evt-2")
);
assert_eq!(
second.message.metadata.get("sse_event").map(String::as_str),
Some("update")
);
let first_reply = CanonicalMessage::from_vec("reply-first");
let second_reply = CanonicalMessage::from_vec("reply-second");
(first.commit)(MessageDisposition::Reply(first_reply))
.await
.expect("commit first reply");
(second.commit)(MessageDisposition::Reply(second_reply))
.await
.expect("commit second reply");
});
let request =
CanonicalMessage::from_vec("data: first\n\nid: evt-2\nevent: update\ndata: second\n\n")
.with_metadata_kv("content-type", "text/event-stream")
.with_metadata_kv("accept", "text/event-stream")
.with_metadata_kv("correlation_id", "shared-stream-correlation");
let response = publisher
.send(request)
.await
.expect("stream request succeeds");
receive_task.await.expect("receive task finished");
let response = match response {
Sent::Response(message) => message,
Sent::Ack => panic!("expected streamed HTTP response body"),
};
let body = response.get_payload_str();
assert!(body.contains("data: reply-first"));
assert!(body.contains("data: reply-second"));
assert_eq!(
response.metadata.get("content-type").map(String::as_str),
Some("text/event-stream")
);
}
#[tokio::test]
async fn test_http_publisher_stream_response_to_sink() {
init_crypto();
let port = get_free_port();
let bind_addr = format!("127.0.0.1:{}", port);
let listener = TcpListener::bind(&bind_addr)
.await
.expect("bind test server");
let addr = listener.local_addr().expect("test server addr");
let server_task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept test request");
let io = TokioIo::new(stream);
let service = hyper::service::service_fn(|_req: Request<Incoming>| async move {
let stream = futures::stream::iter(vec![
Ok::<_, anyhow::Error>(Frame::data(Bytes::from_static(
b"id: one\ndata: alpha\n\n",
))),
Ok::<_, anyhow::Error>(Frame::data(Bytes::from_static(
b"id: two\nevent: delta\ndata: beta\n\n",
))),
]);
Ok::<_, anyhow::Error>(
Response::builder()
.status(StatusCode::OK)
.header("content-type", "text/event-stream")
.body(streamed(stream))
.unwrap(),
)
});
let builder = AutoBuilder::new(TokioExecutor::new());
builder
.serve_connection(io, service)
.await
.expect("serve test response");
});
let sink_endpoint = Endpoint::new_memory(
&format!("http_stream_sink_{}", fast_uuid_v7::gen_id_str()),
10,
);
let mut sink_consumer = create_consumer_from_route("http_stream_sink", &sink_endpoint)
.await
.expect("create stream sink consumer");
let publisher_endpoint = Endpoint::new(EndpointType::Http(HttpConfig {
url: format!("http://{}", addr),
stream_response_to: Some(Box::new(sink_endpoint)),
..Default::default()
}));
let publisher = create_publisher_from_route("http_stream_publisher", &publisher_endpoint)
.await
.expect("create http publisher");
let sent = publisher
.send(
CanonicalMessage::from_vec("prompt")
.with_metadata_kv("correlation_id", "llm-stream-1"),
)
.await
.expect("publish request");
assert!(matches!(sent, Sent::Ack));
server_task.abort();
let _ = server_task.await;
let first = sink_consumer
.receive()
.await
.expect("first streamed response");
assert_eq!(first.message.get_payload_str(), "alpha");
assert_eq!(
first
.message
.metadata
.get("correlation_id")
.map(String::as_str),
Some("llm-stream-1")
);
assert_eq!(
first
.message
.metadata
.get("http_stream_index")
.map(String::as_str),
Some("0")
);
assert_eq!(
first
.message
.metadata
.get("http_stream_end")
.map(String::as_str),
Some("false")
);
(first.commit)(MessageDisposition::Ack).await.unwrap();
let second = sink_consumer
.receive()
.await
.expect("second streamed response");
assert_eq!(second.message.get_payload_str(), "beta");
assert_eq!(
second.message.metadata.get("sse_event").map(String::as_str),
Some("delta")
);
assert_eq!(
second
.message
.metadata
.get("http_stream_index")
.map(String::as_str),
Some("1")
);
(second.commit)(MessageDisposition::Ack).await.unwrap();
let end = sink_consumer.receive().await.expect("stream end marker");
assert!(end.message.payload.is_empty());
assert_eq!(
end.message
.metadata
.get("http_stream_end")
.map(String::as_str),
Some("true")
);
assert_eq!(
end.message
.metadata
.get("http_stream_index")
.map(String::as_str),
Some("2")
);
(end.commit)(MessageDisposition::Ack).await.unwrap();
}
#[tokio::test]
async fn test_http_publisher_stream_response_to_stream_buffer_isolates_parallel_responses() {
init_crypto();
let port = get_free_port();
let bind_addr = format!("127.0.0.1:{}", port);
let listener = TcpListener::bind(&bind_addr)
.await
.expect("bind test server");
let addr = listener.local_addr().expect("test server addr");
let server_task = tokio::spawn(async move {
let mut tasks = Vec::new();
for _ in 0..2 {
let (stream, _) = listener.accept().await.expect("accept test request");
tasks.push(tokio::spawn(async move {
let io = TokioIo::new(stream);
let service = hyper::service::service_fn(|req: Request<Incoming>| async move {
let path = req.uri().path().trim_start_matches('/').to_string();
let first = format!("data: {}-1\n\n", path);
let second = format!("data: {}-2\n\n", path);
let stream = futures::stream::iter(vec![
Ok::<_, anyhow::Error>(Frame::data(Bytes::from(first))),
Ok::<_, anyhow::Error>(Frame::data(Bytes::from(second))),
]);
Ok::<_, anyhow::Error>(
Response::builder()
.status(StatusCode::OK)
.header("content-type", "text/event-stream")
.body(streamed(stream))
.unwrap(),
)
});
let builder = AutoBuilder::new(TokioExecutor::new());
builder
.serve_connection(io, service)
.await
.expect("serve test response");
}));
}
for task in tasks {
let _ = task.await;
}
});
let topic = format!("http_stream_buffer_parallel_{}", fast_uuid_v7::gen_id_str());
let sink_endpoint = Endpoint::new(EndpointType::StreamBuffer(StreamBufferConfig {
topic: topic.clone(),
correlation_id: None,
capacity: Some(20),
}));
let mut consumer_a = create_consumer_from_route(
"http_stream_buffer_a",
&Endpoint::new(EndpointType::StreamBuffer(StreamBufferConfig {
topic: topic.clone(),
correlation_id: Some("stream-a".to_string()),
capacity: Some(20),
})),
)
.await
.expect("create stream-a consumer");
let mut consumer_b = create_consumer_from_route(
"http_stream_buffer_b",
&Endpoint::new(EndpointType::StreamBuffer(StreamBufferConfig {
topic: topic.clone(),
correlation_id: Some("stream-b".to_string()),
capacity: Some(20),
})),
)
.await
.expect("create stream-b consumer");
let publisher_endpoint = Endpoint::new(EndpointType::Http(HttpConfig {
url: format!("http://{}", addr),
stream_response_to: Some(Box::new(sink_endpoint)),
..Default::default()
}));
let publisher: std::sync::Arc<dyn MessagePublisher> =
create_publisher_from_route("http_stream_buffer_publisher", &publisher_endpoint)
.await
.expect("create http publisher");
let send_a = {
let publisher = publisher.clone();
tokio::spawn(async move {
publisher
.send(
CanonicalMessage::from_vec("prompt-a")
.with_metadata_kv("http_path", "/a")
.with_metadata_kv("correlation_id", "stream-a"),
)
.await
.expect("send stream-a")
})
};
let send_b = {
let publisher = publisher.clone();
tokio::spawn(async move {
publisher
.send(
CanonicalMessage::from_vec("prompt-b")
.with_metadata_kv("http_path", "/b")
.with_metadata_kv("correlation_id", "stream-b"),
)
.await
.expect("send stream-b")
})
};
assert!(matches!(send_a.await.expect("join stream-a"), Sent::Ack));
assert!(matches!(send_b.await.expect("join stream-b"), Sent::Ack));
server_task.abort();
let _ = server_task.await;
let mut stream_a_payloads = Vec::new();
loop {
let received = consumer_a.receive().await.expect("stream-a item");
let is_end = received
.message
.metadata
.get("http_stream_end")
.is_some_and(|value| value == "true");
assert_eq!(
received
.message
.metadata
.get("correlation_id")
.map(String::as_str),
Some("stream-a")
);
if !is_end {
stream_a_payloads.push(received.message.get_payload_str().to_string());
}
(received.commit)(MessageDisposition::Ack).await.unwrap();
if is_end {
break;
}
}
let mut stream_b_payloads = Vec::new();
loop {
let received = consumer_b.receive().await.expect("stream-b item");
let is_end = received
.message
.metadata
.get("http_stream_end")
.is_some_and(|value| value == "true");
assert_eq!(
received
.message
.metadata
.get("correlation_id")
.map(String::as_str),
Some("stream-b")
);
if !is_end {
stream_b_payloads.push(received.message.get_payload_str().to_string());
}
(received.commit)(MessageDisposition::Ack).await.unwrap();
if is_end {
break;
}
}
assert_eq!(stream_a_payloads, vec!["a-1", "a-2"]);
assert_eq!(stream_b_payloads, vec!["b-1", "b-2"]);
}
#[tokio::test]
async fn test_http_publisher_stream_response_to_stream_buffer_uses_message_id_fallback() {
init_crypto();
let port = get_free_port();
let bind_addr = format!("127.0.0.1:{}", port);
let listener = TcpListener::bind(&bind_addr)
.await
.expect("bind test server");
let addr = listener.local_addr().expect("test server addr");
let server_task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept test request");
let io = TokioIo::new(stream);
let service = hyper::service::service_fn(|_req: Request<Incoming>| async move {
let stream = futures::stream::iter(vec![Ok::<_, anyhow::Error>(Frame::data(
Bytes::from_static(b"data: fallback\n\n"),
))]);
Ok::<_, anyhow::Error>(
Response::builder()
.status(StatusCode::OK)
.header("content-type", "text/event-stream")
.body(streamed(stream))
.unwrap(),
)
});
let builder = AutoBuilder::new(TokioExecutor::new());
builder
.serve_connection(io, service)
.await
.expect("serve test response");
});
let topic = format!("http_stream_buffer_fallback_{}", fast_uuid_v7::gen_id_str());
let sink_endpoint = Endpoint::new(EndpointType::StreamBuffer(StreamBufferConfig {
topic: topic.clone(),
correlation_id: None,
capacity: Some(10),
}));
let publisher_endpoint = Endpoint::new(EndpointType::Http(HttpConfig {
url: format!("http://{}", addr),
stream_response_to: Some(Box::new(sink_endpoint)),
..Default::default()
}));
let publisher =
create_publisher_from_route("http_stream_fallback_publisher", &publisher_endpoint)
.await
.expect("create http publisher");
let request = CanonicalMessage::from_vec("prompt");
let expected_correlation_id = format!("{:032x}", request.message_id);
let mut consumer = create_consumer_from_route(
"http_stream_fallback_consumer",
&Endpoint::new(EndpointType::StreamBuffer(StreamBufferConfig {
topic: topic.clone(),
correlation_id: Some(expected_correlation_id.clone()),
capacity: Some(10),
})),
)
.await
.expect("create fallback consumer");
let sent = publisher.send(request).await.expect("send request");
assert!(matches!(sent, Sent::Ack));
server_task.abort();
let _ = server_task.await;
let item = consumer.receive().await.expect("fallback stream item");
assert_eq!(item.message.get_payload_str(), "fallback");
assert_eq!(
item.message
.metadata
.get("correlation_id")
.map(String::as_str),
Some(expected_correlation_id.as_str())
);
(item.commit)(MessageDisposition::Ack).await.unwrap();
let end = consumer.receive().await.expect("fallback end marker");
assert_eq!(
end.message
.metadata
.get("http_stream_end")
.map(String::as_str),
Some("true")
);
assert_eq!(
end.message
.metadata
.get("correlation_id")
.map(String::as_str),
Some(expected_correlation_id.as_str())
);
(end.commit)(MessageDisposition::Ack).await.unwrap();
}
#[tokio::test]
async fn test_http_server_shutdown_on_drop() {
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let config = HttpConfig {
url: addr.clone(),
..Default::default()
};
{
let _consumer = HttpConsumer::new(&config)
.await
.expect("Failed to create consumer");
assert!(tokio::net::TcpStream::connect(&addr).await.is_ok());
}
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(tokio::net::TcpStream::connect(&addr).await.is_err());
}
#[tokio::test]
async fn test_http2_only_listener_accepts_h2c_prior_knowledge() {
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let input = Endpoint::new(EndpointType::Http(HttpConfig {
url: addr.clone(),
path: Some("/h2c".to_string()),
server_protocol: HttpServerProtocol::Http2Only,
..Default::default()
}));
let output = raw_text_static_endpoint("h2c-ok");
let handle = crate::Route::new(input, output)
.run("test_http2_only_h2c_prior_knowledge")
.await
.unwrap();
assert!(wait_for_server_ready(&addr, Duration::from_secs(5)).await);
let stream = tokio::net::TcpStream::connect(&addr).await.unwrap();
let (mut client, connection) = h2::client::handshake(stream).await.unwrap();
let connection_task = tokio::spawn(connection);
let request = Request::builder()
.method("GET")
.uri(format!("http://{addr}/h2c"))
.body(())
.unwrap();
let (response, _) = client.send_request(request, true).unwrap();
let response = response.await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let mut body = response.into_body();
let mut bytes = Vec::new();
while let Some(chunk) = body.data().await {
bytes.extend_from_slice(&chunk.unwrap());
}
assert_eq!(bytes, b"h2c-ok");
connection_task.abort();
let _ = connection_task.await;
handle.stop().await;
let _ = handle.join().await;
}
#[tokio::test]
async fn test_http2_only_listener_rejects_plain_http11() {
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let input = Endpoint::new(EndpointType::Http(HttpConfig {
url: addr.clone(),
path: Some("/h2c-only".to_string()),
server_protocol: HttpServerProtocol::Http2Only,
..Default::default()
}));
let output = raw_text_static_endpoint("should-not-be-served-over-http1");
let handle = crate::Route::new(input, output)
.run("test_http2_only_rejects_http11")
.await
.unwrap();
assert!(wait_for_server_ready(&addr, Duration::from_secs(5)).await);
let mut stream = tokio::net::TcpStream::connect(&addr).await.unwrap();
stream
.write_all(
format!("GET /h2c-only HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n")
.as_bytes(),
)
.await
.unwrap();
let _ = stream.shutdown().await;
let response = tokio::time::timeout(Duration::from_secs(2), async {
let mut buf = Vec::new();
stream.read_to_end(&mut buf).await.map(|_| buf)
})
.await
.expect("HTTP/1.1 rejection should complete promptly");
match response {
Ok(buf) => {
let text = String::from_utf8_lossy(&buf);
assert!(
!text.contains(" 200 ")
&& !text.starts_with("HTTP/1.1 200")
&& !text.starts_with("HTTP/1.0 200"),
"Http2Only listener unexpectedly returned success: {text:?}"
);
}
Err(err) => {
assert!(
matches!(
err.kind(),
std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::UnexpectedEof
| std::io::ErrorKind::BrokenPipe
),
"unexpected HTTP/1.1 read error: {err}"
);
}
}
handle.stop().await;
let _ = handle.join().await;
}
#[tokio::test]
async fn test_http_default_auto_listener_accepts_http11() {
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let input = Endpoint::new(EndpointType::Http(HttpConfig {
url: addr.clone(),
path: Some("/auto-http1".to_string()),
..Default::default()
}));
let output = raw_text_static_endpoint("auto-ok");
let handle = crate::Route::new(input, output)
.run("test_http_default_auto_accepts_http11")
.await
.unwrap();
assert!(wait_for_server_ready(&addr, Duration::from_secs(5)).await);
let mut stream = tokio::net::TcpStream::connect(&addr).await.unwrap();
stream
.write_all(
format!("GET /auto-http1 HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n")
.as_bytes(),
)
.await
.unwrap();
let mut response = vec![0; 1024];
let bytes_read = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut response))
.await
.expect("HTTP/1.1 response should arrive promptly")
.unwrap();
let text = String::from_utf8_lossy(&response[..bytes_read]);
assert!(
text.starts_with("HTTP/1.1 200"),
"default auto listener did not serve HTTP/1.1 successfully: {text:?}"
);
assert!(text.contains("auto-ok"));
handle.stop().await;
let _ = handle.join().await;
}
#[tokio::test]
async fn test_http_to_static_response() {
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let http_config = HttpConfig {
url: addr.clone(),
..Default::default()
};
let mut consumer = HttpConsumer::new(&http_config).await.unwrap();
let static_content = "This is a static response";
let static_publisher = crate::endpoints::static_endpoint::StaticEndpointPublisher::new(
&crate::models::StaticConfig::from(static_content),
)
.unwrap();
tokio::spawn(async move {
if let Ok(received) = consumer.receive().await {
let static_response_outcome =
static_publisher.send(received.message).await.unwrap();
let disposition = match static_response_outcome {
Sent::Response(msg) => crate::traits::MessageDisposition::Reply(msg),
Sent::Ack => crate::traits::MessageDisposition::Ack,
};
let _ = (received.commit)(disposition).await;
}
});
tokio::time::sleep(Duration::from_millis(50)).await;
}
#[tokio::test]
async fn test_http_to_response_endpoint() {
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let http_config = HttpConfig {
url: addr.clone(),
..Default::default()
};
let mut consumer = HttpConsumer::new(&http_config).await.unwrap();
let response_endpoint =
crate::models::Endpoint::new(EndpointType::Response(crate::models::ResponseConfig {}));
let publisher = create_publisher_from_route("test_response", &response_endpoint)
.await
.unwrap();
tokio::spawn(async move {
if let Ok(received) = consumer.receive().await {
let outcome = publisher.send(received.message).await.unwrap();
let disposition = match outcome {
Sent::Response(msg) => crate::traits::MessageDisposition::Reply(msg),
Sent::Ack => crate::traits::MessageDisposition::Ack,
};
let _ = (received.commit)(disposition).await;
}
});
tokio::time::sleep(Duration::from_millis(50)).await;
}
#[tokio::test]
async fn test_http_route_inline_response_does_not_echo_unchanged_request_headers() {
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let input = Endpoint::new(EndpointType::Http(HttpConfig {
url: addr.clone(),
path: Some("/inline".to_string()),
..Default::default()
}));
let output = Endpoint::new(EndpointType::Response(
crate::models::ResponseConfig::default(),
));
let route = crate::Route::new(input, output);
let handle = route.run("test_http_inline_fast_path").await.unwrap();
let mut connector = HttpConnector::new();
connector.set_nodelay(true);
let client =
hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build(connector);
let request = Request::builder()
.method(hyper::Method::POST)
.uri(format!("http://{addr}/inline"))
.header("content-type", "application/json")
.header("accept", "application/octet-stream")
.header("x-request-id", "req-123")
.body(http_body_util::Full::<Bytes>::new(Bytes::from_static(
br#"{"value":1}"#,
)))
.unwrap();
let response = client.request(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"application/json"
);
assert!(response.headers().get("x-request-id").is_none());
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body, Bytes::from_static(br#"{"value":1}"#));
handle.stop().await;
let _ = handle.join().await;
}
#[tokio::test]
async fn test_http_route_inline_response_can_be_disabled() {
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let input = Endpoint::new(EndpointType::Http(HttpConfig {
url: addr.clone(),
path: Some("/inline-disabled".to_string()),
inline_response_fast_path: Some(false),
..Default::default()
}));
let output = Endpoint::new(EndpointType::Response(
crate::models::ResponseConfig::default(),
));
let route = crate::Route::new(input, output);
let handle = route
.run("test_http_inline_fast_path_disabled")
.await
.unwrap();
let mut connector = HttpConnector::new();
connector.set_nodelay(true);
let client =
hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build(connector);
let request = Request::builder()
.method(hyper::Method::POST)
.uri(format!("http://{addr}/inline-disabled"))
.header("content-type", "application/json")
.header("accept", "application/octet-stream")
.header("x-request-id", "req-123")
.body(http_body_util::Full::<Bytes>::new(Bytes::from_static(
br#"{"value":1}"#,
)))
.unwrap();
let response = client.request(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"application/json"
);
assert_eq!(response.headers().get("x-request-id").unwrap(), "req-123");
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body, Bytes::from_static(br#"{"value":1}"#));
handle.stop().await;
let _ = handle.join().await;
}
#[tokio::test]
async fn test_http_to_static_raw_sets_content_type_handler_free() {
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let input = Endpoint::new(EndpointType::Http(HttpConfig {
url: addr.clone(),
path: Some("/plaintext".to_string()),
..Default::default()
}));
let mut metadata = HashMap::new();
metadata.insert("content-type".to_string(), "text/plain".to_string());
let output = Endpoint::new(EndpointType::Static(crate::models::StaticConfig {
body: "Hello, World!".to_string(),
raw: true,
metadata,
}));
let route = crate::Route::new(input, output);
let handle = route.run("test_http_to_static_raw").await.unwrap();
let mut connector = HttpConnector::new();
connector.set_nodelay(true);
let client =
hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build(connector);
let request = Request::builder()
.method(hyper::Method::GET)
.uri(format!("http://{addr}/plaintext"))
.body(http_body_util::Full::<Bytes>::new(Bytes::new()))
.unwrap();
let response = client.request(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"text/plain"
);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body, Bytes::from_static(b"Hello, World!"));
handle.stop().await;
let _ = handle.join().await;
}
#[tokio::test]
async fn test_http_route_handler_response_uses_inline_path() {
use crate::traits::Handled;
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let input = Endpoint::new(EndpointType::Http(HttpConfig {
url: addr.clone(),
path: Some("/handler".to_string()),
..Default::default()
}));
let mut output = Endpoint::new(EndpointType::Response(
crate::models::ResponseConfig::default(),
));
let handler = |mut msg: CanonicalMessage| async move {
msg.payload = Bytes::from_static(b"handled-response");
msg.metadata
.insert("content-type".to_string(), "text/plain".to_string());
msg.metadata
.insert("x-response-id".to_string(), "resp-1".to_string());
msg.metadata
.insert("http_status_code".to_string(), "201".to_string());
Ok(Handled::Publish(msg))
};
output.handler = Some(std::sync::Arc::new(handler));
let route = crate::Route::new(input, output);
let handle = route.run("test_http_inline_handler_path").await.unwrap();
let mut connector = HttpConnector::new();
connector.set_nodelay(true);
let client =
hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build(connector);
let request = Request::builder()
.method(hyper::Method::POST)
.uri(format!("http://{addr}/handler"))
.header("content-type", "application/json")
.header("accept", "application/octet-stream")
.header("x-request-id", "req-123")
.body(http_body_util::Full::<Bytes>::new(Bytes::from_static(
br#"{"value":1}"#,
)))
.unwrap();
let response = client.request(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
assert_eq!(
response.headers().get("content-type").unwrap(),
"text/plain"
);
assert_eq!(response.headers().get("x-response-id").unwrap(), "resp-1");
assert!(response.headers().get("x-request-id").is_none());
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body, Bytes::from_static(b"handled-response"));
handle.stop().await;
let _ = handle.join().await;
}
#[tokio::test]
async fn test_http_route_handler_content_type_matching_request_is_not_suppressed() {
use crate::traits::Handled;
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let input = Endpoint::new(EndpointType::Http(HttpConfig {
url: addr.clone(),
path: Some("/ct-match".to_string()),
..Default::default()
}));
let mut output = Endpoint::new(EndpointType::Response(
crate::models::ResponseConfig::default(),
));
let handler = |mut msg: CanonicalMessage| async move {
msg.payload = Bytes::from_static(b"42");
msg.metadata
.insert("content-type".to_string(), "text/plain".to_string());
Ok(Handled::Publish(msg))
};
output.handler = Some(std::sync::Arc::new(handler));
let route = crate::Route::new(input, output);
let handle = route.run("test_http_inline_ct_match").await.unwrap();
let mut connector = HttpConnector::new();
connector.set_nodelay(true);
let client =
hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build(connector);
let request = Request::builder()
.method(hyper::Method::POST)
.uri(format!("http://{addr}/ct-match"))
.header("content-type", "text/plain")
.body(http_body_util::Full::<Bytes>::new(Bytes::from_static(
b"20",
)))
.unwrap();
let response = client.request(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"text/plain"
);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body, Bytes::from_static(b"42"));
handle.stop().await;
let _ = handle.join().await;
}
#[tokio::test]
async fn test_http_route_handler_with_buffer_uses_inline_path() {
use crate::models::{BufferMiddleware, Middleware};
use crate::traits::Handled;
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let input = Endpoint::new(EndpointType::Http(HttpConfig {
url: addr.clone(),
path: Some("/handler-buffered".to_string()),
..Default::default()
}));
let mut output = Endpoint::new(EndpointType::Response(
crate::models::ResponseConfig::default(),
));
output
.middlewares
.push(Middleware::Buffer(BufferMiddleware {
max_messages: 16,
max_delay_ms: 0,
}));
let handler = |mut msg: CanonicalMessage| async move {
msg.payload = Bytes::from_static(b"handled-buffered");
msg.metadata
.insert("content-type".to_string(), "text/plain".to_string());
Ok(Handled::Publish(msg))
};
output.handler = Some(std::sync::Arc::new(handler));
let route = crate::Route::new(input, output);
let handle = route
.run("test_http_inline_handler_buffer_path")
.await
.unwrap();
let mut connector = HttpConnector::new();
connector.set_nodelay(true);
let client =
hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build(connector);
let request = Request::builder()
.method(hyper::Method::POST)
.uri(format!("http://{addr}/handler-buffered"))
.header("content-type", "application/json")
.header("accept", "application/octet-stream")
.header("x-request-id", "req-123")
.body(http_body_util::Full::<Bytes>::new(Bytes::from_static(
br#"{"value":1}"#,
)))
.unwrap();
let response = client.request(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"text/plain"
);
assert!(response.headers().get("x-request-id").is_none());
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body, Bytes::from_static(b"handled-buffered"));
handle.stop().await;
let _ = handle.join().await;
}
#[tokio::test]
async fn test_http_streamable_route_handler_uses_inline_path() {
use crate::traits::Handled;
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let input = Endpoint::new(EndpointType::Http(HttpConfig {
url: addr.clone(),
path: Some("/handler-stream".to_string()),
receive_streamable: true,
..Default::default()
}));
let mut output = Endpoint::new(EndpointType::Response(
crate::models::ResponseConfig::default(),
));
let handler = |mut msg: CanonicalMessage| async move {
let payload = msg.get_payload_str();
msg.set_payload_str(format!("reply-{payload}"));
Ok(Handled::Publish(msg))
};
output.handler = Some(std::sync::Arc::new(handler));
let route = crate::Route::new(input, output);
let handle = route
.run("test_http_inline_streamable_handler_path")
.await
.unwrap();
let mut connector = HttpConnector::new();
connector.set_nodelay(true);
let client =
hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build(connector);
let request = Request::builder()
.method(hyper::Method::POST)
.uri(format!("http://{addr}/handler-stream"))
.header("content-type", "application/x-ndjson")
.header("accept", "application/x-ndjson")
.body(http_body_util::Full::<Bytes>::new(Bytes::from_static(
b"first\nsecond\n",
)))
.unwrap();
let response = client.request(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get("content-type").unwrap(),
"application/x-ndjson"
);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body, Bytes::from_static(b"reply-first\nreply-second\n"));
handle.stop().await;
let _ = handle.join().await;
}
#[tokio::test]
async fn test_http_reply_with_custom_status_code() {
use crate::traits::Handled;
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let http_config = HttpConfig {
url: addr.clone(),
..Default::default()
};
let mut consumer = HttpConsumer::new(&http_config).await.unwrap();
let mut response_endpoint =
crate::models::Endpoint::new(EndpointType::Response(crate::models::ResponseConfig {}));
let handler = |mut msg: CanonicalMessage| async move {
msg.metadata
.insert("http_status_code".to_string(), "201".to_string());
Ok(Handled::Publish(msg))
};
response_endpoint.handler = Some(std::sync::Arc::new(handler));
let publisher =
create_publisher_from_route("test_response_handler_status", &response_endpoint)
.await
.unwrap();
tokio::spawn(async move {
if let Ok(received) = consumer.receive().await {
let outcome = publisher.send(received.message).await.unwrap();
let disposition = match outcome {
Sent::Response(msg) => crate::traits::MessageDisposition::Reply(msg),
Sent::Ack => crate::traits::MessageDisposition::Ack,
};
let _ = (received.commit)(disposition).await;
}
});
tokio::time::sleep(Duration::from_millis(50)).await;
}
#[tokio::test]
async fn test_http_consumers_share_listener_by_path() {
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let url = format!("http://{}", addr);
let mut alpha_consumer = HttpConsumer::new(&HttpConfig {
url: addr.clone(),
path: Some("/alpha".to_string()),
..Default::default()
})
.await
.unwrap();
let mut beta_consumer = HttpConsumer::new(&HttpConfig {
url: addr.clone(),
path: Some("/beta".to_string()),
..Default::default()
})
.await
.unwrap();
let publisher = HttpPublisher::new(&HttpConfig {
url,
..Default::default()
})
.await
.unwrap();
let alpha_task = tokio::spawn(async move {
let received = consumer_receive_ack(&mut alpha_consumer).await;
received.payload
});
let beta_task = tokio::spawn(async move {
let received = consumer_receive_ack(&mut beta_consumer).await;
received.payload
});
let mut alpha_message = CanonicalMessage::new(b"alpha".to_vec(), None);
alpha_message
.metadata
.insert("http_path".to_string(), "/alpha".to_string());
let mut beta_message = CanonicalMessage::new(b"beta".to_vec(), None);
beta_message
.metadata
.insert("http_path".to_string(), "/beta".to_string());
publisher.send(alpha_message).await.unwrap();
publisher.send(beta_message).await.unwrap();
assert_eq!(alpha_task.await.unwrap(), b"alpha".to_vec());
assert_eq!(beta_task.await.unwrap(), b"beta".to_vec());
}
#[tokio::test]
async fn test_http_consumer_rejects_duplicate_path_registration() {
init_crypto();
let port = get_free_port();
let addr = format!("127.0.0.1:{}", port);
let _consumer = HttpConsumer::new(&HttpConfig {
url: addr.clone(),
path: Some("/shared".to_string()),
..Default::default()
})
.await
.unwrap();
let error = HttpConsumer::new(&HttpConfig {
url: addr,
path: Some("/shared".to_string()),
..Default::default()
})
.await
.err()
.expect("duplicate registration should fail");
assert!(
error
.to_string()
.contains("Conflicting HTTP consumer registration"),
"unexpected error: {error}"
);
}
#[tokio::test]
async fn test_http_consumers_on_ephemeral_ports_do_not_share_listener() {
init_crypto();
let first_consumer = HttpConsumer::new(&HttpConfig {
url: "127.0.0.1:0".to_string(),
..Default::default()
})
.await
.unwrap();
let second_consumer = HttpConsumer::new(&HttpConfig {
url: "127.0.0.1:0".to_string(),
..Default::default()
})
.await
.unwrap();
let first_addr = first_consumer.bound_addr().unwrap();
let second_addr = second_consumer.bound_addr().unwrap();
assert_ne!(first_addr, second_addr);
assert_ne!(first_addr.port(), 0);
assert_ne!(second_addr.port(), 0);
}
async fn consumer_receive_ack(consumer: &mut HttpConsumer) -> CanonicalMessage {
let received = consumer.receive().await.unwrap();
let message = received.message.clone();
(received.commit)(crate::traits::MessageDisposition::Ack)
.await
.unwrap();
message
}
}