pub mod bundle;
pub(crate) mod client_cache;
pub mod config;
mod header_policy;
pub mod health;
#[cfg(test)]
mod health_wiring_tests;
pub mod registry;
pub(crate) mod rest_match;
pub(crate) mod ssrf;
pub mod static_config;
pub mod static_dispatch;
pub mod static_endpoint;
pub(crate) mod tls;
#[cfg(test)]
mod tls_harness;
pub(crate) mod tls_reload;
use crate::config::parse_ok_status_code_range;
pub use bundle::HttpBundle;
pub use bundle::HttpStaticBundle;
pub(crate) use client_cache::{
HttpComponentKind, PINNED_CLIENT_MAX_ENTRIES, PINNED_CLIENT_TTL, PinnedClientCache,
};
pub use config::HttpConfig;
pub use health::HttpHealthCheck;
use health::ListenerGate;
pub use registry::HttpRouteRegistry;
pub use static_config::HttpStaticConfig;
pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
#[cfg(test)]
pub(crate) use tls::{
FORCE_FALLBACK_REBUILD_FAIL, FORCE_WEBPKI_FALLBACK, build_client_call_count,
build_client_fallback_count,
};
#[cfg(test)]
#[allow(unused_imports)]
pub(crate) use tls::{FallbackTrigger, webpki_fallback_client};
pub(crate) use tls::{build_client, client_or_emergency, strict_tls_error};
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock};
use std::task::{Context, Poll};
use std::time::Duration;
use tokio::sync::OnceCell;
use tower::Layer;
use tower::Service;
use tracing::debug;
use axum::body::BodyDataStream;
use camel_api::component_metadata::ComponentMetadata;
use camel_auth::bearer_token_layer::BearerTokenLayer;
use camel_auth::oauth2::TokenProvider;
use camel_component_api::tls_source::ServerTlsSource;
use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
use camel_component_api::{UriComponents, UriConfig, parse_uri, raw_query_pairs};
use futures::StreamExt;
use futures::TryStreamExt;
use futures::stream::BoxStream;
#[derive(Clone)]
pub struct HttpEndpointConfig {
pub base_url: String,
pub http_method: Option<String>,
pub throw_exception_on_failure: bool,
pub ok_status_code_range: (u16, u16),
pub response_timeout: Option<Duration>,
pub query_params: Vec<(String, String)>,
pub raw_query: Option<String>,
pub allow_internal: bool,
pub allow_cleartext: bool,
pub blocked_hosts: Vec<String>,
pub max_body_size: usize,
pub read_timeout_ms: u64,
pub max_response_bytes: usize,
pub auth: HttpAuth,
pub token_provider: Option<Arc<dyn TokenProvider>>,
pub user_agent: Option<String>,
pub bridge_endpoint: bool,
pub connection_close: bool,
pub skip_request_headers: Vec<String>,
pub skip_response_headers: Vec<String>,
pub follow_redirects: bool,
pub max_redirects: usize,
pub allowed_uri_hosts: Option<Vec<AllowedUriHost>>,
}
impl std::fmt::Debug for HttpEndpointConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpEndpointConfig")
.field("base_url", &camel_api::redact::redact_url(&self.base_url))
.field("http_method", &self.http_method)
.field(
"throw_exception_on_failure",
&self.throw_exception_on_failure,
)
.field("ok_status_code_range", &self.ok_status_code_range)
.field("response_timeout", &self.response_timeout)
.field(
"query_params",
&self
.query_params
.iter()
.map(|(key, _)| (key, "***"))
.collect::<Vec<_>>(),
)
.field("raw_query", &self.raw_query.as_ref().map(|_| "?[redacted]"))
.field("allow_internal", &self.allow_internal)
.field("allow_cleartext", &self.allow_cleartext)
.field("blocked_hosts", &self.blocked_hosts)
.field("max_body_size", &self.max_body_size)
.field("read_timeout_ms", &self.read_timeout_ms)
.field("max_response_bytes", &self.max_response_bytes)
.field("auth", &self.auth)
.field("token_provider", &self.token_provider)
.field("user_agent", &self.user_agent)
.field("bridge_endpoint", &self.bridge_endpoint)
.field("connection_close", &self.connection_close)
.field("skip_request_headers", &self.skip_request_headers)
.field("skip_response_headers", &self.skip_response_headers)
.field("follow_redirects", &self.follow_redirects)
.field("max_redirects", &self.max_redirects)
.field("allowed_uri_hosts", &self.allowed_uri_hosts)
.finish()
}
}
#[derive(Clone, PartialEq)]
pub enum HttpAuth {
None,
Basic { username: String, password: String },
Bearer { token: String },
}
impl std::fmt::Debug for HttpAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HttpAuth::None => f.write_str("None"),
HttpAuth::Basic { username, .. } => f
.debug_struct("Basic")
.field("username", username)
.field("password", &"***")
.finish(),
HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
}
}
}
fn is_consumed_option(key: &str) -> bool {
HttpEndpointConfig::uri_options()
.iter()
.any(|option| option.name == key || option.aliases.iter().any(|alias| alias == key))
}
impl UriConfig for HttpEndpointConfig {
fn scheme() -> &'static str {
"http"
}
fn from_uri(uri: &str) -> Result<Self, CamelError> {
let parts = parse_uri(uri)?;
Self::from_components(parts)
}
fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
if parts.scheme != "http" && parts.scheme != "https" {
return Err(CamelError::InvalidUri(format!(
"expected scheme 'http' or 'https', got '{}'",
parts.scheme
)));
}
let base_url = format!("{}:{}", parts.scheme, parts.path);
let http_method = parts.params.get("httpMethod").cloned();
let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
Some(v) => parse_bool_param_http(v).map_err(|e| {
CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
})?,
None => true,
};
let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
Some(v) => parse_ok_status_code_range(v)?,
None => (200, 299),
};
let response_timeout = match parts.params.get("responseTimeout") {
Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
})?),
None => None,
};
let allow_internal = match parts.params.get("allowInternal") {
Some(v) => parse_bool_param_http(v).map_err(|e| {
CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
})?,
None => false, };
let allow_cleartext = match parts.params.get("allowCleartext") {
Some(v) => parse_bool_param_http(v).map_err(|e| {
CamelError::InvalidUri(format!("invalid value for allowCleartext: {e}"))
})?,
None => false, };
let blocked_hosts = parts
.params
.get("blockedHosts")
.map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let max_body_size = match parts.params.get("maxBodySize") {
Some(v) => v.parse::<usize>().map_err(|e| {
CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
})?,
None => 10 * 1024 * 1024, };
let read_timeout_ms = match parts.params.get("readTimeout") {
Some(v) => v.parse::<u64>().map_err(|e| {
CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
})?,
None => 30_000, };
let max_response_bytes = match parts.params.get("maxResponseBytes") {
Some(v) => v.parse::<usize>().map_err(|e| {
CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
})?,
None => 10 * 1024 * 1024, };
let auth = parse_auth_from_params(&parts.params)?;
let user_agent = parts.params.get("userAgent").cloned();
if parts.params.contains_key("cookieHandling") {
return Err(CamelError::InvalidUri(
"cookieHandling is not supported".into(),
));
}
let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
Some(v) => parse_bool_param_http(v).map_err(|e| {
CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
})?,
None => false,
};
let connection_close = match parts.params.get("connectionClose") {
Some(v) => parse_bool_param_http(v).map_err(|e| {
CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
})?,
None => false,
};
let skip_request_headers = parts
.params
.get("skipRequestHeaders")
.map(|v| {
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_ascii_lowercase())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let skip_response_headers = parts
.params
.get("skipResponseHeaders")
.map(|v| {
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_ascii_lowercase())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let follow_redirects = match parts.params.get("followRedirects") {
Some(v) => parse_bool_param_http(v).map_err(|e| {
CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
})?,
None => false,
};
let max_redirects = match parts.params.get("maxRedirects") {
Some(v) => v.parse::<usize>().map_err(|e| {
CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
})?,
None => 10,
};
let allowed_uri_hosts = match parts.params.get("allowedUriHosts") {
Some(v) => Some(parse_allowed_uri_hosts(v)?),
None => None,
};
let raw_query = parts.raw_query.clone();
Ok(Self {
base_url,
http_method,
throw_exception_on_failure,
ok_status_code_range,
response_timeout,
query_params: Vec::new(),
raw_query,
allow_internal,
allow_cleartext,
blocked_hosts,
max_body_size,
read_timeout_ms,
max_response_bytes,
auth,
token_provider: None,
user_agent,
bridge_endpoint,
connection_close,
skip_request_headers,
skip_response_headers,
follow_redirects,
max_redirects,
allowed_uri_hosts,
})
}
}
#[derive(Debug, Clone, UriConfig)]
#[allow(dead_code)]
#[uri_scheme = "http"]
#[uri_config(
skip_impl,
metadata(
scheme = "http",
description = "HTTP client and server component",
producer,
consumer,
streaming
),
crate = "camel_component_api"
)]
struct HttpEndpointUriConfig {
#[allow(dead_code)]
_base_url: String,
#[uri_param(
name = "httpMethod",
desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
)]
http_method: Option<String>,
#[uri_param(
name = "throwExceptionOnFailure",
default = "true",
desc = "Throw on non-2xx status"
)]
throw_exception_on_failure: bool,
#[uri_param(
name = "okStatusCodeRange",
default = "200-299",
desc = "Success status code range"
)]
ok_status_code_range: String,
#[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
response_timeout: Option<u64>,
#[uri_param(
name = "connectTimeout",
desc = "Connection timeout in milliseconds (consumed option; effective timeout comes from the global http config)"
)]
connect_timeout: Option<u64>,
#[uri_param(
name = "allowInternal",
default = "false",
desc = "Allow private/internal network destinations (SSRF)"
)]
allow_internal: bool,
#[uri_param(
name = "allowCleartext",
default = "false",
desc = "Allow cleartext http:// to public targets (transport consent; ADR-0081)"
)]
allow_cleartext: bool,
#[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
blocked_hosts: Option<String>,
#[uri_param(
name = "maxBodySize",
default = "10485760",
desc = "Max request/response body bytes"
)]
max_body_size: u64,
#[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
read_timeout: Option<u64>,
#[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
max_response_bytes: Option<u64>,
#[uri_param(
name = "authMethod",
kind = "enum:None,Basic,Bearer",
desc = "Authentication method"
)]
auth_method: Option<String>,
#[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
auth_username: Option<String>,
#[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
auth_password: Option<String>,
#[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
auth_bearer_token: Option<String>,
#[uri_param(name = "userAgent", desc = "User-Agent header")]
user_agent: Option<String>,
#[uri_param(
name = "bridgeEndpoint",
default = "false",
desc = "Bridge endpoint mode"
)]
bridge_endpoint: bool,
#[uri_param(
name = "connectionClose",
default = "false",
desc = "Send Connection: close"
)]
connection_close: bool,
#[uri_param(
name = "skipRequestHeaders",
desc = "Comma-separated request headers to skip"
)]
skip_request_headers: Option<String>,
#[uri_param(
name = "skipResponseHeaders",
desc = "Comma-separated response headers to skip"
)]
skip_response_headers: Option<String>,
#[uri_param(
name = "followRedirects",
default = "false",
desc = "Follow HTTP redirects"
)]
follow_redirects: bool,
#[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
max_redirects: u64,
#[uri_param(
name = "allowedUriHosts",
desc = "Comma-separated allowlist of CamelHttpUri override hosts (host or host:port)"
)]
allowed_uri_hosts: Option<String>,
}
impl HttpEndpointConfig {
pub fn metadata() -> ComponentMetadata {
HttpEndpointUriConfig::metadata()
}
pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
HttpEndpointUriConfig::uri_options()
}
}
fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
let Some(method) = params.get("authMethod") else {
return Ok(HttpAuth::None);
};
if method.eq_ignore_ascii_case("none") {
return Ok(HttpAuth::None);
}
if method.eq_ignore_ascii_case("basic") {
let username = params.get("authUsername").cloned().ok_or_else(|| {
CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
})?;
let password = params.get("authPassword").cloned().ok_or_else(|| {
CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
})?;
return Ok(HttpAuth::Basic { username, password });
}
if method.eq_ignore_ascii_case("bearer") {
let token = params.get("authBearerToken").cloned().ok_or_else(|| {
CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
})?;
return Ok(HttpAuth::Bearer { token });
}
Err(CamelError::InvalidUri(format!(
"invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
)))
}
fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
match value.to_ascii_lowercase().as_str() {
"true" | "1" | "yes" => Ok(true),
"false" | "0" | "no" => Ok(false),
_ => Err(CamelError::InvalidUri(format!(
"invalid boolean value: '{value}'"
))),
}
}
impl HttpEndpointConfig {
pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
let parts = parse_uri(uri)?;
let mut endpoint = Self::from_components(parts.clone())?;
if endpoint.response_timeout.is_none() {
endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
}
if !parts.params.contains_key("allowInternal") {
endpoint.allow_internal = config.allow_internal;
}
if !parts.params.contains_key("blockedHosts") {
endpoint.blocked_hosts = config.blocked_hosts.clone();
}
if !parts.params.contains_key("maxBodySize") {
endpoint.max_body_size = config.max_body_size;
}
if !parts.params.contains_key("readTimeout") {
endpoint.read_timeout_ms = config.read_timeout_ms;
}
if !parts.params.contains_key("maxResponseBytes") {
endpoint.max_response_bytes = config.max_response_bytes;
}
if !parts.params.contains_key("okStatusCodeRange")
&& let Some(range) = &config.ok_status_code_range
{
endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
}
if !parts.params.contains_key("followRedirects") {
endpoint.follow_redirects = config.follow_redirects;
}
if !parts.params.contains_key("maxRedirects") {
endpoint.max_redirects = config.max_redirects.unwrap_or(10);
}
Ok(endpoint)
}
}
#[derive(Debug, Clone)]
pub struct HttpServerConfig {
pub scheme: String,
pub host: String,
pub port: u16,
pub path: String,
pub max_request_body: usize,
pub max_response_body: usize,
pub max_inflight_requests: usize,
pub method: Option<String>,
pub tls_config: Option<crate::config::ServerTlsConfig>,
}
impl UriConfig for HttpServerConfig {
fn scheme() -> &'static str {
"http"
}
fn from_uri(uri: &str) -> Result<Self, CamelError> {
let parts = parse_uri(uri)?;
Self::from_components(parts)
}
fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
if parts.scheme != "http" && parts.scheme != "https" {
return Err(CamelError::InvalidUri(format!(
"expected scheme 'http' or 'https', got '{}'",
parts.scheme
)));
}
let authority_and_path = parts.path.trim_start_matches('/');
let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
(&authority_and_path[..idx], &authority_and_path[idx..])
} else {
(authority_and_path, "/")
};
let path = if path_suffix.is_empty() {
"/"
} else {
path_suffix
}
.to_string();
let (host, port) = if let Some(colon) = authority.rfind(':') {
let port_str = &authority[colon + 1..];
match port_str.parse::<u16>() {
Ok(p) => (authority[..colon].to_string(), p),
Err(_) => {
return Err(CamelError::InvalidUri(format!(
"invalid port '{}' in authority",
port_str
)));
}
}
} else {
let default_port = if parts.scheme == "https" { 443 } else { 80 };
(authority.to_string(), default_port)
};
let max_request_body = parts
.params
.get("maxRequestBody")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(2 * 1024 * 1024);
let max_response_body = parts
.params
.get("maxResponseBody")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(10 * 1024 * 1024);
let max_inflight_requests = parts
.params
.get("maxInflightRequests")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(1024);
let max_inflight_requests = max_inflight_requests_limit(max_inflight_requests)?;
let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
Ok(Self {
scheme: parts.scheme,
host,
port,
path,
max_request_body,
max_response_body,
max_inflight_requests,
method,
tls_config: {
let cert = parts.params.get("tlsCert").cloned();
let key = parts.params.get("tlsKey").cloned();
match (cert, key) {
(Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
cert_path: c,
key_path: k,
}),
(None, None) => None,
_ => None, }
},
})
}
}
impl HttpServerConfig {
pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
let parts = parse_uri(uri)?;
let mut server = Self::from_components(parts.clone())?;
if !parts.params.contains_key("maxRequestBody") {
server.max_request_body = config.max_request_body;
}
if !parts.params.contains_key("maxResponseBody") {
server.max_response_body = config.max_body_size;
}
Ok(server)
}
}
pub enum HttpReplyBody {
Bytes(bytes::Bytes),
Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
}
pub struct RequestEnvelope {
pub method: String,
pub path: String,
pub query: String,
pub headers: http::HeaderMap,
pub body: StreamBody,
pub path_params: std::collections::HashMap<String, String>,
pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
}
pub struct HttpReply {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: HttpReplyBody,
}
type ServerKey = (String, u16);
struct ServerHandle {
registry: HttpRouteRegistry,
bound_addr: std::net::SocketAddr,
max_request_body: usize,
max_response_body: usize,
max_inflight_requests: usize,
is_tls: bool,
tls_cert_path: Option<String>,
tls_key_path: Option<String>,
monitor_task: tokio::task::JoinHandle<()>,
#[allow(dead_code)]
server_abort: tokio::task::AbortHandle,
tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
tls_source: Option<ServerTlsSource>,
}
#[derive(Default)]
struct RegistryState {
entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
staged: HashMap<ServerKey, tokio::net::TcpListener>,
}
pub struct ServerRegistry {
inner: Mutex<RegistryState>,
}
impl ServerRegistry {
pub fn global() -> &'static Self {
static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
INSTANCE.get_or_init(|| ServerRegistry {
inner: Mutex::new(RegistryState::default()),
})
}
#[allow(clippy::too_many_arguments)]
pub async fn get_or_spawn(
&'static self,
host: &str,
port: u16,
max_request_body: usize,
max_response_body: usize,
max_inflight_requests: usize,
runtime: Arc<dyn RuntimeObservability>,
route_id: String,
tls_config: Option<crate::config::ServerTlsConfig>,
) -> Result<HttpRouteRegistry, CamelError> {
self.get_or_spawn_internal(
host,
port,
max_request_body,
max_response_body,
max_inflight_requests,
runtime,
route_id,
tls_config,
None,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn get_or_spawn_with_listener(
&'static self,
listener: tokio::net::TcpListener,
max_request_body: usize,
max_response_body: usize,
max_inflight_requests: usize,
runtime: Arc<dyn RuntimeObservability>,
route_id: String,
tls_config: Option<crate::config::ServerTlsConfig>,
) -> Result<HttpRouteRegistry, CamelError> {
let addr = listener
.local_addr()
.map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
self.get_or_spawn_internal(
&addr.ip().to_string(),
addr.port(),
max_request_body,
max_response_body,
max_inflight_requests,
runtime,
route_id,
tls_config,
Some(listener),
)
.await
}
pub async fn stage_listener(
&'static self,
listener: tokio::net::TcpListener,
) -> Result<(), CamelError> {
let addr = listener
.local_addr()
.map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
let host = addr.ip().to_string();
use std::collections::hash_map::Entry;
let mut guard = self.inner.lock().map_err(|_| {
CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
})?;
match guard.staged.entry((host.clone(), addr.port())) {
Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
"listener already staged for {host}:{}",
addr.port()
))),
Entry::Vacant(slot) => {
slot.insert(listener);
Ok(())
}
}
}
pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
let guard = self.inner.lock().ok()?;
guard
.entries
.get(&(host.to_string(), port))
.and_then(|cell| cell.get())
.map(|handle| handle.bound_addr)
}
#[allow(clippy::too_many_arguments)]
async fn get_or_spawn_internal(
&'static self,
host: &str,
port: u16,
max_request_body: usize,
max_response_body: usize,
max_inflight_requests: usize,
runtime: Arc<dyn RuntimeObservability>,
route_id: String,
tls_config: Option<crate::config::ServerTlsConfig>,
provided: Option<tokio::net::TcpListener>,
) -> Result<HttpRouteRegistry, CamelError> {
let host_owned = host.to_string();
let key = (host.to_string(), port);
let cell = {
let mut guard = self.inner.lock().map_err(|_| {
CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
})?;
if let Some(existing) = guard.entries.get(&key)
&& let Some(handle) = existing.get()
&& handle.monitor_task.is_finished()
{
if handle.is_tls {
let scheme = if handle.is_tls { "https" } else { "http" };
camel_component_api::tls_source::TlsReloadRegistry::global()
.unregister(scheme, host, port);
}
guard.entries.remove(&key);
}
guard
.entries
.entry(key)
.or_insert_with(|| Arc::new(OnceCell::new()))
.clone()
};
if let Some(existing) = cell.get()
&& existing.max_request_body != max_request_body
{
return Err(CamelError::EndpointCreationFailed(format!(
"incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
existing.max_request_body, max_request_body
)));
}
if let Some(existing) = cell.get()
&& existing.max_response_body != max_response_body
{
return Err(CamelError::EndpointCreationFailed(format!(
"incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
existing.max_response_body, max_response_body
)));
}
if let Some(existing) = cell.get()
&& existing.max_inflight_requests != max_inflight_requests
{
return Err(CamelError::EndpointCreationFailed(format!(
"incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
existing.max_inflight_requests, max_inflight_requests
)));
}
if let Some(existing) = cell.get()
&& existing.is_tls != tls_config.is_some()
{
return Err(CamelError::EndpointCreationFailed(format!(
"incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
existing.is_tls,
tls_config.is_some()
)));
}
if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
&& (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
|| existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
{
return Err(CamelError::EndpointCreationFailed(format!(
"incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
)));
}
let handle = cell
.get_or_try_init(|| {
let rt = Arc::clone(&runtime);
let rid = route_id.clone();
let key = (host_owned.clone(), port);
async move {
let source = match provided {
Some(listener) => ListenerSource::Staged(listener),
None => {
let mut guard = self.inner.lock().map_err(|_| {
CamelError::EndpointCreationFailed(
"ServerRegistry lock poisoned".into(),
)
})?;
match guard.staged.remove(&key) {
Some(listener) => ListenerSource::Staged(listener),
None => {
if let Some((staged_host, _)) = guard
.staged
.keys()
.find(|(_, staged_port)| *staged_port == port)
{
let staged_host = staged_host.clone();
return Err(CamelError::EndpointCreationFailed(
format!(
"staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
),
));
}
ListenerSource::Bind
}
}
}
};
spawn_entry(
key,
source,
max_request_body,
max_response_body,
max_inflight_requests,
rt,
rid,
tls_config,
)
.await
.and_then(|handle| {
Arc::try_unwrap(handle).map_err(|_| {
CamelError::EndpointCreationFailed(
"spawned server handle has dangling clones".into(),
)
})
})
}
})
.await?;
Ok(handle.registry.clone())
}
pub async fn unregister(&self, host: &str, port: u16) {
debug!(
host = camel_api::redact::redact_host(host),
port = port,
"consumer unregistered from HTTP server"
);
}
#[cfg(test)]
pub fn reset() {
let instance = Self::global();
let mut guard = instance
.inner
.lock()
.expect("ServerRegistry lock poisoned during test reset");
guard.entries.clear();
guard.staged.clear();
}
}
enum ListenerSource {
Bind,
Staged(tokio::net::TcpListener),
}
#[allow(clippy::too_many_arguments)]
async fn spawn_entry(
key: ServerKey,
source: ListenerSource,
max_request_body: usize,
max_response_body: usize,
max_inflight_requests: usize,
runtime: Arc<dyn RuntimeObservability>,
route_id: String,
tls_config: Option<crate::config::ServerTlsConfig>,
) -> Result<Arc<ServerHandle>, CamelError> {
max_inflight_requests_limit(max_inflight_requests)?;
let rt = Arc::clone(&runtime);
let rid = route_id.clone();
let (host_owned, port) = key;
let listener = match source {
ListenerSource::Bind => {
let addr = format!("{host_owned}:{port}");
tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
})?
}
ListenerSource::Staged(listener) => listener,
};
let bound_addr = listener
.local_addr()
.map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
let server_exited = tokio_util::sync::CancellationToken::new();
let registry = HttpRouteRegistry::new_with_server_exited(server_exited.clone());
let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
let tls_source: Option<ServerTlsSource>;
let server_task = if let Some(ref tls) = tls_config {
let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
let source = ServerTlsSource {
cert_path: std::path::PathBuf::from(&tls.cert_path),
key_path: std::path::PathBuf::from(&tls.key_path),
client_ca_path: None,
};
let rustls_cfg =
axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
tls_rustls_cfg = Some(rustls_cfg.clone());
tls_source = Some(source);
let std_listener = listener.into_std().map_err(|e| {
CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
})?;
tokio::spawn(run_axum_server_tls(
std_listener,
rustls_cfg,
registry.clone(),
max_request_body,
max_response_body,
Arc::clone(&inflight),
Arc::clone(&rt),
rid.clone(),
))
} else {
tls_rustls_cfg = None;
tls_source = None;
tokio::spawn(run_axum_server(
listener,
registry.clone(),
max_request_body,
max_response_body,
Arc::clone(&inflight),
Arc::clone(&rt),
rid.clone(),
))
};
let addr_for_monitor = format!("{host_owned}:{port}");
let server_abort = server_task.abort_handle();
let monitor_task = tokio::spawn(monitor_axum_task(
server_task,
addr_for_monitor,
Arc::clone(&rt),
rid,
server_exited,
));
let handle = ServerHandle {
registry,
bound_addr,
max_request_body,
max_response_body,
max_inflight_requests,
is_tls: tls_config.is_some(),
tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
monitor_task,
server_abort,
tls_config: tls_rustls_cfg,
tls_source,
};
if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
{
let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
tls_cfg.clone(),
source.clone(),
host_owned.clone(),
port,
));
camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
}
Ok(Arc::new(handle))
}
use axum::{
Router,
body::Body as AxumBody,
extract::{Request, State},
http::{Response, StatusCode},
response::IntoResponse,
};
#[derive(Clone)]
pub(crate) struct AppState {
registry: HttpRouteRegistry,
max_request_body: usize,
max_response_body: usize,
inflight: Arc<tokio::sync::Semaphore>,
}
const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
async fn run_axum_server(
listener: tokio::net::TcpListener,
registry: HttpRouteRegistry,
max_request_body: usize,
max_response_body: usize,
inflight: Arc<tokio::sync::Semaphore>,
runtime: Arc<dyn RuntimeObservability>,
route_id: String,
) {
let state = AppState {
registry,
max_request_body,
max_response_body,
inflight,
};
let app = Router::new()
.fallback(dispatch_handler)
.with_state(state)
.layer(tower_http::timeout::TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
CONSUMER_REQUEST_TIMEOUT,
));
axum::serve(listener, app).await.unwrap_or_else(|e| {
runtime
.metrics()
.increment_errors(&route_id, "e:http:accept");
tracing::error!(error = %e, "Axum server error");
});
}
#[allow(clippy::too_many_arguments)]
async fn run_axum_server_tls(
listener: std::net::TcpListener,
tls_cfg: axum_server::tls_rustls::RustlsConfig,
registry: HttpRouteRegistry,
max_request_body: usize,
max_response_body: usize,
inflight: Arc<tokio::sync::Semaphore>,
runtime: Arc<dyn RuntimeObservability>,
route_id: String,
) {
let state = AppState {
registry,
max_request_body,
max_response_body,
inflight,
};
let app = Router::new()
.fallback(dispatch_handler)
.with_state(state)
.layer(tower_http::timeout::TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
CONSUMER_REQUEST_TIMEOUT,
));
let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
Ok(server) => server,
Err(e) => {
runtime
.metrics()
.increment_errors(&route_id, "e:http:accept-tls");
tracing::error!(error = %e, "Axum TLS server setup error");
return;
}
};
server
.serve(app.into_make_service())
.await
.unwrap_or_else(|e| {
runtime
.metrics()
.increment_errors(&route_id, "e:http:accept-tls");
tracing::error!(error = %e, "Axum TLS server error");
});
}
async fn monitor_axum_task(
handle: tokio::task::JoinHandle<()>,
addr: String,
runtime: Arc<dyn RuntimeObservability>,
route_id: String,
server_exited: tokio_util::sync::CancellationToken,
) {
match handle.await {
Ok(()) => {
}
Err(join_err) => {
runtime
.metrics()
.increment_errors(&route_id, "e:http:server-task-exited");
tracing::error!(
addr = %addr,
error = %join_err,
"Axum server task exited unexpectedly — all routes on this port are now dead"
);
server_exited.cancel();
}
}
}
fn load_tls_config(
cert_path: &str,
key_path: &str,
) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
use std::fs::File;
use std::io::BufReader;
let cert_file = File::open(cert_path)
.map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
let key_file = File::open(key_path)
.map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
.map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
.ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
tokio_rustls::rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)
.map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
}
async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
let path = req.uri().path().to_owned();
let method = req.method().to_string();
let api_sender = {
let inner = state.registry.inner.read().await;
inner.api_routes.get(&path).cloned()
};
let (rest_sender, path_params) = if api_sender.is_some() {
(None, Default::default())
} else {
let inner = state.registry.inner.read().await;
match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
rest_match::MatchOutcome::Ambiguous => {
tracing::warn!(
method = %method,
path = %path,
"ambiguous REST template match — returning 500"
);
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(AxumBody::from("Internal Server Error"))
.expect("infallible"); }
rest_match::MatchOutcome::NotFound => (None, Default::default()),
}
};
let sender = api_sender.or(rest_sender);
if let Some(sender) = sender {
let query = req.uri().query().unwrap_or("").to_string();
let headers = req.headers().clone();
let content_length: Option<u64> = headers
.get(http::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok());
if let Some(len) = content_length
&& len > state.max_request_body as u64
{
return Response::builder()
.status(StatusCode::PAYLOAD_TOO_LARGE)
.body(AxumBody::from("Request body exceeds configured limit"))
.expect("infallible"); }
let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
Ok(permit) => permit,
Err(_) => {
return Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.body(AxumBody::from("Service Unavailable"))
.expect("infallible"); }
};
let content_type = headers
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let data_stream: BodyDataStream = req.into_body().into_data_stream();
let max_body = state.max_request_body;
let mut seen: u64 = 0;
let capped_stream =
data_stream
.map_err(|e| CamelError::Io(e.to_string()))
.map(move |chunk| match chunk {
Ok(bytes) => {
seen = seen.saturating_add(bytes.len() as u64);
if seen > max_body as u64 {
Err(CamelError::ProcessorError(format!(
"Request body exceeds configured limit of {max_body} bytes"
)))
} else {
Ok(bytes)
}
}
Err(e) => Err(e),
});
let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
let stream_body = StreamBody {
stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
metadata: StreamMetadata {
size_hint: content_length,
content_type,
origin: None,
},
};
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
let envelope = RequestEnvelope {
method,
path,
query,
headers,
body: stream_body,
path_params,
reply_tx,
};
if sender.send(envelope).await.is_err() {
return Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.body(AxumBody::from("Consumer unavailable"))
.expect("infallible"); }
match reply_rx.await {
Ok(reply) => {
let reply = match reply.body {
HttpReplyBody::Bytes(b)
if exceeds_max_response_body(b.len(), state.max_response_body) =>
{
HttpReply {
status: 500,
headers: vec![],
body: HttpReplyBody::Bytes(bytes::Bytes::from(
"Response body exceeds configured limit",
)),
}
}
_ => reply,
};
let status =
StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let mut builder = Response::builder().status(status);
for (k, v) in &reply.headers {
builder = builder.header(k.as_str(), v.as_str());
}
match reply.body {
HttpReplyBody::Bytes(b) => {
builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(AxumBody::from("Invalid response headers from consumer"))
.expect("infallible") })
}
HttpReplyBody::Stream(stream) => builder
.body(AxumBody::from_stream(stream))
.unwrap_or_else(|_| {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(AxumBody::from("Invalid response headers from consumer"))
.expect("infallible") }),
}
}
Err(_) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(AxumBody::from("Pipeline error"))
.expect("infallible"), }
} else {
static_dispatch::dispatch_static(&state, req, &path).await
}
}
fn exceeds_max_response_body(len: usize, max: usize) -> bool {
len > max
}
fn title_case_header(name: &str) -> String {
name.split('-')
.map(|part| {
let mut chars = part.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
}
})
.collect::<Vec<_>>()
.join("-")
}
pub(crate) struct HttpKernelAuth {
pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
}
impl HttpKernelAuth {
pub(crate) fn from_security_context(
ctx: &camel_component_api::SecurityContext,
) -> Option<Self> {
Some(Self {
plan: ctx.plan.clone()?,
providers: ctx.providers.clone()?,
})
}
}
fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
max_inflight_requests.max(1)
}
pub(crate) fn max_inflight_requests_limit(configured: usize) -> Result<usize, CamelError> {
if configured > tokio::sync::Semaphore::MAX_PERMITS {
return Err(CamelError::Config(format!(
"maxInflightRequests {configured} exceeds the supported upper bound {} (tokio::sync::Semaphore::MAX_PERMITS)",
tokio::sync::Semaphore::MAX_PERMITS
)));
}
Ok(configured)
}
pub struct HttpConsumer {
config: HttpServerConfig,
runtime: Arc<dyn RuntimeObservability>,
kernel: Option<Arc<HttpKernelAuth>>,
}
impl HttpConsumer {
pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
Self {
config,
runtime,
kernel: None,
}
}
}
#[async_trait::async_trait]
impl Consumer for HttpConsumer {
async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
use camel_component_api::{Body, Exchange, Message};
max_inflight_requests_limit(self.config.max_inflight_requests)?;
let registry = ServerRegistry::global()
.get_or_spawn(
&self.config.host,
self.config.port,
self.config.max_request_body,
self.config.max_response_body,
self.config.max_inflight_requests,
self.runtime.clone(),
ctx.route_id().to_string(),
self.config.tls_config.clone(),
)
.await?;
let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
envelope_channel_capacity(self.config.max_inflight_requests),
);
if let Some(method) = self.config.method.clone() {
let segments = rest_match::parse_path_template(&self.config.path);
registry
.register_rest_endpoint(method, segments, env_tx)
.await?;
} else {
registry
.register_api_route(self.config.path.clone(), env_tx)
.await?;
}
ctx.mark_ready();
let in_flight = ctx.in_flight_counter();
let path = self.config.path.clone();
let registry_for_cleanup = registry.clone();
let server_exited = registry.server_exited.clone();
let cancel_token = ctx.cancel_token();
let kernel = self.kernel.clone();
let mut server_died = false;
loop {
tokio::select! {
_ = ctx.cancelled() => {
break;
}
_ = server_exited.cancelled() => {
server_died = true;
break;
}
envelope = env_rx.recv() => {
let Some(envelope) = envelope else { break; };
let claim =
in_flight.as_ref().map(camel_component_api::InFlightClaim::attach);
let mut msg = Message::default();
msg.set_header("CamelHttpMethod",
serde_json::Value::String(envelope.method.clone()));
msg.set_header("CamelHttpPath",
serde_json::Value::String(envelope.path.clone()));
msg.set_header("CamelHttpQuery",
serde_json::Value::String(envelope.query.clone()));
for (param_name, param_value) in &envelope.path_params {
msg.set_header(
format!("CamelHttpPath_{param_name}"),
serde_json::Value::String(param_value.clone()),
);
}
for (k, v) in &envelope.headers {
if let Ok(val_str) = v.to_str() {
msg.set_header(
title_case_header(k.as_str()),
serde_json::Value::String(val_str.to_string()),
);
}
}
msg.body = Body::Stream(envelope.body);
#[allow(unused_mut)]
let mut exchange = Exchange::new(msg);
#[cfg(feature = "otel")]
{
let headers: HashMap<String, String> = envelope
.headers
.iter()
.filter_map(|(k, v)| {
Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
})
.collect();
camel_otel::extract_into_exchange(&mut exchange, &headers);
}
let reply_tx = envelope.reply_tx;
let sender = ctx.sender().clone();
let path_clone = path.clone();
let cancel = cancel_token.clone();
let auth_headers = envelope.headers.clone();
let auth_uri: http::Uri = {
let full = if envelope.query.is_empty() {
envelope.path.clone()
} else {
format!("{}?{}", envelope.path, envelope.query)
};
full.parse().unwrap_or_default()
};
let kernel = kernel.clone();
tokio::spawn(async move {
if cancel.is_cancelled() {
let _ = reply_tx.send(HttpReply {
status: 503,
headers: vec![],
body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
});
return;
}
if let Some(kernel) = kernel.as_ref()
&& !matches!(
kernel.plan.access_mode,
camel_api::security_policy::AccessMode::Public
)
{
let principal = match camel_auth::extract_token_multi(
&auth_headers,
&auth_uri,
&kernel.plan.credential_sources,
) {
Some(extracted) => {
match camel_auth::kernel_authenticate(
&kernel.plan,
&kernel.providers,
&extracted,
)
.await
{
Ok(principal) => principal,
Err(e) => {
tracing::warn!(
path = %path_clone,
error = %e,
"HTTP request authentication failed"
);
let _ = reply_tx.send(pipeline_error_to_reply(
e,
&path_clone,
));
return;
}
}
}
None => {
tracing::warn!(
path = %path_clone,
"HTTP request rejected: no credential found in any source"
);
let _ = reply_tx.send(pipeline_error_to_reply(
CamelError::Unauthenticated(
"no credential found in any source".to_string(),
),
&path_clone,
));
return;
}
};
camel_auth::install_carrier(&mut exchange, &principal);
}
let (tx, rx) = tokio::sync::oneshot::channel();
let envelope = camel_component_api::consumer::ExchangeEnvelope {
exchange,
reply_tx: Some(tx),
in_flight_claim: claim,
};
let result = match sender.send(envelope).await {
Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
}
.and_then(|r| r);
let reply = match result {
Ok(out) => {
let status = out
.input
.header("CamelHttpResponseCode")
.and_then(|v| {
let raw = v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
let code = raw as u16;
(100..1000).contains(&code).then_some(code)
})
.unwrap_or(200);
let user_content_type = out
.input
.header("Content-Type")
.and_then(|v| v.as_str().map(|s| s.to_string()));
let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
v.to_string().into_bytes(),
)), Some("application/json".to_string())),
Body::Stream(s) => {
let ct = s.metadata.content_type.clone();
match s.stream.lock().await.take() {
Some(stream) => (
HttpReplyBody::Stream(stream),
ct,
),
None => {
tracing::error!(
"Body::Stream already consumed before HTTP reply — returning 500"
);
let error_reply = HttpReply {
status: 500,
headers: vec![],
body: HttpReplyBody::Bytes(bytes::Bytes::new()),
};
if reply_tx.send(error_reply).is_err() {
debug!("reply_tx dropped before error reply could be sent");
}
return;
}
}
}
_ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
};
let resp_headers = select_response_headers(
&out.input.headers,
user_content_type,
inferred_content_type,
);
HttpReply {
status,
headers: resp_headers,
body: reply_body,
}
}
Err(e) => {
pipeline_error_to_reply(e, &path_clone)
}
};
let _ = reply_tx.send(reply);
});
}
}
}
if let Some(method) = &self.config.method {
registry_for_cleanup
.unregister_rest_endpoint(method, &path)
.await;
} else {
registry_for_cleanup.unregister_api_route(&path).await;
}
ServerRegistry::global()
.unregister(&self.config.host, self.config.port)
.await;
if server_died {
tracing::error!(
host = %camel_api::redact::redact_host(&self.config.host),
port = self.config.port,
path = %path,
"Shared HTTP server exited — failing consumer to engage route supervision (ADR-0007)"
);
return Err(CamelError::RouteError(format!(
"shared HTTP server for {}:{} exited unexpectedly; route transport is dead",
camel_api::redact::redact_host(&self.config.host),
self.config.port
)));
}
Ok(())
}
async fn stop(&mut self) -> Result<(), CamelError> {
Ok(())
}
fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
camel_component_api::ConcurrencyModel::Concurrent { max: None }
}
fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
camel_component_api::ConsumerStartupMode::Explicit
}
fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
}
}
pub struct HttpComponent {
config: HttpConfig,
pinned_cache: std::sync::Arc<PinnedClientCache>,
client: reqwest::Client,
strict_tls_error: Option<CamelError>,
}
impl HttpComponent {
pub fn new() -> Self {
let config = HttpConfig::default();
let strict_err = strict_tls_error(&config);
let (client, build_err) = client_or_emergency(&config);
Self {
client,
config,
pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
PINNED_CLIENT_TTL,
PINNED_CLIENT_MAX_ENTRIES,
)),
strict_tls_error: strict_err.or(build_err),
}
}
pub fn with_config(config: HttpConfig) -> Self {
let strict_err = strict_tls_error(&config);
let (client, build_err) = client_or_emergency(&config);
Self {
client,
config,
pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
PINNED_CLIENT_TTL,
PINNED_CLIENT_MAX_ENTRIES,
)),
strict_tls_error: strict_err.or(build_err),
}
}
pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
match config {
Some(cfg) => Self::with_config(cfg),
None => Self::new(),
}
}
}
impl Default for HttpComponent {
fn default() -> Self {
Self::new()
}
}
impl Component for HttpComponent {
fn scheme(&self) -> &str {
"http"
}
fn metadata(&self) -> ComponentMetadata {
HttpEndpointConfig::metadata()
}
fn create_endpoint(
&self,
uri: &str,
ctx: &dyn camel_component_api::ComponentContext,
) -> Result<Box<dyn Endpoint>, CamelError> {
if let Some(err) = &self.strict_tls_error {
return Err(err.clone());
}
self.config.validate()?;
let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
let listener_gate = ListenerGate::default();
ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::gated(
server_config.host.clone(),
server_config.port,
listener_gate.clone(),
)));
self.pinned_cache
.wire(HttpComponentKind::Http, ctx.metrics());
Ok(Box::new(HttpEndpoint {
uri: uri.to_string(),
config,
server_config,
client: self.client.clone(),
pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
http_config: self.config.clone(),
listener_gate,
}))
}
}
pub struct HttpsComponent {
config: HttpConfig,
pinned_cache: std::sync::Arc<PinnedClientCache>,
client: reqwest::Client,
strict_tls_error: Option<CamelError>,
}
impl HttpsComponent {
pub fn new() -> Self {
let config = HttpConfig::default();
let strict_err = strict_tls_error(&config);
let (client, build_err) = client_or_emergency(&config);
Self {
client,
config,
pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
PINNED_CLIENT_TTL,
PINNED_CLIENT_MAX_ENTRIES,
)),
strict_tls_error: strict_err.or(build_err),
}
}
pub fn with_config(config: HttpConfig) -> Self {
let strict_err = strict_tls_error(&config);
let (client, build_err) = client_or_emergency(&config);
Self {
client,
config,
pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
PINNED_CLIENT_TTL,
PINNED_CLIENT_MAX_ENTRIES,
)),
strict_tls_error: strict_err.or(build_err),
}
}
pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
match config {
Some(cfg) => Self::with_config(cfg),
None => Self::new(),
}
}
}
impl Default for HttpsComponent {
fn default() -> Self {
Self::new()
}
}
impl Component for HttpsComponent {
fn scheme(&self) -> &str {
"https"
}
fn metadata(&self) -> ComponentMetadata {
let mut meta = HttpEndpointConfig::metadata();
meta.scheme = "https".to_string();
meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
meta
}
fn create_endpoint(
&self,
uri: &str,
ctx: &dyn camel_component_api::ComponentContext,
) -> Result<Box<dyn Endpoint>, CamelError> {
if let Some(err) = &self.strict_tls_error {
return Err(err.clone());
}
self.config.validate()?;
let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
let listener_gate = ListenerGate::default();
ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::gated(
server_config.host.clone(),
server_config.port,
listener_gate.clone(),
)));
self.pinned_cache
.wire(HttpComponentKind::Https, ctx.metrics());
Ok(Box::new(HttpEndpoint {
uri: uri.to_string(),
config,
server_config,
client: self.client.clone(),
pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
http_config: self.config.clone(),
listener_gate,
}))
}
}
struct HttpEndpoint {
uri: String,
config: HttpEndpointConfig,
server_config: HttpServerConfig,
client: reqwest::Client,
pinned_cache: std::sync::Arc<PinnedClientCache>,
http_config: HttpConfig,
listener_gate: ListenerGate,
}
impl Endpoint for HttpEndpoint {
fn uri(&self) -> &str {
&self.uri
}
fn create_consumer(
&self,
rt: Arc<dyn camel_component_api::RuntimeObservability>,
) -> Result<Box<dyn Consumer>, CamelError> {
let scheme_is_https = self.server_config.scheme == "https";
let has_tls = self.server_config.tls_config.is_some();
if scheme_is_https && !has_tls {
return Err(CamelError::EndpointCreationFailed(
"https:// consumer requires tlsCert and tlsKey parameters".to_string(),
));
}
if !scheme_is_https && has_tls {
return Err(CamelError::EndpointCreationFailed(
"http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
));
}
max_inflight_requests_limit(self.server_config.max_inflight_requests)?;
self.listener_gate.open();
Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
}
fn create_producer(
&self,
rt: Arc<dyn camel_component_api::RuntimeObservability>,
_ctx: &ProducerContext,
) -> Result<BoxProcessor, CamelError> {
let producer = HttpProducer {
config: Arc::new(self.config.clone()),
client: self.client.clone(),
pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
http_config: Arc::new(self.http_config.clone()),
runtime: rt,
};
if let Some(ref provider) = self.config.token_provider {
let layer = BearerTokenLayer::new(Arc::clone(provider));
Ok(BoxProcessor::new(layer.layer(producer)))
} else {
Ok(BoxProcessor::new(producer))
}
}
}
#[derive(Clone)]
struct HttpProducer {
config: Arc<HttpEndpointConfig>,
client: reqwest::Client,
pinned_cache: std::sync::Arc<PinnedClientCache>,
http_config: Arc<HttpConfig>,
runtime: Arc<dyn RuntimeObservability>,
}
impl HttpProducer {
fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
if let Some(ref method) = config.http_method {
return method.to_uppercase();
}
if let Some(method) = exchange
.input
.header("CamelHttpMethod")
.and_then(|v| v.as_str())
{
return method.to_uppercase();
}
if !exchange.input.body.is_empty() {
return "POST".to_string();
}
"GET".to_string()
}
fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
if config.bridge_endpoint {
let Some(query) = resolve_endpoint_query(config)? else {
return Ok(config.base_url.clone());
};
let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
CamelError::ProcessorError(format!(
"invalid base URL '{}': {e}",
redact_url_for_diagnostics(&config.base_url)
))
})?;
let mut url = config.base_url.clone();
url.push('?');
url.push_str(&query);
return Ok(url);
}
if let Some(uri) = exchange
.input
.header("CamelHttpUri")
.and_then(|v| v.as_str())
{
if let Some(fence) = &config.allowed_uri_hosts
&& !uri_host_allowed(uri, fence)?
{
return Err(CamelError::ProcessorError(format!(
"CamelHttpUri host not allowed by allowedUriHosts fence: {}",
redact_url_for_diagnostics(uri)
)));
}
let (base, override_query) = match uri.split_once('?') {
Some((base, query)) => (base, Some(query)),
None => (uri, None),
};
if let Some(query) = override_query {
for (_key, span) in raw_query_pairs(query)? {
validate_raw_query_span(span)?;
}
}
let mut url = base.to_string();
if let Some(path) = exchange
.input
.header("CamelHttpPath")
.and_then(|v| v.as_str())
{
if !url.ends_with('/') && !path.starts_with('/') {
url.push('/');
}
url.push_str(path);
}
if let Some(query) = exchange
.input
.header("CamelHttpQuery")
.and_then(|v| v.as_str())
{
if let Some(merged) = merge_header_query(override_query, query)? {
url.push('?');
url.push_str(&merged);
}
return Ok(url);
}
if let Some(query) = override_query {
url.push('?');
url.push_str(query);
}
return Ok(url);
}
let mut url = config.base_url.clone();
if let Some(path) = exchange
.input
.header("CamelHttpPath")
.and_then(|v| v.as_str())
{
if !url.ends_with('/') && !path.starts_with('/') {
url.push('/');
}
url.push_str(path);
}
if let Some(query) = exchange
.input
.header("CamelHttpQuery")
.and_then(|v| v.as_str())
{
if let Some(merged) =
merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
{
url.push('?');
url.push_str(&merged);
}
return Ok(url);
}
if let Some(query) = resolve_endpoint_query(config)? {
url.push('?');
url.push_str(&query);
}
Ok(url)
}
fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
status >= range.0 && status <= range.1
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AllowedUriHost {
pub host: String,
pub port: Option<u16>,
}
fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
let mut entries = Vec::new();
for segment in raw.split(',') {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
let parsed = url::Url::parse(&format!("http://{segment}"))
.map_err(|_| invalid_allowed_uri_host_entry(segment))?;
if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
return Err(invalid_allowed_uri_host_entry(segment));
}
let Some(host) = parsed.host_str() else {
return Err(invalid_allowed_uri_host_entry(segment));
};
entries.push(AllowedUriHost {
host: host.to_string(),
port: parsed.port(),
});
}
if entries.is_empty() {
return Err(CamelError::InvalidUri(
"allowedUriHosts declares no valid host entries".to_string(),
));
}
Ok(entries)
}
fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
}
pub(crate) fn uri_host_allowed(
url_str: &str,
fence: &[AllowedUriHost],
) -> Result<bool, CamelError> {
let Ok(parsed) = url::Url::parse(url_str) else {
return Ok(false);
};
let Some(host) = parsed.host_str() else {
return Ok(false);
};
let effective_port = parsed.port().or(match parsed.scheme() {
"https" => Some(443_u16),
"http" => Some(80),
_ => None,
});
Ok(fence.iter().any(|entry| {
entry.host == host
&& match entry.port {
None => true,
Some(port) => effective_port == Some(port),
}
}))
}
fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
let mut parts: Vec<String> = Vec::new();
let mut authored_keys = std::collections::HashSet::new();
if let Some(raw) = config.raw_query.as_deref() {
for (key, span) in raw_query_pairs(raw)? {
authored_keys.insert(key.clone());
if is_consumed_option(&key) {
continue;
}
validate_raw_query_span(span)?;
parts.push(span.to_string());
}
}
for (key, value) in &config.query_params {
if !authored_keys.contains(key.as_str()) {
parts.push(format!(
"{}={}",
encode_query_component(key),
encode_query_component(value)
));
}
}
if parts.is_empty() && config.raw_query.as_deref() != Some("") {
return Ok(None);
}
Ok(Some(parts.join("&")))
}
fn merge_header_query(
higher_precedence: Option<&str>,
header_query: &str,
) -> Result<Option<String>, CamelError> {
if header_query.is_empty() {
return Ok(higher_precedence.map(str::to_string));
}
let mut parts: Vec<String> = Vec::new();
let mut higher_keys = std::collections::HashSet::new();
for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
higher_keys.insert(key);
parts.push(span.to_string());
}
for (key, span) in raw_query_pairs(header_query)? {
validate_raw_query_span(span)?;
if !higher_keys.contains(key.as_str()) {
parts.push(span.to_string());
}
}
if parts.is_empty() {
return Ok(None);
}
Ok(Some(parts.join("&")))
}
fn is_legal_query_byte(byte: u8) -> bool {
matches!(byte,
b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
| b'-' | b'.' | b'_' | b'~'
| b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
| b':' | b'@' | b'/' | b'?'
| b'%')
}
fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
for &byte in span.as_bytes() {
if !is_legal_query_byte(byte) {
return Err(CamelError::ProcessorError(format!(
"raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
)));
}
}
Ok(())
}
fn encode_query_component(component: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut out = String::with_capacity(component.len());
for &byte in component.as_bytes() {
match byte {
b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
out.push(byte as char);
}
_ => {
out.push('%');
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
}
}
out
}
pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
match url::Url::parse(raw) {
Ok(mut u) => {
if !u.cannot_be_a_base()
&& u.host_str().is_none()
&& camel_api::redact::window_has_at_sign(raw)
{
return "[redacted]".to_string();
}
if !u.username().is_empty() || u.password().is_some() {
let _ = u.set_username("***");
let _ = u.set_password(None);
}
let s = u.to_string();
camel_api::redact::redact_url(&s)
}
Err(_) => camel_api::redact::redact_url_fail_closed(raw),
}
}
const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
fn truncate_error_body(body: &[u8]) -> String {
if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
String::from_utf8_lossy(body).into_owned()
} else {
let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
s.push_str("...[truncated]");
s
}
}
impl HttpProducer {
fn is_entity_enclosing(method: &str) -> bool {
matches!(method, "POST" | "PUT" | "PATCH")
}
}
impl Service<Exchange> for HttpProducer {
type Response = Exchange;
type Error = CamelError;
type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, exchange: Exchange) -> Self::Future {
let config = self.config.clone();
let shared_client = self.client.clone();
let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
let http_config = self.http_config.clone();
let component_metrics = self.runtime.component_metrics();
Box::pin(async move {
let mut exchange = exchange;
let outcome = async {
let method_str = HttpProducer::resolve_method(&exchange, &config);
let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
let url = HttpProducer::resolve_url(&exchange, &config)?;
ssrf::validate_url_for_ssrf(&url, &config)?;
let resolved = ssrf::resolve_initial_url_for_ssrf(
&url,
config.allow_internal,
config.allow_cleartext,
)
.await?;
let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
pinned_cache
.get_or_build(host.as_str(), addrs, || {
build_client(&http_config, Some((host.as_str(), addrs)))
})
.await?
} else {
shared_client.clone()
};
debug!(
correlation_id = %exchange.correlation_id(),
method = %method_str,
url = %redact_url_for_diagnostics(&url),
"HTTP request"
);
let method = method_str.parse::<reqwest::Method>().map_err(|e| {
CamelError::ProcessorError(format!(
"Invalid HTTP method '{}': {}",
method_str, e
))
})?;
let mut collected_headers: Vec<(
reqwest::header::HeaderName,
reqwest::header::HeaderValue,
)> = Vec::new();
if let Some(user_agent) = &config.user_agent
&& !config.bridge_endpoint
{
match constructed_header("user-agent", user_agent) {
Ok((_, val)) => {
collected_headers.push((reqwest::header::USER_AGENT, val));
}
Err(drop) => debug!(
correlation_id = %exchange.correlation_id(),
header = %drop.name,
"outbound header dropped: {}",
drop.reason
),
}
}
#[cfg(feature = "otel")]
let should_inject_otel = !config.bridge_endpoint;
#[cfg(feature = "otel")]
if should_inject_otel {
let mut otel_headers = HashMap::new();
camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
for (k, v) in otel_headers {
match constructed_header(&k, &v) {
Ok((name, val)) => collected_headers.push((name, val)),
Err(drop) => debug!(
correlation_id = %exchange.correlation_id(),
header = %drop.name,
"outbound header dropped: {}",
drop.reason
),
}
}
}
let conn_tokens = header_policy::connection_tokens(
exchange
.input
.headers
.iter()
.filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
.filter_map(|(_, v)| v.as_str()),
);
let outbound = select_outbound_headers(
&exchange.input.headers,
&config.skip_request_headers,
&conn_tokens,
);
for drop in &outbound.drops {
if let Some(value_kind) = drop.value_kind {
debug!(
correlation_id = %exchange.correlation_id(),
header = %drop.name,
value_kind = value_kind,
"outbound header dropped: {}",
drop.reason
);
} else {
debug!(
correlation_id = %exchange.correlation_id(),
header = %drop.name,
"outbound header dropped: {}",
drop.reason
);
}
}
collected_headers.extend(outbound.accepted);
if !config.bridge_endpoint {
match &config.auth {
HttpAuth::None => {}
HttpAuth::Basic { username, password } => {
use base64::Engine;
let credentials = format!("{username}:{password}");
let encoded =
base64::engine::general_purpose::STANDARD.encode(credentials);
match constructed_header("authorization", &format!("Basic {encoded}")) {
Ok((_, val)) => {
collected_headers.push((reqwest::header::AUTHORIZATION, val));
}
Err(drop) => debug!(
correlation_id = %exchange.correlation_id(),
header = %drop.name,
"outbound header dropped: {}",
drop.reason
),
}
}
HttpAuth::Bearer { token } => {
let bearer = format!("Bearer {token}");
match constructed_header("authorization", &bearer) {
Ok((_, val)) => {
collected_headers.push((reqwest::header::AUTHORIZATION, val));
}
Err(drop) => debug!(
correlation_id = %exchange.correlation_id(),
header = %drop.name,
"outbound header dropped: {}",
drop.reason
),
}
}
}
if config.connection_close {
collected_headers.push((
reqwest::header::CONNECTION,
reqwest::header::HeaderValue::from_static("close"),
));
}
}
let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
let materialized_body: Option<Vec<u8>> = if is_stream_body {
if suppress_body {
std::mem::take(&mut exchange.input.body);
tracing::warn!(
correlation_id = %exchange.correlation_id(),
method = %method_str,
"dropping request body for non-entity-enclosing HTTP method"
);
}
None } else {
let body = std::mem::take(&mut exchange.input.body);
let bytes = body.into_bytes(config.max_body_size).await?;
if bytes.is_empty() {
None
} else if suppress_body {
tracing::warn!(
correlation_id = %exchange.correlation_id(),
method = %method_str,
"dropping request body for non-entity-enclosing HTTP method"
);
None
} else {
Some(bytes.to_vec())
}
};
let response = if config.follow_redirects && !is_stream_body {
ssrf::send_with_ssrf_safe_redirects(
&client,
&shared_client,
&pinned_cache,
&http_config,
&config,
method,
&url,
collected_headers,
materialized_body,
config.max_redirects,
config.response_timeout,
)
.await?
} else {
let mut request = client.request(method, &url);
if let Some(timeout) = config.response_timeout {
request = request.timeout(timeout);
}
for (name, value) in &collected_headers {
request = request.header(name, value);
}
if is_stream_body {
if let Body::Stream(ref s) = exchange.input.body {
let mut stream_lock = s.stream.lock().await;
if let Some(stream) = stream_lock.take() {
request = request.body(reqwest::Body::wrap_stream(stream));
} else {
return Err(CamelError::AlreadyConsumed);
}
}
} else if let Some(ref body_bytes) = materialized_body {
request = request.body(body_bytes.clone());
}
request.send().await.map_err(|e| {
CamelError::ProcessorError(format!("HTTP request failed: {e}"))
})?
};
let status_code = response.status().as_u16();
let status_text = response
.status()
.canonical_reason()
.unwrap_or("Unknown")
.to_string();
for (key, value) in response.headers() {
if config
.skip_response_headers
.iter()
.any(|h| h.eq_ignore_ascii_case(key.as_str()))
{
continue;
}
if let Ok(val_str) = value.to_str() {
exchange.input.set_header(
title_case_header(key.as_str()),
serde_json::Value::String(val_str.to_string()),
);
}
}
exchange.input.set_header(
"CamelHttpResponseCode",
serde_json::Value::Number(status_code.into()),
);
exchange.input.set_header(
"CamelHttpResponseText",
serde_json::Value::String(status_text.clone()),
);
let read_timeout = Duration::from_millis(config.read_timeout_ms);
let response_body = tokio::time::timeout(read_timeout, async {
if let Some(content_len) = response.content_length()
&& content_len > config.max_response_bytes as u64
{
return Err(CamelError::ProcessorError(format!(
"Response body too large: {} bytes exceeds limit of {} bytes",
content_len, config.max_response_bytes
)));
}
use futures::TryStreamExt;
let mut stream = response.bytes_stream();
let mut total: usize = 0;
let mut collected = Vec::new();
while let Some(chunk) = stream.try_next().await.map_err(|e| {
CamelError::ProcessorError(format!("Failed to read response body: {e}"))
})? {
total += chunk.len();
if total > config.max_response_bytes {
return Err(CamelError::ProcessorError(format!(
"Response body too large: {} bytes exceeds limit of {} bytes",
total, config.max_response_bytes
)));
}
collected.push(chunk);
}
let mut result = bytes::BytesMut::with_capacity(total);
for chunk in collected {
result.extend_from_slice(&chunk);
}
Ok::<bytes::Bytes, CamelError>(result.freeze())
})
.await
.map_err(|_| {
CamelError::ProcessorError(format!(
"Read timeout after {}ms",
config.read_timeout_ms
))
})??;
if config.throw_exception_on_failure
&& !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
{
return Err(CamelError::HttpOperationFailed {
method: method_str,
url: redact_url_for_diagnostics(&url),
status_code,
status_text,
response_body: Some(truncate_error_body(&response_body)),
});
}
if !response_body.is_empty() {
exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
}
debug!(
correlation_id = %exchange.correlation_id(),
status = status_code,
url = %redact_url_for_diagnostics(&url),
"HTTP response"
);
Ok(exchange)
}
.await;
component_metrics.observe("http", "request", outcome.is_err());
outcome
})
}
}
#[cfg(test)]
pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
REGISTRY_TEST_MUTEX
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(test)]
static CA_STORE_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
pub(crate) fn lock_ca_store_test_mutex() -> std::sync::MutexGuard<'static, ()> {
CA_STORE_TEST_MUTEX
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
match e {
CamelError::Unauthenticated(msg) => {
tracing::warn!(error = %msg, path = %path, "Authentication failed");
HttpReply {
status: 401,
headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
}
}
CamelError::Unauthorized(msg) => {
tracing::warn!(error = %msg, path = %path, "Authorization failed");
HttpReply {
status: 403,
headers: vec![],
body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
}
}
CamelError::TypeConversionFailed(msg) => {
tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
json_error_reply(400, "bad_request", msg)
}
CamelError::ValidationError(msg) => {
tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
json_error_reply(400, "validation_error", msg)
}
CamelError::ConsumerStopping => {
tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
HttpReply {
status: 503,
headers: vec![],
body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
}
}
CamelError::UnsupportedMediaType { consumed, declared } => {
tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
json_error_reply(
415,
"unsupported_media_type",
format!("consumed {consumed}, declared {declared}"),
)
}
CamelError::NotAcceptable { accept, produced } => {
tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
json_error_reply(
406,
"not_acceptable",
format!("accept {accept}, produced {produced}"),
)
}
e => {
tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
HttpReply {
status: 500,
headers: vec![],
body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
}
}
}
}
fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
let body = serde_json::to_string(&serde_json::json!({
"error": code,
"message": message,
}))
.unwrap_or_else(|_| "{}".to_string()); HttpReply {
status,
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
}
}
const fn json_value_kind(v: &serde_json::Value) -> &'static str {
match v {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "bool",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn select_response_headers(
headers: &HashMap<String, serde_json::Value>,
user_content_type: Option<String>,
inferred_content_type: Option<String>,
) -> Vec<(String, String)> {
let conn_tokens = header_policy::connection_tokens(
headers
.iter()
.filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
.filter_map(|(_, v)| v.as_str()),
);
let mut selected: Vec<(String, String)> = Vec::new();
for (k, v) in headers {
if k.starts_with("Camel") {
debug!(header = %k, "reply header dropped: Camel namespace");
continue;
}
if header_policy::excluded_response(k, &conn_tokens) {
debug!(header = %k, "reply header dropped: emission policy");
continue;
}
match scalar_string_form(v) {
Some(s) => selected.push((k.clone(), s)),
None => debug!(
header = %k,
value_kind = json_value_kind(v),
"reply header dropped: no scalar string form"
),
}
}
if let Some(ct) = user_content_type.or(inferred_content_type) {
selected.push(("Content-Type".to_string(), ct));
}
selected
}
#[derive(Debug)]
struct OutboundHeaderDrop<'a> {
name: &'a str,
reason: &'static str,
value_kind: Option<&'static str>,
}
struct OutboundHeaderSelection<'a> {
accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
drops: Vec<OutboundHeaderDrop<'a>>,
}
fn select_outbound_headers<'a>(
headers: &'a HashMap<String, serde_json::Value>,
skip_request_headers: &[String],
conn_tokens: &[String],
) -> OutboundHeaderSelection<'a> {
let mut accepted = Vec::new();
let mut drops = Vec::new();
for (key, value) in headers {
if key.starts_with("Camel") {
drops.push(OutboundHeaderDrop {
name: key,
reason: "Camel namespace",
value_kind: None,
});
continue;
}
if skip_request_headers
.iter()
.any(|h| h.eq_ignore_ascii_case(key))
{
drops.push(OutboundHeaderDrop {
name: key,
reason: "skip_request_headers",
value_kind: None,
});
continue;
}
if header_policy::excluded_outbound(key, conn_tokens) {
drops.push(OutboundHeaderDrop {
name: key,
reason: "outbound emission policy",
value_kind: None,
});
continue;
}
let Some(val_str) = scalar_string_form(value) else {
drops.push(OutboundHeaderDrop {
name: key,
reason: "no scalar string form",
value_kind: Some(json_value_kind(value)),
});
continue;
};
match constructed_header(key, &val_str) {
Ok((name, val)) => accepted.push((name, val)),
Err(drop) => drops.push(drop),
}
}
OutboundHeaderSelection { accepted, drops }
}
fn constructed_header<'a>(
name: &'a str,
value: &str,
) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
Ok(header_name) => header_name,
Err(_) => {
return Err(OutboundHeaderDrop {
name,
reason: "invalid header name",
value_kind: None,
});
}
};
let header_value = match reqwest::header::HeaderValue::from_str(value) {
Ok(header_value) => header_value,
Err(_) => {
return Err(OutboundHeaderDrop {
name,
reason: "invalid header value",
value_kind: None,
});
}
};
Ok((header_name, header_value))
}
#[cfg(test)]
#[path = "lib_tests.rs"]
mod tests;