1pub mod auth;
2pub mod bundle;
3pub mod config;
4pub mod health;
5pub mod registry;
6pub(crate) mod rest_match;
7pub(crate) mod ssrf;
8pub mod static_config;
9pub mod static_dispatch;
10pub mod static_endpoint;
11use crate::config::parse_ok_status_code_range;
12pub use bundle::HttpBundle;
13pub use bundle::HttpStaticBundle;
14pub use config::HttpConfig;
15pub use health::HttpHealthCheck;
16pub use registry::HttpRouteRegistry;
17pub use static_config::HttpStaticConfig;
18pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
19
20use std::collections::HashMap;
21use std::future::Future;
22use std::pin::Pin;
23use std::sync::{Arc, Mutex, OnceLock};
24use std::task::{Context, Poll};
25use std::time::Duration;
26
27use tokio::sync::OnceCell;
28use tower::Layer;
29use tower::Service;
30use tracing::debug;
31
32use axum::body::BodyDataStream;
33use camel_auth::bearer_token_layer::BearerTokenLayer;
34use camel_auth::oauth2::TokenProvider;
35use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
36use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
37use camel_component_api::{UriComponents, UriConfig, parse_uri};
38use futures::TryStreamExt;
39use futures::stream::BoxStream;
40
41#[derive(Debug, Clone)]
102pub struct HttpEndpointConfig {
103 pub base_url: String,
104 pub http_method: Option<String>,
105 pub throw_exception_on_failure: bool,
106 pub ok_status_code_range: (u16, u16),
107 pub response_timeout: Option<Duration>,
108 pub query_params: HashMap<String, String>,
109 pub allow_private_ips: bool,
110 pub blocked_hosts: Vec<String>,
111 pub max_body_size: usize,
112 pub read_timeout_ms: u64,
113 pub max_response_bytes: usize,
114 pub auth: HttpAuth,
115 pub token_provider: Option<Arc<dyn TokenProvider>>,
116 pub user_agent: Option<String>,
117 pub cookie_handling: CookieHandling,
118 pub bridge_endpoint: bool,
119 pub connection_close: bool,
120 pub skip_request_headers: Vec<String>,
121 pub skip_response_headers: Vec<String>,
122 pub follow_redirects: bool,
123 pub max_redirects: usize,
124}
125
126#[derive(Clone, PartialEq)]
127pub enum HttpAuth {
128 None,
129 Basic { username: String, password: String },
130 Bearer { token: String },
131}
132
133impl std::fmt::Debug for HttpAuth {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 match self {
136 HttpAuth::None => f.write_str("None"),
137 HttpAuth::Basic { username, .. } => f
138 .debug_struct("Basic")
139 .field("username", username)
140 .field("password", &"***")
141 .finish(),
142 HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
143 }
144 }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum CookieHandling {
149 Disabled,
150 InMemory,
151}
152
153const HTTP_CAMEL_OPTIONS: &[&str] = &[
155 "httpMethod",
156 "throwExceptionOnFailure",
157 "okStatusCodeRange",
158 "followRedirects",
159 "maxRedirects",
160 "connectTimeout",
161 "responseTimeout",
162 "allowPrivateIps",
163 "blockedHosts",
164 "maxBodySize",
165 "readTimeout",
166 "maxResponseBytes",
167 "authMethod",
168 "authUsername",
169 "authPassword",
170 "authBearerToken",
171 "userAgent",
172 "cookieHandling",
173 "bridgeEndpoint",
174 "connectionClose",
175 "skipRequestHeaders",
176 "skipResponseHeaders",
177];
178
179impl UriConfig for HttpEndpointConfig {
180 fn scheme() -> &'static str {
182 "http"
183 }
184
185 fn from_uri(uri: &str) -> Result<Self, CamelError> {
186 let parts = parse_uri(uri)?;
187 Self::from_components(parts)
188 }
189
190 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
191 if parts.scheme != "http" && parts.scheme != "https" {
193 return Err(CamelError::InvalidUri(format!(
194 "expected scheme 'http' or 'https', got '{}'",
195 parts.scheme
196 )));
197 }
198
199 let base_url = format!("{}:{}", parts.scheme, parts.path);
202
203 let http_method = parts.params.get("httpMethod").cloned();
204
205 let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
206 Some(v) => parse_bool_param_http(v).map_err(|e| {
207 CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
208 })?,
209 None => true,
210 };
211
212 let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
214 Some(v) => parse_ok_status_code_range(v)?,
215 None => (200, 299),
216 };
217
218 let response_timeout = match parts.params.get("responseTimeout") {
219 Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
220 CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
221 })?),
222 None => None,
223 };
224
225 let allow_private_ips = match parts.params.get("allowPrivateIps") {
227 Some(v) => parse_bool_param_http(v).map_err(|e| {
228 CamelError::InvalidUri(format!("invalid value for allowPrivateIps: {e}"))
229 })?,
230 None => false, };
232
233 let blocked_hosts = parts
235 .params
236 .get("blockedHosts")
237 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
238 .unwrap_or_default();
239
240 let max_body_size = match parts.params.get("maxBodySize") {
241 Some(v) => v.parse::<usize>().map_err(|e| {
242 CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
243 })?,
244 None => 10 * 1024 * 1024, };
246
247 let read_timeout_ms = match parts.params.get("readTimeout") {
248 Some(v) => v.parse::<u64>().map_err(|e| {
249 CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
250 })?,
251 None => 30_000, };
253
254 let max_response_bytes = match parts.params.get("maxResponseBytes") {
255 Some(v) => v.parse::<usize>().map_err(|e| {
256 CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
257 })?,
258 None => 10 * 1024 * 1024, };
260
261 let auth = parse_auth_from_params(&parts.params)?;
262
263 let user_agent = parts.params.get("userAgent").cloned();
264
265 let cookie_handling = match parts.params.get("cookieHandling") {
266 Some(v) if v.eq_ignore_ascii_case("inmemory") => CookieHandling::InMemory,
267 Some(v) if v.eq_ignore_ascii_case("disabled") => CookieHandling::Disabled,
268 Some(v) => {
269 return Err(CamelError::InvalidUri(format!(
270 "invalid value for cookieHandling: {v} (expected Disabled or InMemory)"
271 )));
272 }
273 None => CookieHandling::Disabled,
274 };
275
276 let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
277 Some(v) => parse_bool_param_http(v).map_err(|e| {
278 CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
279 })?,
280 None => false,
281 };
282
283 let connection_close = match parts.params.get("connectionClose") {
284 Some(v) => parse_bool_param_http(v).map_err(|e| {
285 CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
286 })?,
287 None => false,
288 };
289
290 let skip_request_headers = parts
291 .params
292 .get("skipRequestHeaders")
293 .map(|v| {
294 v.split(',')
295 .map(str::trim)
296 .filter(|s| !s.is_empty())
297 .map(|s| s.to_ascii_lowercase())
298 .collect::<Vec<_>>()
299 })
300 .unwrap_or_default();
301
302 let skip_response_headers = parts
303 .params
304 .get("skipResponseHeaders")
305 .map(|v| {
306 v.split(',')
307 .map(str::trim)
308 .filter(|s| !s.is_empty())
309 .map(|s| s.to_ascii_lowercase())
310 .collect::<Vec<_>>()
311 })
312 .unwrap_or_default();
313
314 let follow_redirects = match parts.params.get("followRedirects") {
315 Some(v) => parse_bool_param_http(v).map_err(|e| {
316 CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
317 })?,
318 None => false,
319 };
320
321 let max_redirects = match parts.params.get("maxRedirects") {
322 Some(v) => v.parse::<usize>().map_err(|e| {
323 CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
324 })?,
325 None => 10,
326 };
327
328 let query_params: HashMap<String, String> = parts
330 .params
331 .into_iter()
332 .filter(|(k, _)| !HTTP_CAMEL_OPTIONS.contains(&k.as_str()))
333 .collect();
334
335 Ok(Self {
336 base_url,
337 http_method,
338 throw_exception_on_failure,
339 ok_status_code_range,
340 response_timeout,
341 query_params,
342 allow_private_ips,
343 blocked_hosts,
344 max_body_size,
345 read_timeout_ms,
346 max_response_bytes,
347 auth,
348 token_provider: None,
349 user_agent,
350 cookie_handling,
351 bridge_endpoint,
352 connection_close,
353 skip_request_headers,
354 skip_response_headers,
355 follow_redirects,
356 max_redirects,
357 })
358 }
359}
360
361fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
362 let Some(method) = params.get("authMethod") else {
363 return Ok(HttpAuth::None);
364 };
365
366 if method.eq_ignore_ascii_case("none") {
367 return Ok(HttpAuth::None);
368 }
369
370 if method.eq_ignore_ascii_case("basic") {
371 let username = params.get("authUsername").cloned().ok_or_else(|| {
372 CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
373 })?;
374 let password = params.get("authPassword").cloned().ok_or_else(|| {
375 CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
376 })?;
377 return Ok(HttpAuth::Basic { username, password });
378 }
379
380 if method.eq_ignore_ascii_case("bearer") {
381 let token = params.get("authBearerToken").cloned().ok_or_else(|| {
382 CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
383 })?;
384 return Ok(HttpAuth::Bearer { token });
385 }
386
387 Err(CamelError::InvalidUri(format!(
388 "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
389 )))
390}
391
392fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
393 match value.to_ascii_lowercase().as_str() {
394 "true" | "1" | "yes" => Ok(true),
395 "false" | "0" | "no" => Ok(false),
396 _ => Err(CamelError::InvalidUri(format!(
397 "invalid boolean value: '{value}'"
398 ))),
399 }
400}
401
402impl HttpEndpointConfig {
403 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
404 let parts = parse_uri(uri)?;
405 let mut endpoint = Self::from_components(parts.clone())?;
406 if endpoint.response_timeout.is_none() {
407 endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
408 }
409 if !parts.params.contains_key("allowPrivateIps") {
410 endpoint.allow_private_ips = config.allow_private_ips;
411 }
412 if !parts.params.contains_key("blockedHosts") {
413 endpoint.blocked_hosts = config.blocked_hosts.clone();
414 }
415 if !parts.params.contains_key("maxBodySize") {
416 endpoint.max_body_size = config.max_body_size;
417 }
418 if !parts.params.contains_key("readTimeout") {
419 endpoint.read_timeout_ms = config.read_timeout_ms;
420 }
421 if !parts.params.contains_key("maxResponseBytes") {
422 endpoint.max_response_bytes = config.max_response_bytes;
423 }
424 if !parts.params.contains_key("okStatusCodeRange")
425 && let Some(range) = &config.ok_status_code_range
426 {
427 endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
428 }
429 if !parts.params.contains_key("followRedirects") {
430 endpoint.follow_redirects = config.follow_redirects;
431 }
432 if !parts.params.contains_key("maxRedirects") {
433 endpoint.max_redirects = config.max_redirects.unwrap_or(10);
434 }
435
436 Ok(endpoint)
437 }
438}
439
440#[derive(Debug, Clone)]
446pub struct HttpServerConfig {
447 pub scheme: String,
449 pub host: String,
451 pub port: u16,
453 pub path: String,
455 pub max_request_body: usize,
457 pub max_response_body: usize,
459 pub max_inflight_requests: usize,
461 pub method: Option<String>,
468 pub tls_config: Option<crate::config::ServerTlsConfig>,
471}
472
473impl UriConfig for HttpServerConfig {
474 fn scheme() -> &'static str {
476 "http"
477 }
478
479 fn from_uri(uri: &str) -> Result<Self, CamelError> {
480 let parts = parse_uri(uri)?;
481 Self::from_components(parts)
482 }
483
484 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
485 if parts.scheme != "http" && parts.scheme != "https" {
487 return Err(CamelError::InvalidUri(format!(
488 "expected scheme 'http' or 'https', got '{}'",
489 parts.scheme
490 )));
491 }
492
493 let authority_and_path = parts.path.trim_start_matches('/');
496
497 let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
499 (&authority_and_path[..idx], &authority_and_path[idx..])
500 } else {
501 (authority_and_path, "/")
502 };
503
504 let path = if path_suffix.is_empty() {
505 "/"
506 } else {
507 path_suffix
508 }
509 .to_string();
510
511 let (host, port) = if let Some(colon) = authority.rfind(':') {
513 let port_str = &authority[colon + 1..];
514 match port_str.parse::<u16>() {
515 Ok(p) => (authority[..colon].to_string(), p),
516 Err(_) => {
517 return Err(CamelError::InvalidUri(format!(
518 "invalid port '{}' in authority",
519 port_str
520 )));
521 }
522 }
523 } else {
524 let default_port = if parts.scheme == "https" { 443 } else { 80 };
526 (authority.to_string(), default_port)
527 };
528
529 let max_request_body = parts
530 .params
531 .get("maxRequestBody")
532 .and_then(|v| v.parse::<usize>().ok())
533 .unwrap_or(2 * 1024 * 1024); let max_response_body = parts
536 .params
537 .get("maxResponseBody")
538 .and_then(|v| v.parse::<usize>().ok())
539 .unwrap_or(10 * 1024 * 1024); let max_inflight_requests = parts
542 .params
543 .get("maxInflightRequests")
544 .and_then(|v| v.parse::<usize>().ok())
545 .unwrap_or(1024);
546
547 let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
553
554 Ok(Self {
555 scheme: parts.scheme,
556 host,
557 port,
558 path,
559 max_request_body,
560 max_response_body,
561 max_inflight_requests,
562 method,
563 tls_config: {
564 let cert = parts.params.get("tlsCert").cloned();
565 let key = parts.params.get("tlsKey").cloned();
566 match (cert, key) {
567 (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
568 cert_path: c,
569 key_path: k,
570 }),
571 (None, None) => None,
572 _ => None, }
574 },
575 })
576 }
577}
578
579impl HttpServerConfig {
580 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
581 let parts = parse_uri(uri)?;
582 let mut server = Self::from_components(parts.clone())?;
583 if !parts.params.contains_key("maxRequestBody") {
584 server.max_request_body = config.max_request_body;
585 }
586 if !parts.params.contains_key("maxResponseBody") {
587 server.max_response_body = config.max_body_size;
589 }
590 Ok(server)
591 }
592}
593
594pub enum HttpReplyBody {
602 Bytes(bytes::Bytes),
603 Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
604}
605
606pub struct RequestEnvelope {
611 pub method: String,
612 pub path: String,
613 pub query: String,
614 pub headers: http::HeaderMap,
615 pub body: StreamBody,
616 pub path_params: std::collections::HashMap<String, String>,
622 pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
623}
624
625pub struct HttpReply {
629 pub status: u16,
630 pub headers: Vec<(String, String)>,
631 pub body: HttpReplyBody,
632}
633
634type ServerKey = (String, u16);
639
640struct ServerHandle {
642 registry: HttpRouteRegistry,
643 max_request_body: usize,
644 max_response_body: usize,
645 max_inflight_requests: usize,
646 is_tls: bool,
647 tls_cert_path: Option<String>,
648 tls_key_path: Option<String>,
649}
650
651pub struct ServerRegistry {
653 inner: Mutex<HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>>,
654}
655
656impl ServerRegistry {
657 pub fn global() -> &'static Self {
659 static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
660 INSTANCE.get_or_init(|| ServerRegistry {
661 inner: Mutex::new(HashMap::new()),
662 })
663 }
664
665 #[allow(clippy::too_many_arguments)]
668 pub async fn get_or_spawn(
669 &'static self,
670 host: &str,
671 port: u16,
672 max_request_body: usize,
673 max_response_body: usize,
674 max_inflight_requests: usize,
675 runtime: Arc<dyn RuntimeObservability>,
676 route_id: String,
677 tls_config: Option<crate::config::ServerTlsConfig>,
678 ) -> Result<HttpRouteRegistry, CamelError> {
679 let host_owned = host.to_string();
680
681 let cell = {
682 let mut guard = self.inner.lock().map_err(|_| {
683 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
684 })?;
685 let key = (host.to_string(), port);
686 guard
687 .entry(key)
688 .or_insert_with(|| Arc::new(OnceCell::new()))
689 .clone()
690 };
691
692 if let Some(existing) = cell.get()
693 && existing.max_request_body != max_request_body
694 {
695 return Err(CamelError::EndpointCreationFailed(format!(
696 "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
697 existing.max_request_body, max_request_body
698 )));
699 }
700
701 if let Some(existing) = cell.get()
702 && existing.max_response_body != max_response_body
703 {
704 return Err(CamelError::EndpointCreationFailed(format!(
705 "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
706 existing.max_response_body, max_response_body
707 )));
708 }
709
710 if let Some(existing) = cell.get()
711 && existing.max_inflight_requests != max_inflight_requests
712 {
713 return Err(CamelError::EndpointCreationFailed(format!(
714 "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
715 existing.max_inflight_requests, max_inflight_requests
716 )));
717 }
718
719 if let Some(existing) = cell.get()
721 && existing.is_tls != tls_config.is_some()
722 {
723 return Err(CamelError::EndpointCreationFailed(format!(
724 "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
725 existing.is_tls,
726 tls_config.is_some()
727 )));
728 }
729
730 if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
732 && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
733 || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
734 {
735 return Err(CamelError::EndpointCreationFailed(format!(
736 "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
737 )));
738 }
739
740 let handle = cell
741 .get_or_try_init(|| {
742 let rt = Arc::clone(&runtime);
743 let rid = route_id.clone();
744 async move {
745 let addr = format!("{host_owned}:{port}");
746 let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
747 CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
748 })?;
749 let registry = HttpRouteRegistry::new();
750 let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
751 let task = if let Some(ref tls) = tls_config {
752 let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
753 let std_listener = listener.into_std().map_err(|e| {
755 CamelError::EndpointCreationFailed(format!(
756 "TLS listener conversion: {e}"
757 ))
758 })?;
759 tokio::spawn(run_axum_server_tls(
760 std_listener,
761 rustls_config,
762 registry.clone(),
763 max_request_body,
764 max_response_body,
765 Arc::clone(&inflight),
766 Arc::clone(&rt),
767 rid.clone(),
768 ))
769 } else {
770 tokio::spawn(run_axum_server(
771 listener,
772 registry.clone(),
773 max_request_body,
774 max_response_body,
775 Arc::clone(&inflight),
776 Arc::clone(&rt),
777 rid.clone(),
778 ))
779 };
780 let addr_for_monitor = format!("{host_owned}:{port}");
781 tokio::spawn(monitor_axum_task(
782 task,
783 addr_for_monitor,
784 Arc::clone(&rt),
785 rid,
786 ));
787 Ok::<ServerHandle, CamelError>(ServerHandle {
788 registry,
789 max_request_body,
790 max_response_body,
791 max_inflight_requests,
792 is_tls: tls_config.is_some(),
793 tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
794 tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
795 })
796 }
797 })
798 .await?;
799
800 Ok(handle.registry.clone())
801 }
802
803 #[cfg(test)]
810 pub fn reset() {
811 let instance = Self::global();
812 let mut guard = instance
813 .inner
814 .lock()
815 .expect("ServerRegistry lock poisoned during test reset");
816 guard.clear();
817 }
818}
819
820use axum::{
825 Router,
826 body::Body as AxumBody,
827 extract::{Request, State},
828 http::{Response, StatusCode},
829 response::IntoResponse,
830};
831
832#[derive(Clone)]
833pub(crate) struct AppState {
834 registry: HttpRouteRegistry,
835 max_request_body: usize,
836 max_response_body: usize,
837 inflight: Arc<tokio::sync::Semaphore>,
838}
839
840async fn run_axum_server(
841 listener: tokio::net::TcpListener,
842 registry: HttpRouteRegistry,
843 max_request_body: usize,
844 max_response_body: usize,
845 inflight: Arc<tokio::sync::Semaphore>,
846 runtime: Arc<dyn RuntimeObservability>,
847 route_id: String,
848) {
849 let state = AppState {
850 registry,
851 max_request_body,
852 max_response_body,
853 inflight,
854 };
855 let app = Router::new().fallback(dispatch_handler).with_state(state);
856
857 axum::serve(listener, app).await.unwrap_or_else(|e| {
858 runtime
859 .metrics()
860 .increment_errors(&route_id, "e:http:accept");
861 tracing::error!(error = %e, "Axum server error");
863 });
864}
865
866#[allow(clippy::too_many_arguments)]
867async fn run_axum_server_tls(
868 listener: std::net::TcpListener,
869 rustls_config: tokio_rustls::rustls::ServerConfig,
870 registry: HttpRouteRegistry,
871 max_request_body: usize,
872 max_response_body: usize,
873 inflight: Arc<tokio::sync::Semaphore>,
874 runtime: Arc<dyn RuntimeObservability>,
875 route_id: String,
876) {
877 let state = AppState {
878 registry,
879 max_request_body,
880 max_response_body,
881 inflight,
882 };
883 let app = Router::new().fallback(dispatch_handler).with_state(state);
884
885 let tls_cfg =
886 axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
887
888 axum_server::from_tcp_rustls(listener, tls_cfg)
889 .serve(app.into_make_service())
890 .await
891 .unwrap_or_else(|e| {
892 runtime
893 .metrics()
894 .increment_errors(&route_id, "e:http:accept-tls");
895 tracing::error!(error = %e, "Axum TLS server error");
897 });
898}
899
900async fn monitor_axum_task(
908 handle: tokio::task::JoinHandle<()>,
909 addr: String,
910 runtime: Arc<dyn RuntimeObservability>,
911 route_id: String,
912) {
913 match handle.await {
914 Ok(()) => {
915 }
917 Err(join_err) => {
918 runtime
919 .metrics()
920 .increment_errors(&route_id, "e:http:server-task-exited");
921 tracing::error!(
923 addr = %addr,
924 error = %join_err,
925 "Axum server task exited unexpectedly — all routes on this port are now dead"
926 );
927 }
928 }
929}
930
931fn load_tls_config(
934 cert_path: &str,
935 key_path: &str,
936) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
937 use std::fs::File;
938 use std::io::BufReader;
939
940 let cert_file = File::open(cert_path)
941 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
942 let key_file = File::open(key_path)
943 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
944
945 let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
946 .collect::<Result<Vec<_>, _>>()
947 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
948
949 let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
950 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
951 .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
952
953 tokio_rustls::rustls::ServerConfig::builder()
954 .with_no_client_auth()
955 .with_single_cert(certs, key)
956 .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
957}
958
959async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
960 let path = req.uri().path().to_owned();
961 let method = req.method().to_string();
962
963 let api_sender = {
980 let inner = state.registry.inner.read().await;
981 inner.api_routes.get(&path).cloned()
982 }; let (rest_sender, path_params) = if api_sender.is_some() {
985 (None, Default::default())
987 } else {
988 let inner = state.registry.inner.read().await;
989 match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
990 rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
991 rest_match::MatchOutcome::Ambiguous => {
992 tracing::warn!(
998 method = %method,
999 path = %path,
1000 "ambiguous REST template match — returning 500"
1001 );
1002 return Response::builder()
1003 .status(StatusCode::INTERNAL_SERVER_ERROR)
1004 .body(AxumBody::from("Internal Server Error"))
1005 .expect("infallible"); }
1007 rest_match::MatchOutcome::NotFound => (None, Default::default()),
1008 }
1009 }; let sender = api_sender.or(rest_sender);
1012
1013 if let Some(sender) = sender {
1014 let query = req.uri().query().unwrap_or("").to_string();
1015 let headers = req.headers().clone();
1016
1017 let content_length: Option<u64> = headers
1019 .get(http::header::CONTENT_LENGTH)
1020 .and_then(|v| v.to_str().ok())
1021 .and_then(|s| s.parse().ok());
1022
1023 if let Some(len) = content_length
1024 && len > state.max_request_body as u64
1025 {
1026 return Response::builder()
1027 .status(StatusCode::PAYLOAD_TOO_LARGE)
1028 .body(AxumBody::from("Request body exceeds configured limit"))
1029 .expect("infallible"); }
1031
1032 let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1033 Ok(permit) => permit,
1034 Err(_) => {
1035 return Response::builder()
1036 .status(StatusCode::SERVICE_UNAVAILABLE)
1037 .body(AxumBody::from("Service Unavailable"))
1038 .expect("infallible"); }
1040 };
1041
1042 let content_type = headers
1044 .get(http::header::CONTENT_TYPE)
1045 .and_then(|v| v.to_str().ok())
1046 .map(|s| s.to_string());
1047
1048 let data_stream: BodyDataStream = req.into_body().into_data_stream();
1049 let mapped_stream = data_stream.map_err(|e| CamelError::Io(e.to_string()));
1050 let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(mapped_stream);
1051
1052 let stream_body = StreamBody {
1053 stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1054 metadata: StreamMetadata {
1055 size_hint: content_length,
1056 content_type,
1057 origin: None,
1058 },
1059 };
1060
1061 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1062 let envelope = RequestEnvelope {
1063 method,
1064 path,
1065 query,
1066 headers,
1067 body: stream_body,
1068 path_params,
1069 reply_tx,
1070 };
1071
1072 if sender.send(envelope).await.is_err() {
1073 return Response::builder()
1074 .status(StatusCode::SERVICE_UNAVAILABLE)
1075 .body(AxumBody::from("Consumer unavailable"))
1076 .expect("infallible"); }
1078
1079 match reply_rx.await {
1080 Ok(reply) => {
1081 let reply = match reply.body {
1082 HttpReplyBody::Bytes(b)
1083 if exceeds_max_response_body(b.len(), state.max_response_body) =>
1084 {
1085 HttpReply {
1086 status: 500,
1087 headers: vec![],
1088 body: HttpReplyBody::Bytes(bytes::Bytes::from(
1089 "Response body exceeds configured limit",
1090 )),
1091 }
1092 }
1093 _ => reply,
1094 };
1095
1096 let status =
1097 StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1098 let mut builder = Response::builder().status(status);
1099 for (k, v) in &reply.headers {
1100 builder = builder.header(k.as_str(), v.as_str());
1101 }
1102 match reply.body {
1103 HttpReplyBody::Bytes(b) => {
1104 builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1105 Response::builder()
1106 .status(StatusCode::INTERNAL_SERVER_ERROR)
1107 .body(AxumBody::from("Invalid response headers from consumer"))
1108 .expect("infallible") })
1110 }
1111 HttpReplyBody::Stream(stream) => builder
1112 .body(AxumBody::from_stream(stream))
1113 .unwrap_or_else(|_| {
1114 Response::builder()
1115 .status(StatusCode::INTERNAL_SERVER_ERROR)
1116 .body(AxumBody::from("Invalid response headers from consumer"))
1117 .expect("infallible") }),
1119 }
1120 }
1121 Err(_) => Response::builder()
1122 .status(StatusCode::INTERNAL_SERVER_ERROR)
1123 .body(AxumBody::from("Pipeline error"))
1124 .expect("infallible"), }
1126 } else {
1127 static_dispatch::dispatch_static(&state, req, &path).await
1129 }
1130}
1131
1132fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1133 len > max
1134}
1135
1136fn title_case_header(name: &str) -> String {
1137 name.split('-')
1138 .map(|part| {
1139 let mut chars = part.chars();
1140 match chars.next() {
1141 None => String::new(),
1142 Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1143 }
1144 })
1145 .collect::<Vec<_>>()
1146 .join("-")
1147}
1148
1149pub struct HttpConsumer {
1154 config: HttpServerConfig,
1155 runtime: Arc<dyn RuntimeObservability>,
1157}
1158
1159impl HttpConsumer {
1160 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1161 Self { config, runtime }
1162 }
1163}
1164
1165#[async_trait::async_trait]
1166impl Consumer for HttpConsumer {
1167 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1168 use camel_component_api::{Body, Exchange, Message};
1169
1170 let registry = ServerRegistry::global()
1171 .get_or_spawn(
1172 &self.config.host,
1173 self.config.port,
1174 self.config.max_request_body,
1175 self.config.max_response_body,
1176 self.config.max_inflight_requests,
1177 self.runtime.clone(),
1178 ctx.route_id().to_string(),
1179 self.config.tls_config.clone(),
1180 )
1181 .await?;
1182
1183 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(64);
1185 if let Some(method) = self.config.method.clone() {
1192 let segments = rest_match::parse_path_template(&self.config.path);
1193 registry
1194 .register_rest_endpoint(method, segments, env_tx)
1195 .await;
1196 } else {
1197 registry
1198 .register_api_route(self.config.path.clone(), env_tx)
1199 .await;
1200 }
1201
1202 let path = self.config.path.clone();
1203 let registry_for_cleanup = registry.clone();
1204 let cancel_token = ctx.cancel_token();
1205 loop {
1206 tokio::select! {
1207 _ = ctx.cancelled() => {
1208 break;
1209 }
1210 envelope = env_rx.recv() => {
1211 let Some(envelope) = envelope else { break; };
1212
1213 let mut msg = Message::default();
1215
1216 msg.set_header("CamelHttpMethod",
1218 serde_json::Value::String(envelope.method.clone()));
1219 msg.set_header("CamelHttpPath",
1220 serde_json::Value::String(envelope.path.clone()));
1221 msg.set_header("CamelHttpQuery",
1222 serde_json::Value::String(envelope.query.clone()));
1223
1224 for (param_name, param_value) in &envelope.path_params {
1231 msg.set_header(
1232 format!("CamelHttpPath_{param_name}"),
1233 serde_json::Value::String(param_value.clone()),
1234 );
1235 }
1236
1237 for (k, v) in &envelope.headers {
1239 if let Ok(val_str) = v.to_str() {
1240 msg.set_header(
1241 title_case_header(k.as_str()),
1242 serde_json::Value::String(val_str.to_string()),
1243 );
1244 }
1245 }
1246
1247 msg.body = Body::Stream(envelope.body);
1250
1251 #[allow(unused_mut)]
1252 let mut exchange = Exchange::new(msg);
1253
1254 #[cfg(feature = "otel")]
1256 {
1257 let headers: HashMap<String, String> = envelope
1258 .headers
1259 .iter()
1260 .filter_map(|(k, v)| {
1261 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1262 })
1263 .collect();
1264 camel_otel::extract_into_exchange(&mut exchange, &headers);
1265 }
1266
1267 let reply_tx = envelope.reply_tx;
1268 let sender = ctx.sender().clone();
1269 let path_clone = path.clone();
1270 let cancel = cancel_token.clone();
1271
1272 tokio::spawn(async move {
1292 if cancel.is_cancelled() {
1300 let _ = reply_tx.send(HttpReply {
1301 status: 503,
1302 headers: vec![],
1303 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1304 });
1305 return;
1306 }
1307
1308 let (tx, rx) = tokio::sync::oneshot::channel();
1310 let envelope = camel_component_api::consumer::ExchangeEnvelope {
1311 exchange,
1312 reply_tx: Some(tx),
1313 };
1314
1315 let result = match sender.send(envelope).await {
1316 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1317 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1318 }
1319 .and_then(|r| r);
1320
1321 let reply = match result {
1322 Ok(out) => {
1323 let status = out
1324 .input
1325 .header("CamelHttpResponseCode")
1326 .and_then(|v| {
1327 let raw = v.as_u64()
1328 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
1329 let code = raw as u16;
1330 (100..1000).contains(&code).then_some(code)
1331 })
1332 .unwrap_or(200);
1333
1334 let user_content_type = out
1335 .input
1336 .header("Content-Type")
1337 .and_then(|v| v.as_str().map(|s| s.to_string()));
1338
1339 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
1340 Body::Empty => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
1341 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
1342 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
1343 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
1344 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
1345 v.to_string().into_bytes(),
1346 )), Some("application/json".to_string())),
1347 Body::Stream(s) => {
1348 let ct = s.metadata.content_type.clone();
1349 match s.stream.lock().await.take() {
1350 Some(stream) => (
1351 HttpReplyBody::Stream(stream),
1352 ct,
1353 ),
1354 None => {
1355 tracing::error!(
1357 "Body::Stream already consumed before HTTP reply — returning 500"
1358 );
1359 let error_reply = HttpReply {
1360 status: 500,
1361 headers: vec![],
1362 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
1363 };
1364 if reply_tx.send(error_reply).is_err() {
1365 debug!("reply_tx dropped before error reply could be sent");
1366 }
1367 return;
1368 }
1369 }
1370 }
1371 };
1372
1373 let mut resp_headers: Vec<(String, String)> = out
1374 .input
1375 .headers
1376 .iter()
1377 .filter(|(k, _)| !k.starts_with("Camel"))
1378 .filter(|(k, _)| {
1379 !matches!(
1380 k.to_lowercase().as_str(),
1381 "content-length"
1382 | "content-type"
1383 | "transfer-encoding"
1384 | "connection"
1385 | "cache-control"
1386 | "date"
1387 | "pragma"
1388 | "trailer"
1389 | "upgrade"
1390 | "via"
1391 | "warning"
1392 | "host"
1393 | "user-agent"
1394 | "accept"
1395 | "accept-encoding"
1396 | "accept-language"
1397 | "accept-charset"
1398 | "authorization"
1399 | "proxy-authorization"
1400 | "cookie"
1401 | "expect"
1402 | "from"
1403 | "if-match"
1404 | "if-modified-since"
1405 | "if-none-match"
1406 | "if-range"
1407 | "if-unmodified-since"
1408 | "max-forwards"
1409 | "proxy-connection"
1410 | "range"
1411 | "referer"
1412 | "te"
1413 )
1414 })
1415 .filter_map(|(k, v)| {
1416 v.as_str().map(|s| (k.clone(), s.to_string()))
1417 })
1418 .collect();
1419
1420 let content_type = user_content_type
1421 .or(inferred_content_type);
1422 if let Some(ct) = content_type {
1423 resp_headers.push(("Content-Type".to_string(), ct));
1424 }
1425
1426 HttpReply {
1427 status,
1428 headers: resp_headers,
1429 body: reply_body,
1430 }
1431 }
1432 Err(e) => {
1433 pipeline_error_to_reply(e, &path_clone)
1434 }
1435 };
1436
1437 let _ = reply_tx.send(reply);
1439 });
1440 }
1441 }
1442 }
1443
1444 if let Some(method) = &self.config.method {
1449 registry_for_cleanup
1450 .unregister_rest_endpoint(method, &path)
1451 .await;
1452 } else {
1453 registry_for_cleanup.unregister_api_route(&path).await;
1454 }
1455
1456 Ok(())
1457 }
1458
1459 async fn stop(&mut self) -> Result<(), CamelError> {
1460 Ok(())
1461 }
1462
1463 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
1464 camel_component_api::ConcurrencyModel::Concurrent { max: None }
1465 }
1466}
1467
1468pub struct HttpComponent {
1473 config: HttpConfig,
1474}
1475
1476pub(crate) fn build_client(
1477 config: &HttpConfig,
1478 cookie_handling: CookieHandling,
1479 resolve_override: Option<(&str, &[std::net::SocketAddr])>,
1480) -> reqwest::Client {
1481 let mut builder = reqwest::Client::builder()
1482 .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
1483 .pool_max_idle_per_host(config.pool_max_idle_per_host)
1484 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
1485
1486 builder = builder.redirect(reqwest::redirect::Policy::none());
1490
1491 if let Some((host, addrs)) = resolve_override {
1492 builder = builder.resolve_to_addrs(host, addrs);
1493 }
1494
1495 if let Some(proxy_url) = &config.proxy_url
1496 && let Ok(proxy) = reqwest::Proxy::all(proxy_url)
1497 {
1498 builder = builder.proxy(proxy);
1499 }
1500
1501 if matches!(cookie_handling, CookieHandling::InMemory) {
1502 }
1504
1505 if let Some(tls) = &config.tls
1506 && tls.enabled
1507 {
1508 if tls.insecure || !tls.verify_peer {
1509 tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
1511 builder = builder.danger_accept_invalid_certs(true);
1512 }
1513
1514 if let Some(ca_path) = &tls.ca_cert_path
1515 && let Ok(ca_bytes) = std::fs::read(ca_path)
1516 {
1517 let cert = reqwest::Certificate::from_pem(&ca_bytes)
1518 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes));
1519 if let Ok(ca_cert) = cert {
1520 builder = builder.add_root_certificate(ca_cert);
1521 }
1522 }
1523
1524 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path)
1525 && let (Ok(cert_bytes), Ok(key_bytes)) =
1526 (std::fs::read(cert_path), std::fs::read(key_path))
1527 {
1528 let mut identity_pem = cert_bytes;
1529 identity_pem.extend_from_slice(&key_bytes);
1530 if let Ok(identity) = reqwest::Identity::from_pem(&identity_pem) {
1531 builder = builder.identity(identity);
1532 }
1533 }
1534 }
1535
1536 builder
1537 .build()
1538 .expect("reqwest::Client::build() with valid config should not fail") }
1540
1541impl HttpComponent {
1542 pub fn new() -> Self {
1543 let config = HttpConfig::default();
1544 Self { config }
1545 }
1546
1547 pub fn with_config(config: HttpConfig) -> Self {
1548 Self { config }
1549 }
1550
1551 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1552 match config {
1553 Some(cfg) => Self::with_config(cfg),
1554 None => Self::new(),
1555 }
1556 }
1557}
1558
1559impl Default for HttpComponent {
1560 fn default() -> Self {
1561 Self::new()
1562 }
1563}
1564
1565impl Component for HttpComponent {
1566 fn scheme(&self) -> &str {
1567 "http"
1568 }
1569
1570 fn create_endpoint(
1571 &self,
1572 uri: &str,
1573 ctx: &dyn camel_component_api::ComponentContext,
1574 ) -> Result<Box<dyn Endpoint>, CamelError> {
1575 self.config.validate()?;
1576 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1577 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1578 let client = build_client(&self.config, config.cookie_handling, None);
1579 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1580 server_config.host.clone(),
1581 server_config.port,
1582 )));
1583 Ok(Box::new(HttpEndpoint {
1584 uri: uri.to_string(),
1585 config,
1586 server_config,
1587 client,
1588 http_config: self.config.clone(),
1589 }))
1590 }
1591}
1592
1593pub struct HttpsComponent {
1594 config: HttpConfig,
1595}
1596
1597impl HttpsComponent {
1598 pub fn new() -> Self {
1599 let config = HttpConfig::default();
1600 Self { config }
1601 }
1602
1603 pub fn with_config(config: HttpConfig) -> Self {
1604 Self { config }
1605 }
1606
1607 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1608 match config {
1609 Some(cfg) => Self::with_config(cfg),
1610 None => Self::new(),
1611 }
1612 }
1613}
1614
1615impl Default for HttpsComponent {
1616 fn default() -> Self {
1617 Self::new()
1618 }
1619}
1620
1621impl Component for HttpsComponent {
1622 fn scheme(&self) -> &str {
1623 "https"
1624 }
1625
1626 fn create_endpoint(
1627 &self,
1628 uri: &str,
1629 ctx: &dyn camel_component_api::ComponentContext,
1630 ) -> Result<Box<dyn Endpoint>, CamelError> {
1631 self.config.validate()?;
1632 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1633 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1634 let client = build_client(&self.config, config.cookie_handling, None);
1635 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1636 server_config.host.clone(),
1637 server_config.port,
1638 )));
1639 Ok(Box::new(HttpEndpoint {
1640 uri: uri.to_string(),
1641 config,
1642 server_config,
1643 client,
1644 http_config: self.config.clone(),
1645 }))
1646 }
1647}
1648
1649struct HttpEndpoint {
1654 uri: String,
1655 config: HttpEndpointConfig,
1656 server_config: HttpServerConfig,
1657 client: reqwest::Client,
1658 http_config: HttpConfig,
1659}
1660
1661impl Endpoint for HttpEndpoint {
1662 fn uri(&self) -> &str {
1663 &self.uri
1664 }
1665
1666 fn create_consumer(
1667 &self,
1668 rt: Arc<dyn camel_component_api::RuntimeObservability>,
1669 ) -> Result<Box<dyn Consumer>, CamelError> {
1670 let scheme_is_https = self.server_config.scheme == "https";
1673 let has_tls = self.server_config.tls_config.is_some();
1674
1675 if scheme_is_https && !has_tls {
1676 return Err(CamelError::EndpointCreationFailed(
1677 "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
1678 ));
1679 }
1680 if !scheme_is_https && has_tls {
1681 return Err(CamelError::EndpointCreationFailed(
1682 "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
1683 ));
1684 }
1685 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
1686 }
1687
1688 fn create_producer(
1689 &self,
1690 _rt: Arc<dyn camel_component_api::RuntimeObservability>,
1691 _ctx: &ProducerContext,
1692 ) -> Result<BoxProcessor, CamelError> {
1693 let producer = HttpProducer {
1694 config: Arc::new(self.config.clone()),
1695 client: self.client.clone(),
1696 http_config: Arc::new(self.http_config.clone()),
1697 };
1698 if let Some(ref provider) = self.config.token_provider {
1699 let layer = BearerTokenLayer::new(Arc::clone(provider));
1700 Ok(BoxProcessor::new(layer.layer(producer)))
1701 } else {
1702 Ok(BoxProcessor::new(producer))
1703 }
1704 }
1705}
1706
1707#[derive(Clone)]
1712struct HttpProducer {
1713 config: Arc<HttpEndpointConfig>,
1714 client: reqwest::Client,
1715 http_config: Arc<HttpConfig>,
1716}
1717
1718impl HttpProducer {
1719 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1720 if let Some(ref method) = config.http_method {
1721 return method.to_uppercase();
1722 }
1723 if let Some(method) = exchange
1724 .input
1725 .header("CamelHttpMethod")
1726 .and_then(|v| v.as_str())
1727 {
1728 return method.to_uppercase();
1729 }
1730 if !exchange.input.body.is_empty() {
1731 return "POST".to_string();
1732 }
1733 "GET".to_string()
1734 }
1735
1736 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1737 if let Some(uri) = exchange
1738 .input
1739 .header("CamelHttpUri")
1740 .and_then(|v| v.as_str())
1741 {
1742 let mut url = uri.to_string();
1743 if let Some(path) = exchange
1744 .input
1745 .header("CamelHttpPath")
1746 .and_then(|v| v.as_str())
1747 {
1748 if !url.ends_with('/') && !path.starts_with('/') {
1749 url.push('/');
1750 }
1751 url.push_str(path);
1752 }
1753 if let Some(query) = exchange
1754 .input
1755 .header("CamelHttpQuery")
1756 .and_then(|v| v.as_str())
1757 {
1758 url.push('?');
1759 url.push_str(query);
1760 }
1761 return url;
1762 }
1763
1764 let mut url = config.base_url.clone();
1765
1766 if let Some(path) = exchange
1767 .input
1768 .header("CamelHttpPath")
1769 .and_then(|v| v.as_str())
1770 {
1771 if !url.ends_with('/') && !path.starts_with('/') {
1772 url.push('/');
1773 }
1774 url.push_str(path);
1775 }
1776
1777 if let Some(query) = exchange
1778 .input
1779 .header("CamelHttpQuery")
1780 .and_then(|v| v.as_str())
1781 {
1782 url.push('?');
1783 url.push_str(query);
1784 } else if !config.query_params.is_empty() {
1785 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
1787 parsed.query_pairs_mut().append_pair(k, v);
1788 }
1789 url = parsed.to_string();
1790 }
1791
1792 url
1793 }
1794
1795 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
1796 status >= range.0 && status <= range.1
1797 }
1798}
1799
1800impl Service<Exchange> for HttpProducer {
1801 type Response = Exchange;
1802 type Error = CamelError;
1803 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1804
1805 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1806 Poll::Ready(Ok(()))
1807 }
1808
1809 fn call(&mut self, mut exchange: Exchange) -> Self::Future {
1810 let config = self.config.clone();
1811 let client = self.client.clone();
1812 let http_config = self.http_config.clone();
1813
1814 Box::pin(async move {
1815 let method_str = HttpProducer::resolve_method(&exchange, &config);
1816 let url = HttpProducer::resolve_url(&exchange, &config);
1817
1818 ssrf::validate_url_for_ssrf(&url, &config)?;
1820
1821 let resolved =
1826 ssrf::resolve_initial_url_for_ssrf(&url, config.allow_private_ips).await?;
1827 let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
1828 build_client(
1829 &http_config,
1830 config.cookie_handling,
1831 Some((host.as_str(), addrs)),
1832 )
1833 } else {
1834 client
1835 };
1836
1837 debug!(
1838 correlation_id = %exchange.correlation_id(),
1839 method = %method_str,
1840 url = %url,
1841 "HTTP request"
1842 );
1843
1844 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
1845 CamelError::ProcessorError(format!("Invalid HTTP method '{}': {}", method_str, e))
1846 })?;
1847
1848 let mut collected_headers: Vec<(
1850 reqwest::header::HeaderName,
1851 reqwest::header::HeaderValue,
1852 )> = Vec::new();
1853
1854 if let Some(user_agent) = &config.user_agent
1855 && !config.bridge_endpoint
1856 && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
1857 {
1858 collected_headers.push((reqwest::header::USER_AGENT, val));
1859 }
1860
1861 #[cfg(feature = "otel")]
1863 let should_inject_otel = !config.bridge_endpoint;
1864 #[cfg(feature = "otel")]
1865 if should_inject_otel {
1866 let mut otel_headers = HashMap::new();
1867 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
1868 for (k, v) in otel_headers {
1869 if let (Ok(name), Ok(val)) = (
1870 reqwest::header::HeaderName::from_bytes(k.as_bytes()),
1871 reqwest::header::HeaderValue::from_str(&v),
1872 ) {
1873 collected_headers.push((name, val));
1874 }
1875 }
1876 }
1877
1878 for (key, value) in &exchange.input.headers {
1879 if !key.starts_with("Camel")
1880 && !config
1881 .skip_request_headers
1882 .iter()
1883 .any(|h| h.eq_ignore_ascii_case(key))
1884 && let Some(val_str) = value.as_str()
1885 && let (Ok(name), Ok(val)) = (
1886 reqwest::header::HeaderName::from_bytes(key.as_bytes()),
1887 reqwest::header::HeaderValue::from_str(val_str),
1888 )
1889 {
1890 collected_headers.push((name, val));
1891 }
1892 }
1893
1894 if !config.bridge_endpoint {
1896 match &config.auth {
1897 HttpAuth::None => {}
1898 HttpAuth::Basic { username, password } => {
1899 use base64::Engine;
1900 let credentials = format!("{username}:{password}");
1902 let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
1903 if let Ok(val) =
1904 reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
1905 {
1906 collected_headers.push((reqwest::header::AUTHORIZATION, val));
1907 }
1908 }
1909 HttpAuth::Bearer { token } => {
1910 let bearer = format!("Bearer {token}");
1912 if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
1913 collected_headers.push((reqwest::header::AUTHORIZATION, val));
1914 }
1915 }
1916 }
1917
1918 if config.connection_close
1919 && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
1920 {
1921 collected_headers.push((reqwest::header::CONNECTION, val));
1922 }
1923 }
1924
1925 let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
1927 let materialized_body: Option<Vec<u8>> = if is_stream_body {
1928 None } else {
1930 let body = std::mem::take(&mut exchange.input.body);
1931 let bytes = body.into_bytes(config.max_body_size).await?;
1932 if bytes.is_empty() {
1933 None
1934 } else {
1935 Some(bytes.to_vec())
1936 }
1937 };
1938
1939 let response = if config.follow_redirects && !is_stream_body {
1940 ssrf::send_with_ssrf_safe_redirects(
1942 &client,
1943 &http_config,
1944 &config,
1945 method,
1946 &url,
1947 collected_headers,
1948 materialized_body,
1949 config.max_redirects,
1950 config.response_timeout,
1951 )
1952 .await?
1953 } else {
1954 let mut request = client.request(method, &url);
1956
1957 if let Some(timeout) = config.response_timeout {
1958 request = request.timeout(timeout);
1959 }
1960
1961 for (name, value) in &collected_headers {
1962 request = request.header(name, value);
1963 }
1964
1965 if is_stream_body {
1966 if let Body::Stream(ref s) = exchange.input.body {
1967 let mut stream_lock = s.stream.lock().await;
1968 if let Some(stream) = stream_lock.take() {
1969 request = request.body(reqwest::Body::wrap_stream(stream));
1970 } else {
1971 return Err(CamelError::AlreadyConsumed);
1972 }
1973 }
1974 } else if let Some(ref body_bytes) = materialized_body {
1975 request = request.body(body_bytes.clone());
1976 }
1977
1978 request
1979 .send()
1980 .await
1981 .map_err(|e| CamelError::ProcessorError(format!("HTTP request failed: {e}")))?
1982 };
1983
1984 let status_code = response.status().as_u16();
1985 let status_text = response
1986 .status()
1987 .canonical_reason()
1988 .unwrap_or("Unknown")
1989 .to_string();
1990
1991 for (key, value) in response.headers() {
1992 if config
1993 .skip_response_headers
1994 .iter()
1995 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
1996 {
1997 continue;
1998 }
1999 if let Ok(val_str) = value.to_str() {
2000 exchange.input.set_header(
2001 title_case_header(key.as_str()),
2002 serde_json::Value::String(val_str.to_string()),
2003 );
2004 }
2005 }
2006
2007 exchange.input.set_header(
2008 "CamelHttpResponseCode",
2009 serde_json::Value::Number(status_code.into()),
2010 );
2011 exchange.input.set_header(
2012 "CamelHttpResponseText",
2013 serde_json::Value::String(status_text.clone()),
2014 );
2015
2016 let read_timeout = Duration::from_millis(config.read_timeout_ms);
2018 let response_body = tokio::time::timeout(read_timeout, async {
2019 if let Some(content_len) = response.content_length()
2021 && content_len > config.max_response_bytes as u64
2022 {
2023 return Err(CamelError::ProcessorError(format!(
2024 "Response body too large: {} bytes exceeds limit of {} bytes",
2025 content_len, config.max_response_bytes
2026 )));
2027 }
2028 use futures::TryStreamExt;
2030 let mut stream = response.bytes_stream();
2031 let mut total: usize = 0;
2032 let mut collected = Vec::new();
2033 while let Some(chunk) = stream.try_next().await.map_err(|e| {
2034 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
2035 })? {
2036 total += chunk.len();
2037 if total > config.max_response_bytes {
2038 return Err(CamelError::ProcessorError(format!(
2039 "Response body too large: {} bytes exceeds limit of {} bytes",
2040 total, config.max_response_bytes
2041 )));
2042 }
2043 collected.push(chunk);
2044 }
2045 let mut result = bytes::BytesMut::with_capacity(total);
2046 for chunk in collected {
2047 result.extend_from_slice(&chunk);
2048 }
2049 Ok::<bytes::Bytes, CamelError>(result.freeze())
2050 })
2051 .await
2052 .map_err(|_| {
2053 CamelError::ProcessorError(format!(
2054 "Read timeout after {}ms",
2055 config.read_timeout_ms
2056 ))
2057 })??;
2058
2059 if config.throw_exception_on_failure
2060 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
2061 {
2062 return Err(CamelError::HttpOperationFailed {
2063 method: method_str,
2064 url,
2065 status_code,
2066 status_text,
2067 response_body: Some(String::from_utf8_lossy(&response_body).to_string()),
2068 });
2069 }
2070
2071 if !response_body.is_empty() {
2072 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
2073 }
2074
2075 debug!(
2076 correlation_id = %exchange.correlation_id(),
2077 status = status_code,
2078 url = %url,
2079 "HTTP response"
2080 );
2081 Ok(exchange)
2082 })
2083 }
2084}
2085
2086#[cfg(test)]
2096pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
2097
2098fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
2106 match e {
2107 CamelError::Unauthenticated(msg) => {
2108 tracing::warn!(error = %msg, path = %path, "Authentication failed");
2109 HttpReply {
2110 status: 401,
2111 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
2112 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
2113 }
2114 }
2115 CamelError::Unauthorized(msg) => {
2116 tracing::warn!(error = %msg, path = %path, "Authorization failed");
2117 HttpReply {
2118 status: 403,
2119 headers: vec![],
2120 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
2121 }
2122 }
2123 CamelError::TypeConversionFailed(msg) => {
2124 tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
2125 let body = serde_json::to_string(&serde_json::json!({
2126 "error": "bad_request",
2127 "message": msg,
2128 }))
2129 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2131 status: 400,
2132 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2133 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2134 }
2135 }
2136 CamelError::ValidationError(msg) => {
2137 tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
2138 let body = serde_json::to_string(&serde_json::json!({
2139 "error": "validation_error",
2140 "message": msg,
2141 }))
2142 .unwrap_or_else(|_| "{}".to_string()); HttpReply {
2144 status: 400,
2145 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2146 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2147 }
2148 }
2149 e => {
2150 tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
2152 HttpReply {
2153 status: 500,
2154 headers: vec![],
2155 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
2156 }
2157 }
2158 }
2159}
2160
2161#[cfg(test)]
2162mod tests {
2163 use camel_component_api::test_support::{NoopRuntimeObservability, PanicRuntimeObservability};
2164 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2165 std::sync::Arc::new(PanicRuntimeObservability)
2166 }
2167 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2168 std::sync::Arc::new(PanicRuntimeObservability)
2169 }
2170 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2171 std::sync::Arc::new(NoopRuntimeObservability)
2172 }
2173
2174 use super::*;
2175 use crate::rest_match::PathSegment;
2176 use camel_component_api::{Message, NoOpComponentContext};
2177 use std::sync::Arc;
2178 use std::time::Duration;
2179
2180 fn test_producer_ctx() -> ProducerContext {
2181 ProducerContext::new()
2182 }
2183
2184 #[test]
2185 fn test_http_config_defaults() {
2186 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
2187 assert_eq!(config.base_url, "http://localhost:8080/api");
2188 assert!(config.http_method.is_none());
2189 assert!(config.throw_exception_on_failure);
2190 assert_eq!(config.ok_status_code_range, (200, 299));
2191 assert!(config.response_timeout.is_none());
2192 assert!(matches!(config.auth, HttpAuth::None));
2193 assert!(matches!(config.cookie_handling, CookieHandling::Disabled));
2194 assert!(!config.bridge_endpoint);
2195 assert!(!config.connection_close);
2196 }
2197
2198 #[test]
2199 fn test_http_config_scheme() {
2200 assert_eq!(HttpEndpointConfig::scheme(), "http");
2202 }
2203
2204 #[test]
2205 fn test_http_config_from_components() {
2206 let components = camel_component_api::UriComponents {
2208 scheme: "https".to_string(),
2209 path: "//api.example.com/v1".to_string(),
2210 params: std::collections::HashMap::from([(
2211 "httpMethod".to_string(),
2212 "POST".to_string(),
2213 )]),
2214 };
2215 let config = HttpEndpointConfig::from_components(components).unwrap();
2216 assert_eq!(config.base_url, "https://api.example.com/v1");
2217 assert_eq!(config.http_method, Some("POST".to_string()));
2218 }
2219
2220 #[test]
2221 fn test_http_config_with_options() {
2222 let config = HttpEndpointConfig::from_uri(
2223 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
2224 ).unwrap();
2225 assert_eq!(config.base_url, "https://api.example.com/v1");
2226 assert_eq!(config.http_method, Some("PUT".to_string()));
2227 assert!(!config.throw_exception_on_failure);
2228 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
2229 }
2230
2231 #[test]
2232 fn test_http_endpoint_config_auth_and_headers_options() {
2233 let config = HttpEndpointConfig::from_uri(
2234 "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie&cookieHandling=InMemory",
2235 )
2236 .unwrap();
2237
2238 assert!(matches!(
2239 config.auth,
2240 HttpAuth::Basic { username, password } if username == "u" && password == "p"
2241 ));
2242 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
2243 assert!(matches!(config.cookie_handling, CookieHandling::InMemory));
2244 assert!(config.bridge_endpoint);
2245 assert!(config.connection_close);
2246 assert_eq!(
2247 config.skip_request_headers,
2248 vec!["authorization".to_string(), "x-secret".to_string()]
2249 );
2250 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
2251 }
2252
2253 #[test]
2254 fn test_http_endpoint_config_bearer_auth() {
2255 let config = HttpEndpointConfig::from_uri(
2256 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
2257 )
2258 .unwrap();
2259 assert!(matches!(
2260 config.auth,
2261 HttpAuth::Bearer { token } if token == "t"
2262 ));
2263 }
2264
2265 #[test]
2266 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
2267 let config = HttpConfig::default()
2268 .with_response_timeout_ms(999)
2269 .with_allow_private_ips(true)
2270 .with_blocked_hosts(vec!["evil.com".to_string()])
2271 .with_max_body_size(12345);
2272 let endpoint =
2273 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
2274 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
2275 assert!(endpoint.allow_private_ips);
2276 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
2277 assert_eq!(endpoint.max_body_size, 12345);
2278 }
2279
2280 #[test]
2281 fn test_from_uri_with_defaults_uri_overrides_config() {
2282 let config = HttpConfig::default()
2283 .with_response_timeout_ms(999)
2284 .with_allow_private_ips(true)
2285 .with_blocked_hosts(vec!["evil.com".to_string()])
2286 .with_max_body_size(12345);
2287 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
2288 "http://example.com/api?responseTimeout=500&allowPrivateIps=false&blockedHosts=bad.net&maxBodySize=99",
2289 &config,
2290 )
2291 .unwrap();
2292 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
2293 assert!(!endpoint.allow_private_ips);
2294 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
2295 assert_eq!(endpoint.max_body_size, 99);
2296 }
2297
2298 #[test]
2299 fn test_http_config_ok_status_range() {
2300 let config =
2301 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
2302 assert_eq!(config.ok_status_code_range, (200, 204));
2303 }
2304
2305 #[test]
2306 fn test_http_config_wrong_scheme() {
2307 let result = HttpEndpointConfig::from_uri("file:/tmp");
2308 assert!(result.is_err());
2309 }
2310
2311 #[test]
2312 fn test_http_component_scheme() {
2313 let component = HttpComponent::new();
2314 assert_eq!(component.scheme(), "http");
2315 }
2316
2317 #[test]
2318 fn test_https_component_scheme() {
2319 let component = HttpsComponent::new();
2320 assert_eq!(component.scheme(), "https");
2321 }
2322
2323 #[test]
2324 fn test_http_endpoint_creates_consumer() {
2325 let component = HttpComponent::new();
2326 let ctx = NoOpComponentContext;
2327 let endpoint = component
2328 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
2329 .unwrap();
2330 assert!(endpoint.create_consumer(rt()).is_ok());
2331 }
2332
2333 #[test]
2334 fn test_https_endpoint_creates_consumer_errors_without_tls() {
2335 let component = HttpsComponent::new();
2336 let ctx = NoOpComponentContext;
2337 let endpoint = component
2338 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
2339 .unwrap();
2340 assert!(endpoint.create_consumer(rt()).is_err());
2342 }
2343
2344 #[test]
2345 fn test_http_endpoint_creates_producer() {
2346 let ctx = test_producer_ctx();
2347 let component = HttpComponent::new();
2348 let endpoint_ctx = NoOpComponentContext;
2349 let endpoint = component
2350 .create_endpoint("http://localhost/api", &endpoint_ctx)
2351 .unwrap();
2352 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
2353 }
2354
2355 #[tokio::test]
2360 async fn test_producer_with_token_provider() {
2361 use camel_auth::oauth2::TokenProvider;
2362 use tower::ServiceExt;
2363
2364 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
2365 Arc::new(std::sync::Mutex::new(None));
2366 let captured_clone = Arc::clone(&captured_auth);
2367
2368 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2369 let port = listener.local_addr().unwrap().port();
2370
2371 let _handle = tokio::spawn(async move {
2372 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2373 if let Ok((mut stream, _)) = listener.accept().await {
2374 let mut buf = vec![0u8; 8192];
2375 let n = stream.read(&mut buf).await.unwrap_or(0);
2376 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2377 let auth = request
2378 .lines()
2379 .find(|l| l.to_lowercase().starts_with("authorization:"))
2380 .map(|l| {
2381 l.split(':')
2382 .nth(1)
2383 .map(|s| s.trim().to_string())
2384 .unwrap_or_default()
2385 });
2386 *captured_clone.lock().unwrap() = auth;
2387 let body = r#"{"echo":"ok"}"#;
2388 let resp = format!(
2389 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2390 body.len(),
2391 body
2392 );
2393 let _ = stream.write_all(resp.as_bytes()).await;
2394 }
2395 });
2396
2397 #[derive(Debug)]
2398 struct StaticProvider;
2399 #[async_trait::async_trait]
2400 impl TokenProvider for StaticProvider {
2401 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
2402 Ok("injected-token".into())
2403 }
2404 }
2405
2406 let uri = format!("http://127.0.0.1:{}/api?allowPrivateIps=true", port);
2407 let ctx = test_producer_ctx();
2408 let component = HttpComponent::new();
2409 let endpoint_ctx = NoOpComponentContext;
2410 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
2411 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2412
2413 let exchange = Exchange::new(Message::new("hello"));
2414
2415 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
2416 let mut layered = layer.layer(producer);
2417 let result = layered.ready().await.unwrap().call(exchange).await;
2418 assert!(result.is_ok(), "producer call failed: {:?}", result);
2419
2420 tokio::time::sleep(Duration::from_millis(100)).await;
2421 let auth = captured_auth.lock().unwrap().take();
2422 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
2423 }
2424
2425 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
2426 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2427 let addr = listener.local_addr().unwrap();
2428 let url = format!("http://127.0.0.1:{}", addr.port());
2429
2430 let handle = tokio::spawn(async move {
2431 loop {
2432 if let Ok((mut stream, _)) = listener.accept().await {
2433 tokio::spawn(async move {
2434 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2435 let mut buf = vec![0u8; 4096];
2436 let n = stream.read(&mut buf).await.unwrap_or(0);
2437 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2438
2439 let method = request.split_whitespace().next().unwrap_or("GET");
2440
2441 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
2442 let response = format!(
2443 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
2444 body.len(),
2445 body
2446 );
2447 let _ = stream.write_all(response.as_bytes()).await;
2448 });
2449 }
2450 }
2451 });
2452
2453 (url, handle)
2454 }
2455
2456 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
2457 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2458 let addr = listener.local_addr().unwrap();
2459 let url = format!("http://127.0.0.1:{}", addr.port());
2460
2461 let handle = tokio::spawn(async move {
2462 loop {
2463 if let Ok((mut stream, _)) = listener.accept().await {
2464 let status = status;
2465 tokio::spawn(async move {
2466 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2467 let mut buf = vec![0u8; 4096];
2468 let _ = stream.read(&mut buf).await;
2469
2470 let status_text = match status {
2471 404 => "Not Found",
2472 500 => "Internal Server Error",
2473 _ => "Error",
2474 };
2475 let body = "error body";
2476 let response = format!(
2477 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
2478 status,
2479 status_text,
2480 body.len(),
2481 body
2482 );
2483 let _ = stream.write_all(response.as_bytes()).await;
2484 });
2485 }
2486 }
2487 });
2488
2489 (url, handle)
2490 }
2491
2492 #[tokio::test]
2493 async fn test_http_producer_get_request() {
2494 use tower::ServiceExt;
2495
2496 let (url, _handle) = start_test_server().await;
2497 let ctx = test_producer_ctx();
2498
2499 let component = HttpComponent::new();
2500 let endpoint_ctx = NoOpComponentContext;
2501 let endpoint = component
2502 .create_endpoint(
2503 &format!("{url}/api/test?allowPrivateIps=true"),
2504 &endpoint_ctx,
2505 )
2506 .unwrap();
2507 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2508
2509 let exchange = Exchange::new(Message::default());
2510 let result = producer.oneshot(exchange).await.unwrap();
2511
2512 let status = result
2513 .input
2514 .header("CamelHttpResponseCode")
2515 .and_then(|v| v.as_u64())
2516 .unwrap();
2517 assert_eq!(status, 200);
2518
2519 assert!(!result.input.body.is_empty());
2520 }
2521
2522 #[tokio::test]
2523 async fn test_http_producer_post_with_body() {
2524 use tower::ServiceExt;
2525
2526 let (url, _handle) = start_test_server().await;
2527 let ctx = test_producer_ctx();
2528
2529 let component = HttpComponent::new();
2530 let endpoint_ctx = NoOpComponentContext;
2531 let endpoint = component
2532 .create_endpoint(
2533 &format!("{url}/api/data?allowPrivateIps=true"),
2534 &endpoint_ctx,
2535 )
2536 .unwrap();
2537 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2538
2539 let exchange = Exchange::new(Message::new("request body"));
2540 let result = producer.oneshot(exchange).await.unwrap();
2541
2542 let status = result
2543 .input
2544 .header("CamelHttpResponseCode")
2545 .and_then(|v| v.as_u64())
2546 .unwrap();
2547 assert_eq!(status, 200);
2548 }
2549
2550 #[tokio::test]
2551 async fn test_http_producer_method_from_header() {
2552 use tower::ServiceExt;
2553
2554 let (url, _handle) = start_test_server().await;
2555 let ctx = test_producer_ctx();
2556
2557 let component = HttpComponent::new();
2558 let endpoint_ctx = NoOpComponentContext;
2559 let endpoint = component
2560 .create_endpoint(&format!("{url}/api?allowPrivateIps=true"), &endpoint_ctx)
2561 .unwrap();
2562 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2563
2564 let mut exchange = Exchange::new(Message::default());
2565 exchange.input.set_header(
2566 "CamelHttpMethod",
2567 serde_json::Value::String("DELETE".to_string()),
2568 );
2569
2570 let result = producer.oneshot(exchange).await.unwrap();
2571 let status = result
2572 .input
2573 .header("CamelHttpResponseCode")
2574 .and_then(|v| v.as_u64())
2575 .unwrap();
2576 assert_eq!(status, 200);
2577 }
2578
2579 #[tokio::test]
2580 async fn test_http_producer_forced_method() {
2581 use tower::ServiceExt;
2582
2583 let (url, _handle) = start_test_server().await;
2584 let ctx = test_producer_ctx();
2585
2586 let component = HttpComponent::new();
2587 let endpoint_ctx = NoOpComponentContext;
2588 let endpoint = component
2589 .create_endpoint(
2590 &format!("{url}/api?httpMethod=PUT&allowPrivateIps=true"),
2591 &endpoint_ctx,
2592 )
2593 .unwrap();
2594 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2595
2596 let exchange = Exchange::new(Message::default());
2597 let result = producer.oneshot(exchange).await.unwrap();
2598
2599 let status = result
2600 .input
2601 .header("CamelHttpResponseCode")
2602 .and_then(|v| v.as_u64())
2603 .unwrap();
2604 assert_eq!(status, 200);
2605 }
2606
2607 #[tokio::test]
2608 async fn test_http_producer_throw_exception_on_failure() {
2609 use tower::ServiceExt;
2610
2611 let (url, _handle) = start_status_server(404).await;
2612 let ctx = test_producer_ctx();
2613
2614 let component = HttpComponent::new();
2615 let endpoint_ctx = NoOpComponentContext;
2616 let endpoint = component
2617 .create_endpoint(
2618 &format!("{url}/not-found?allowPrivateIps=true"),
2619 &endpoint_ctx,
2620 )
2621 .unwrap();
2622 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2623
2624 let exchange = Exchange::new(Message::default());
2625 let result = producer.oneshot(exchange).await;
2626 assert!(result.is_err());
2627
2628 match result.unwrap_err() {
2629 CamelError::HttpOperationFailed { status_code, .. } => {
2630 assert_eq!(status_code, 404);
2631 }
2632 e => panic!("Expected HttpOperationFailed, got: {e}"),
2633 }
2634 }
2635
2636 #[tokio::test]
2637 async fn test_http_producer_no_throw_on_failure() {
2638 use tower::ServiceExt;
2639
2640 let (url, _handle) = start_status_server(500).await;
2641 let ctx = test_producer_ctx();
2642
2643 let component = HttpComponent::new();
2644 let endpoint_ctx = NoOpComponentContext;
2645 let endpoint = component
2646 .create_endpoint(
2647 &format!("{url}/error?throwExceptionOnFailure=false&allowPrivateIps=true"),
2648 &endpoint_ctx,
2649 )
2650 .unwrap();
2651 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2652
2653 let exchange = Exchange::new(Message::default());
2654 let result = producer.oneshot(exchange).await.unwrap();
2655
2656 let status = result
2657 .input
2658 .header("CamelHttpResponseCode")
2659 .and_then(|v| v.as_u64())
2660 .unwrap();
2661 assert_eq!(status, 500);
2662 }
2663
2664 #[tokio::test]
2665 async fn test_http_producer_uri_override() {
2666 use tower::ServiceExt;
2667
2668 let (url, _handle) = start_test_server().await;
2669 let ctx = test_producer_ctx();
2670
2671 let component = HttpComponent::new();
2672 let endpoint_ctx = NoOpComponentContext;
2673 let endpoint = component
2674 .create_endpoint(
2675 "http://localhost:1/does-not-exist?allowPrivateIps=true",
2676 &endpoint_ctx,
2677 )
2678 .unwrap();
2679 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2680
2681 let mut exchange = Exchange::new(Message::default());
2682 exchange.input.set_header(
2683 "CamelHttpUri",
2684 serde_json::Value::String(format!("{url}/api")),
2685 );
2686
2687 let result = producer.oneshot(exchange).await.unwrap();
2688 let status = result
2689 .input
2690 .header("CamelHttpResponseCode")
2691 .and_then(|v| v.as_u64())
2692 .unwrap();
2693 assert_eq!(status, 200);
2694 }
2695
2696 #[tokio::test]
2697 async fn test_http_producer_response_headers_mapped() {
2698 use tower::ServiceExt;
2699
2700 let (url, _handle) = start_test_server().await;
2701 let ctx = test_producer_ctx();
2702
2703 let component = HttpComponent::new();
2704 let endpoint_ctx = NoOpComponentContext;
2705 let endpoint = component
2706 .create_endpoint(&format!("{url}/api?allowPrivateIps=true"), &endpoint_ctx)
2707 .unwrap();
2708 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2709
2710 let exchange = Exchange::new(Message::default());
2711 let result = producer.oneshot(exchange).await.unwrap();
2712
2713 assert!(
2714 result.input.header("Content-Type").is_some(),
2715 "Response should have Content-Type header"
2716 );
2717 assert!(result.input.header("CamelHttpResponseText").is_some());
2718 }
2719
2720 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
2725 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2726 let addr = listener.local_addr().unwrap();
2727 let url = format!("http://127.0.0.1:{}", addr.port());
2728
2729 let handle = tokio::spawn(async move {
2730 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2731 loop {
2732 if let Ok((mut stream, _)) = listener.accept().await {
2733 tokio::spawn(async move {
2734 let mut buf = vec![0u8; 4096];
2735 let n = stream.read(&mut buf).await.unwrap_or(0);
2736 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2737
2738 if request.contains("GET /final") {
2740 let body = r#"{"status":"final"}"#;
2741 let response = format!(
2742 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2743 body.len(),
2744 body
2745 );
2746 let _ = stream.write_all(response.as_bytes()).await;
2747 } else {
2748 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n";
2750 let _ = stream.write_all(response.as_bytes()).await;
2751 }
2752 });
2753 }
2754 }
2755 });
2756
2757 (url, handle)
2758 }
2759
2760 #[tokio::test]
2761 async fn test_follow_redirects_false_does_not_follow() {
2762 use tower::ServiceExt;
2763
2764 let (url, _handle) = start_redirect_server().await;
2765 let ctx = test_producer_ctx();
2766
2767 let component =
2768 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
2769 let endpoint_ctx = NoOpComponentContext;
2770 let endpoint = component
2771 .create_endpoint(
2772 &format!("{url}?throwExceptionOnFailure=false&allowPrivateIps=true"),
2773 &endpoint_ctx,
2774 )
2775 .unwrap();
2776 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2777
2778 let exchange = Exchange::new(Message::default());
2779 let result = producer.oneshot(exchange).await.unwrap();
2780
2781 let status = result
2783 .input
2784 .header("CamelHttpResponseCode")
2785 .and_then(|v| v.as_u64())
2786 .unwrap();
2787 assert_eq!(
2788 status, 302,
2789 "Should NOT follow redirect when followRedirects=false"
2790 );
2791 }
2792
2793 #[tokio::test]
2794 async fn test_follow_redirects_true_follows_redirect() {
2795 use tower::ServiceExt;
2796
2797 let (url, _handle) = start_redirect_server().await;
2798 let ctx = test_producer_ctx();
2799
2800 let component =
2801 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
2802 let endpoint_ctx = NoOpComponentContext;
2803 let endpoint = component
2804 .create_endpoint(&format!("{url}?allowPrivateIps=true"), &endpoint_ctx)
2805 .unwrap();
2806 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2807
2808 let exchange = Exchange::new(Message::default());
2809 let result = producer.oneshot(exchange).await.unwrap();
2810
2811 let status = result
2813 .input
2814 .header("CamelHttpResponseCode")
2815 .and_then(|v| v.as_u64())
2816 .unwrap();
2817 assert_eq!(
2818 status, 200,
2819 "Should follow redirect when followRedirects=true"
2820 );
2821 }
2822
2823 #[tokio::test]
2826 async fn test_redirect_to_private_ip_is_ssrf_blocked() {
2827 use tower::ServiceExt;
2828
2829 let (url, _handle) = start_redirect_server().await;
2831 let ctx = test_producer_ctx();
2832
2833 let component =
2834 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
2835 let endpoint_ctx = NoOpComponentContext;
2836 let endpoint = component
2837 .create_endpoint(&format!("{url}?allowPrivateIps=true"), &endpoint_ctx)
2838 .unwrap();
2839 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2840
2841 let exchange = Exchange::new(Message::default());
2842 let result = producer.oneshot(exchange).await;
2843
2844 assert!(
2846 result.is_ok(),
2847 "Redirect should succeed with allowPrivateIps=true, got: {:?}",
2848 result
2849 );
2850 let exchange = result.unwrap();
2851 let status = exchange
2852 .input
2853 .header("CamelHttpResponseCode")
2854 .and_then(|v| v.as_u64())
2855 .unwrap();
2856 assert_eq!(status, 200, "Should follow redirect to /final");
2857 }
2858
2859 #[tokio::test]
2861 async fn test_redirect_to_private_ip_allowed_when_configured() {
2862 use tower::ServiceExt;
2863
2864 let (url, _handle) = start_redirect_server().await;
2866 let ctx = test_producer_ctx();
2867
2868 let component =
2869 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
2870 let endpoint_ctx = NoOpComponentContext;
2871 let endpoint = component
2872 .create_endpoint(&format!("{url}?allowPrivateIps=true"), &endpoint_ctx)
2873 .unwrap();
2874 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2875
2876 let exchange = Exchange::new(Message::default());
2877 let result = producer.oneshot(exchange).await.unwrap();
2878
2879 let status = result
2880 .input
2881 .header("CamelHttpResponseCode")
2882 .and_then(|v| v.as_u64())
2883 .unwrap();
2884 assert_eq!(
2885 status, 200,
2886 "Should follow redirect to private IP when allowPrivateIps=true"
2887 );
2888 }
2889
2890 #[tokio::test]
2893 async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
2894 use tower::ServiceExt;
2895
2896 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2898 let addr = listener.local_addr().unwrap();
2899 let url = format!("http://127.0.0.1:{}", addr.port());
2900
2901 let handle = tokio::spawn(async move {
2902 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2903 loop {
2904 if let Ok((mut stream, _)) = listener.accept().await {
2905 tokio::spawn(async move {
2906 let mut buf = vec![0u8; 4096];
2907 let _ = stream.read(&mut buf).await;
2908 let response = "HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data/\r\nContent-Length: 0\r\n\r\n";
2910 let _ = stream.write_all(response.as_bytes()).await;
2911 });
2912 }
2913 }
2914 });
2915
2916 let ctx = test_producer_ctx();
2917 let component =
2918 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
2919 let endpoint_ctx = NoOpComponentContext;
2920 let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
2922 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2923
2924 let exchange = Exchange::new(Message::default());
2925 let result = producer.oneshot(exchange).await;
2926
2927 assert!(
2929 result.is_err(),
2930 "Redirect to private IP 169.254.169.254 must be blocked when allowPrivateIps=false"
2931 );
2932 let err = result.unwrap_err().to_string();
2933 assert!(
2934 err.contains("blocked IP")
2935 || err.contains("private IP")
2936 || err.contains("SSRF")
2937 || err.contains("not allowed"),
2938 "Error should mention SSRF/IP blocking, got: {err}"
2939 );
2940
2941 handle.abort();
2942 }
2943
2944 #[tokio::test]
2946 async fn test_too_many_redirects_returns_error() {
2947 use tower::ServiceExt;
2948
2949 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2951 let addr = listener.local_addr().unwrap();
2952 let url = format!("http://127.0.0.1:{}", addr.port());
2953
2954 let handle = tokio::spawn(async move {
2955 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2956 loop {
2957 if let Ok((mut stream, _)) = listener.accept().await {
2958 tokio::spawn(async move {
2959 let mut buf = vec![0u8; 4096];
2960 let _ = stream.read(&mut buf).await;
2961 let response =
2963 "HTTP/1.1 302 Found\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n";
2964 let _ = stream.write_all(response.as_bytes()).await;
2965 });
2966 }
2967 }
2968 });
2969
2970 let ctx = test_producer_ctx();
2971 let component =
2972 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
2973 let endpoint_ctx = NoOpComponentContext;
2974 let endpoint = component
2975 .create_endpoint(
2976 &format!("{url}?allowPrivateIps=true&maxRedirects=2"),
2977 &endpoint_ctx,
2978 )
2979 .unwrap();
2980 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2981
2982 let exchange = Exchange::new(Message::default());
2983 let result = producer.oneshot(exchange).await;
2984
2985 match result {
2993 Err(e) => {
2994 let msg = e.to_string();
2996 assert!(
2997 msg.contains("HTTP operation failed") || msg.contains("302"),
2998 "expected redirect-after-exhaustion error, got: {msg}"
2999 );
3000 }
3001 Ok(ex) => {
3002 let response_code = ex
3003 .input
3004 .header("CamelHttpResponseCode")
3005 .and_then(|v| v.as_u64());
3006 assert_eq!(
3007 response_code,
3008 Some(302),
3009 "expected 302 after exhausting redirects"
3010 );
3011 }
3012 }
3013
3014 handle.abort();
3015 }
3016
3017 #[tokio::test]
3018 async fn test_query_params_forwarded_to_http_request() {
3019 use tower::ServiceExt;
3020
3021 let (url, _handle) = start_test_server().await;
3022 let ctx = test_producer_ctx();
3023
3024 let component = HttpComponent::new();
3025 let endpoint_ctx = NoOpComponentContext;
3026 let endpoint = component
3028 .create_endpoint(
3029 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowPrivateIps=true"),
3030 &endpoint_ctx,
3031 )
3032 .unwrap();
3033 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3034
3035 let exchange = Exchange::new(Message::default());
3036 let result = producer.oneshot(exchange).await.unwrap();
3037
3038 let status = result
3041 .input
3042 .header("CamelHttpResponseCode")
3043 .and_then(|v| v.as_u64())
3044 .unwrap();
3045 assert_eq!(status, 200);
3046 }
3047
3048 #[tokio::test]
3049 async fn test_non_camel_query_params_are_forwarded() {
3050 let config = HttpEndpointConfig::from_uri(
3053 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
3054 )
3055 .unwrap();
3056
3057 assert!(
3059 config.query_params.contains_key("apiKey"),
3060 "apiKey should be preserved"
3061 );
3062 assert!(
3063 config.query_params.contains_key("token"),
3064 "token should be preserved"
3065 );
3066 assert_eq!(config.query_params.get("apiKey").unwrap(), "secret123");
3067 assert_eq!(config.query_params.get("token").unwrap(), "abc456");
3068
3069 assert!(
3071 !config.query_params.contains_key("httpMethod"),
3072 "httpMethod should not be forwarded"
3073 );
3074 }
3075
3076 #[test]
3077 fn test_query_params_are_url_encoded_when_resolving_url() {
3078 let config =
3079 HttpEndpointConfig::from_uri("http://example.com/api?q=hello world&tag=a+b").unwrap();
3080 let exchange = Exchange::new(Message::default());
3081
3082 let url = HttpProducer::resolve_url(&exchange, &config);
3083
3084 assert!(url.contains("q=hello+world"), "url was: {url}");
3085 assert!(url.contains("tag=a%2Bb"), "url was: {url}");
3086 }
3087
3088 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
3093 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3094 let addr = listener.local_addr().unwrap();
3095 let url = format!("http://127.0.0.1:{}", addr.port());
3096
3097 let handle = tokio::spawn(async move {
3098 loop {
3099 if let Ok((mut stream, _)) = listener.accept().await {
3100 let delay = delay_ms;
3101 tokio::spawn(async move {
3102 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3103 let mut buf = vec![0u8; 4096];
3104 let _ = stream.read(&mut buf).await;
3105 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
3107 let _ = stream.write_all(headers.as_bytes()).await;
3108 tokio::time::sleep(Duration::from_millis(delay)).await;
3110 let body = r#"{"status":"slow"}"#;
3111 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
3112 let _ = stream.write_all(chunk.as_bytes()).await;
3113 });
3114 }
3115 }
3116 });
3117
3118 (url, handle)
3119 }
3120
3121 #[tokio::test]
3122 async fn test_http_producer_timeout() {
3123 use tower::ServiceExt;
3124
3125 let (url, _handle) = start_slow_server(500).await;
3127 let ctx = test_producer_ctx();
3128
3129 let component = HttpComponent::with_config(
3130 HttpConfig::default()
3131 .with_read_timeout_ms(100)
3132 .with_response_timeout_ms(30_000), );
3134 let endpoint_ctx = NoOpComponentContext;
3135 let endpoint = component
3136 .create_endpoint(&format!("{url}/slow?allowPrivateIps=true"), &endpoint_ctx)
3137 .unwrap();
3138 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3139
3140 let exchange = Exchange::new(Message::default());
3141 let result = producer.oneshot(exchange).await;
3142
3143 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
3144 let err = result.unwrap_err().to_string();
3145 assert!(
3146 err.contains("Read timeout") || err.contains("timeout"),
3147 "Error should mention timeout, got: {}",
3148 err
3149 );
3150 }
3151
3152 #[tokio::test]
3153 async fn test_http_producer_no_timeout_when_fast() {
3154 use tower::ServiceExt;
3155
3156 let (url, _handle) = start_test_server().await;
3157 let ctx = test_producer_ctx();
3158
3159 let component =
3160 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
3161 let endpoint_ctx = NoOpComponentContext;
3162 let endpoint = component
3163 .create_endpoint(&format!("{url}/api?allowPrivateIps=true"), &endpoint_ctx)
3164 .unwrap();
3165 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3166
3167 let exchange = Exchange::new(Message::default());
3168 let result = producer.oneshot(exchange).await.unwrap();
3169
3170 let status = result
3171 .input
3172 .header("CamelHttpResponseCode")
3173 .and_then(|v| v.as_u64())
3174 .unwrap();
3175 assert_eq!(status, 200);
3176 }
3177
3178 #[tokio::test]
3183 async fn test_http_producer_blocks_metadata_endpoint() {
3184 use tower::ServiceExt;
3185
3186 let ctx = test_producer_ctx();
3187 let component = HttpComponent::new();
3188 let endpoint_ctx = NoOpComponentContext;
3189 let endpoint = component
3190 .create_endpoint(
3191 "http://example.com/api?allowPrivateIps=false",
3192 &endpoint_ctx,
3193 )
3194 .unwrap();
3195 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3196
3197 let mut exchange = Exchange::new(Message::default());
3198 exchange.input.set_header(
3199 "CamelHttpUri",
3200 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
3201 );
3202
3203 let result = producer.oneshot(exchange).await;
3204 assert!(result.is_err(), "Should block AWS metadata endpoint");
3205
3206 let err = result.unwrap_err();
3207 assert!(
3208 err.to_string().contains("Private IP"),
3209 "Error should mention private IP blocking, got: {}",
3210 err
3211 );
3212 }
3213
3214 #[test]
3215 fn test_ssrf_config_defaults() {
3216 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
3217 assert!(
3218 !config.allow_private_ips,
3219 "Private IPs should be blocked by default"
3220 );
3221 assert!(
3222 config.blocked_hosts.is_empty(),
3223 "Blocked hosts should be empty by default"
3224 );
3225 }
3226
3227 #[test]
3228 fn test_ssrf_config_allow_private_ips() {
3229 let config =
3230 HttpEndpointConfig::from_uri("http://example.com/api?allowPrivateIps=true").unwrap();
3231 assert!(
3232 config.allow_private_ips,
3233 "Private IPs should be allowed when explicitly set"
3234 );
3235 }
3236
3237 #[test]
3238 fn test_ssrf_config_blocked_hosts() {
3239 let config = HttpEndpointConfig::from_uri(
3240 "http://example.com/api?blockedHosts=evil.com,malware.net",
3241 )
3242 .unwrap();
3243 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
3244 }
3245
3246 #[tokio::test]
3247 async fn test_http_producer_blocks_localhost() {
3248 use tower::ServiceExt;
3249
3250 let ctx = test_producer_ctx();
3251 let component = HttpComponent::new();
3252 let endpoint_ctx = NoOpComponentContext;
3253 let endpoint = component
3254 .create_endpoint("http://example.com/api", &endpoint_ctx)
3255 .unwrap();
3256 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3257
3258 let mut exchange = Exchange::new(Message::default());
3259 exchange.input.set_header(
3260 "CamelHttpUri",
3261 serde_json::Value::String("http://localhost:8080/internal".to_string()),
3262 );
3263
3264 let result = producer.oneshot(exchange).await;
3265 assert!(result.is_err(), "Should block localhost");
3266 }
3267
3268 #[tokio::test]
3269 async fn test_http_producer_blocks_loopback_ip() {
3270 use tower::ServiceExt;
3271
3272 let ctx = test_producer_ctx();
3273 let component = HttpComponent::new();
3274 let endpoint_ctx = NoOpComponentContext;
3275 let endpoint = component
3276 .create_endpoint("http://example.com/api", &endpoint_ctx)
3277 .unwrap();
3278 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3279
3280 let mut exchange = Exchange::new(Message::default());
3281 exchange.input.set_header(
3282 "CamelHttpUri",
3283 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
3284 );
3285
3286 let result = producer.oneshot(exchange).await;
3287 assert!(result.is_err(), "Should block loopback IP");
3288 }
3289
3290 #[tokio::test]
3291 async fn test_http_producer_allows_private_ip_when_enabled() {
3292 use tower::ServiceExt;
3293
3294 let ctx = test_producer_ctx();
3295 let component = HttpComponent::new();
3296 let endpoint_ctx = NoOpComponentContext;
3297 let endpoint = component
3300 .create_endpoint("http://192.168.1.1/api?allowPrivateIps=true", &endpoint_ctx)
3301 .unwrap();
3302 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3303
3304 let exchange = Exchange::new(Message::default());
3305
3306 let result = producer.oneshot(exchange).await;
3309 if let Err(ref e) = result {
3311 let err_str = e.to_string();
3312 assert!(
3313 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
3314 "Should not be SSRF error, got: {}",
3315 err_str
3316 );
3317 }
3318 }
3319
3320 #[test]
3325 fn test_http_server_config_parse() {
3326 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
3327 assert_eq!(cfg.host, "0.0.0.0");
3328 assert_eq!(cfg.port, 8080);
3329 assert_eq!(cfg.path, "/orders");
3330 assert_eq!(cfg.max_inflight_requests, 1024);
3331 }
3332
3333 #[test]
3334 fn test_http_server_config_scheme() {
3335 assert_eq!(HttpServerConfig::scheme(), "http");
3337 }
3338
3339 #[test]
3340 fn test_http_server_config_from_components() {
3341 let components = camel_component_api::UriComponents {
3343 scheme: "https".to_string(),
3344 path: "//0.0.0.0:8443/api".to_string(),
3345 params: std::collections::HashMap::from([
3346 ("maxRequestBody".to_string(), "5242880".to_string()),
3347 ("maxInflightRequests".to_string(), "7".to_string()),
3348 ]),
3349 };
3350 let cfg = HttpServerConfig::from_components(components).unwrap();
3351 assert_eq!(cfg.host, "0.0.0.0");
3352 assert_eq!(cfg.port, 8443);
3353 assert_eq!(cfg.path, "/api");
3354 assert_eq!(cfg.max_request_body, 5242880);
3355 assert_eq!(cfg.max_inflight_requests, 7);
3356 }
3357
3358 #[test]
3359 fn test_http_server_config_default_path() {
3360 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
3361 assert_eq!(cfg.path, "/");
3362 }
3363
3364 #[test]
3365 fn test_http_server_config_wrong_scheme() {
3366 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
3367 }
3368
3369 #[test]
3370 fn test_http_server_config_invalid_port() {
3371 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
3372 }
3373
3374 #[test]
3375 fn test_http_server_config_default_port_by_scheme() {
3376 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
3378 assert_eq!(cfg_http.port, 80);
3379
3380 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
3382 assert_eq!(cfg_https.port, 443);
3383 }
3384
3385 #[test]
3386 fn test_request_envelope_and_reply_are_send() {
3387 fn assert_send<T: Send>() {}
3388 assert_send::<RequestEnvelope>();
3389 assert_send::<HttpReply>();
3390 }
3391
3392 #[test]
3397 fn test_server_registry_global_is_singleton() {
3398 let r1 = ServerRegistry::global();
3399 let r2 = ServerRegistry::global();
3400 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
3401 }
3402
3403 #[allow(clippy::await_holding_lock)]
3404 #[tokio::test]
3405 async fn test_concurrent_get_or_spawn_returns_same_registry() {
3406 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3407 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3408 let port = listener.local_addr().unwrap().port();
3409 drop(listener);
3410
3411 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
3412 Arc::new(std::sync::Mutex::new(Vec::new()));
3413
3414 let mut handles = Vec::new();
3415 for _ in 0..4 {
3416 let results = results.clone();
3417 handles.push(tokio::spawn(async move {
3418 let registry = ServerRegistry::global()
3419 .get_or_spawn(
3420 "127.0.0.1",
3421 port,
3422 2 * 1024 * 1024,
3423 10 * 1024 * 1024,
3424 1024,
3425 test_rt(),
3426 "test-route".into(),
3427 None,
3428 )
3429 .await
3430 .unwrap();
3431 results.lock().unwrap().push(registry);
3432 }));
3433 }
3434
3435 for h in handles {
3436 h.await.unwrap();
3437 }
3438
3439 let registries = results.lock().unwrap();
3440 assert_eq!(registries.len(), 4);
3441 for i in 1..registries.len() {
3442 assert!(
3443 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
3444 "all concurrent callers should get same route registry"
3445 );
3446 }
3447 }
3448
3449 #[test]
3450 fn test_server_registry_distinguishes_host_and_port() {
3451 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3452 let rt = tokio::runtime::Runtime::new().expect("runtime");
3453 rt.block_on(async {
3454 let registry = ServerRegistry::global();
3455 let d1 = registry
3459 .get_or_spawn(
3460 "127.0.0.1",
3461 0,
3462 1024 * 1024,
3463 10 * 1024 * 1024,
3464 1024,
3465 test_rt(),
3466 "test-route-1".into(),
3467 None,
3468 )
3469 .await;
3470 let d2 = registry
3471 .get_or_spawn(
3472 "0.0.0.0",
3473 0,
3474 1024 * 1024,
3475 10 * 1024 * 1024,
3476 1024,
3477 test_rt(),
3478 "test-route-2".into(),
3479 None,
3480 )
3481 .await;
3482 assert!(d1.is_ok());
3483 assert!(d2.is_ok());
3484 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
3485 });
3486 }
3487
3488 #[allow(clippy::await_holding_lock)]
3489 #[tokio::test]
3490 async fn test_shared_server_max_request_body_policy_is_deterministic() {
3491 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3492 let registry = ServerRegistry::global();
3493 let d1 = registry
3495 .get_or_spawn(
3496 "127.0.0.1",
3497 9991,
3498 1024 * 1024,
3499 10 * 1024 * 1024,
3500 1024,
3501 test_rt(),
3502 "test-route".into(),
3503 None,
3504 )
3505 .await;
3506 assert!(d1.is_ok());
3507
3508 let d2 = registry
3511 .get_or_spawn(
3512 "127.0.0.1",
3513 9991,
3514 2 * 1024 * 1024,
3515 10 * 1024 * 1024,
3516 1024,
3517 test_rt(),
3518 "test-route-2".into(),
3519 None,
3520 )
3521 .await;
3522 assert!(d2.is_err());
3523 let err = d2.unwrap_err();
3524 assert!(
3525 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
3526 "Expected incompatible maxRequestBody error, got: {}",
3527 err
3528 );
3529 }
3530
3531 #[test]
3532 fn test_server_registry_reset_clears_entries() {
3533 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3534 let rt = tokio::runtime::Runtime::new().expect("runtime");
3535 rt.block_on(async {
3536 let d1 = ServerRegistry::global()
3538 .get_or_spawn(
3539 "127.0.0.1",
3540 9992,
3541 1024 * 1024,
3542 10 * 1024 * 1024,
3543 1024,
3544 test_rt(),
3545 "test-route".into(),
3546 None,
3547 )
3548 .await;
3549 assert!(d1.is_ok());
3550
3551 let guard = ServerRegistry::global().inner.lock().expect("lock");
3553 assert!(guard.contains_key(&("127.0.0.1".to_string(), 9992)));
3554 drop(guard);
3555
3556 ServerRegistry::reset();
3558
3559 let guard = ServerRegistry::global().inner.lock().expect("lock");
3561 assert!(
3562 guard.is_empty(),
3563 "registry should be empty after reset, has {} entries",
3564 guard.len()
3565 );
3566 });
3567 }
3568
3569 #[tokio::test]
3570 async fn registry_rejects_tls_on_plain_port() {
3571 ServerRegistry::reset();
3572 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
3573
3574 let _r1 = ServerRegistry::global()
3576 .get_or_spawn(
3577 "127.0.0.1",
3578 0,
3579 1024,
3580 1024,
3581 16,
3582 Arc::clone(&rt),
3583 "route-1".into(),
3584 None, )
3586 .await;
3587
3588 let result = ServerRegistry::global()
3590 .get_or_spawn(
3591 "127.0.0.1",
3592 0,
3593 1024,
3594 1024,
3595 16,
3596 Arc::clone(&rt),
3597 "route-2".into(),
3598 Some(crate::config::ServerTlsConfig {
3599 cert_path: "/x.pem".into(),
3600 key_path: "/y.pem".into(),
3601 }),
3602 )
3603 .await;
3604 assert!(result.is_err(), "must reject TLS on plain port");
3605 }
3606
3607 #[tokio::test]
3612 async fn test_dispatch_handler_returns_404_for_unknown_path() {
3613 let registry = HttpRouteRegistry::new();
3614 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3616 let port = listener.local_addr().unwrap().port();
3617 tokio::spawn(run_axum_server(
3618 listener,
3619 registry,
3620 2 * 1024 * 1024,
3621 10 * 1024 * 1024,
3622 Arc::new(tokio::sync::Semaphore::new(1024)),
3623 test_rt(),
3624 "test-route".into(),
3625 ));
3626
3627 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3629
3630 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
3631 .await
3632 .unwrap();
3633 assert_eq!(resp.status().as_u16(), 404);
3634 }
3635
3636 #[tokio::test]
3641 async fn test_http_consumer_start_registers_path() {
3642 use camel_component_api::ConsumerContext;
3643
3644 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3646 let port = listener.local_addr().unwrap().port();
3647 drop(listener); let consumer_cfg = HttpServerConfig {
3650 scheme: "http".to_string(),
3651 host: "127.0.0.1".to_string(),
3652 port,
3653 path: "/ping".to_string(),
3654 max_request_body: 2 * 1024 * 1024,
3655 max_response_body: 10 * 1024 * 1024,
3656 max_inflight_requests: 1024,
3657 method: None,
3658 tls_config: None,
3659 };
3660 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3661
3662 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
3663 let token = tokio_util::sync::CancellationToken::new();
3664 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3665
3666 tokio::spawn(async move {
3667 consumer.start(ctx).await.unwrap();
3668 });
3669
3670 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3671
3672 let client = reqwest::Client::new();
3673 let resp_future = client
3674 .post(format!("http://127.0.0.1:{port}/ping"))
3675 .body("hello world")
3676 .send();
3677
3678 let (http_result, _) = tokio::join!(resp_future, async {
3679 if let Some(mut envelope) = rx.recv().await {
3680 envelope.exchange.input.set_header(
3682 "CamelHttpResponseCode",
3683 serde_json::Value::Number(201.into()),
3684 );
3685 if let Some(reply_tx) = envelope.reply_tx {
3686 let _ = reply_tx.send(Ok(envelope.exchange));
3687 }
3688 }
3689 });
3690
3691 let resp = http_result.unwrap();
3692 assert_eq!(resp.status().as_u16(), 201);
3693
3694 token.cancel();
3695 }
3696
3697 #[tokio::test]
3698 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
3699 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3700
3701 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3702 let port = listener.local_addr().unwrap().port();
3703 drop(listener);
3704
3705 let consumer_cfg = HttpServerConfig {
3706 scheme: "http".to_string(),
3707 host: "127.0.0.1".to_string(),
3708 port,
3709 path: "/saturation".to_string(),
3710 max_request_body: 2 * 1024 * 1024,
3711 max_response_body: 10 * 1024 * 1024,
3712 max_inflight_requests: 1,
3713 method: None,
3714 tls_config: None,
3715 };
3716 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3717
3718 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3719 let token = tokio_util::sync::CancellationToken::new();
3720 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3721 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3722 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3723
3724 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
3725 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
3726
3727 tokio::spawn(async move {
3728 let mut first_seen_tx = Some(first_seen_tx);
3729 let mut unblock_first_rx = Some(unblock_first_rx);
3730
3731 while let Some(envelope) = rx.recv().await {
3732 if let Some(tx) = first_seen_tx.take() {
3733 let _ = tx.send(());
3734 if let Some(rx_unblock) = unblock_first_rx.take() {
3735 let _ = rx_unblock.await;
3736 }
3737 }
3738
3739 if let Some(reply_tx) = envelope.reply_tx {
3740 let _ = reply_tx.send(Ok(envelope.exchange));
3741 }
3742 }
3743 });
3744
3745 let client = reqwest::Client::new();
3746 let first_req = {
3747 let client = client.clone();
3748 async move {
3749 client
3750 .get(format!("http://127.0.0.1:{port}/saturation"))
3751 .send()
3752 .await
3753 .unwrap()
3754 }
3755 };
3756
3757 let first_handle = tokio::spawn(first_req);
3758 first_seen_rx.await.unwrap();
3759
3760 let second_resp = client
3761 .get(format!("http://127.0.0.1:{port}/saturation"))
3762 .send()
3763 .await
3764 .unwrap();
3765
3766 assert_eq!(second_resp.status().as_u16(), 503);
3767
3768 let _ = unblock_first_tx.send(());
3769 let first_resp = first_handle.await.unwrap();
3770 assert_eq!(first_resp.status().as_u16(), 200);
3771
3772 token.cancel();
3773 }
3774
3775 #[tokio::test]
3776 #[allow(clippy::await_holding_lock)]
3777 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
3778 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3779
3780 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3781
3782 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3783 let port = listener.local_addr().unwrap().port();
3784 drop(listener);
3785
3786 let consumer_cfg = HttpServerConfig {
3787 scheme: "http".to_string(),
3788 host: "127.0.0.1".to_string(),
3789 port,
3790 path: "/limit-bytes".to_string(),
3791 max_request_body: 2 * 1024 * 1024,
3792 max_response_body: 16,
3793 max_inflight_requests: 1024,
3794 method: None,
3795 tls_config: None,
3796 };
3797 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3798
3799 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3800 let token = tokio_util::sync::CancellationToken::new();
3801 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3802 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3803 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3804
3805 let client = reqwest::Client::new();
3806 let send_fut = client
3807 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
3808 .send();
3809
3810 let (http_result, _) = tokio::join!(send_fut, async {
3811 if let Some(mut envelope) = rx.recv().await {
3812 envelope.exchange.input.body =
3813 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
3814 if let Some(reply_tx) = envelope.reply_tx {
3815 let _ = reply_tx.send(Ok(envelope.exchange));
3816 }
3817 }
3818 });
3819
3820 let resp = http_result.unwrap();
3821 assert_eq!(resp.status().as_u16(), 500);
3822 let body = resp.text().await.unwrap();
3823 assert_eq!(body, "Response body exceeds configured limit");
3824 token.cancel();
3825 }
3826
3827 #[tokio::test]
3828 #[allow(clippy::await_holding_lock)]
3829 async fn test_http_consumer_enforces_max_response_body_for_json() {
3830 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3831
3832 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3833
3834 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3835 let port = listener.local_addr().unwrap().port();
3836 drop(listener);
3837
3838 let consumer_cfg = HttpServerConfig {
3839 scheme: "http".to_string(),
3840 host: "127.0.0.1".to_string(),
3841 port,
3842 path: "/limit-json".to_string(),
3843 max_request_body: 2 * 1024 * 1024,
3844 max_response_body: 16,
3845 max_inflight_requests: 1024,
3846 method: None,
3847 tls_config: None,
3848 };
3849 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3850
3851 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3852 let token = tokio_util::sync::CancellationToken::new();
3853 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3854 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3855 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3856
3857 let client = reqwest::Client::new();
3858 let send_fut = client
3859 .get(format!("http://127.0.0.1:{port}/limit-json"))
3860 .send();
3861
3862 let (http_result, _) = tokio::join!(send_fut, async {
3863 if let Some(mut envelope) = rx.recv().await {
3864 envelope.exchange.input.body = camel_component_api::Body::Json(
3865 serde_json::json!({"message":"this response is bigger than sixteen"}),
3866 );
3867 if let Some(reply_tx) = envelope.reply_tx {
3868 let _ = reply_tx.send(Ok(envelope.exchange));
3869 }
3870 }
3871 });
3872
3873 let resp = http_result.unwrap();
3874 assert_eq!(resp.status().as_u16(), 500);
3875 let body = resp.text().await.unwrap();
3876 assert_eq!(body, "Response body exceeds configured limit");
3877 token.cancel();
3878 }
3879
3880 #[tokio::test]
3881 #[allow(clippy::await_holding_lock)]
3882 async fn test_http_consumer_enforces_max_response_body_for_xml() {
3883 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3884
3885 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3886
3887 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3888 let port = listener.local_addr().unwrap().port();
3889 drop(listener);
3890
3891 let consumer_cfg = HttpServerConfig {
3892 scheme: "http".to_string(),
3893 host: "127.0.0.1".to_string(),
3894 port,
3895 path: "/limit-xml".to_string(),
3896 max_request_body: 2 * 1024 * 1024,
3897 max_response_body: 16,
3898 max_inflight_requests: 1024,
3899 method: None,
3900 tls_config: None,
3901 };
3902 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3903
3904 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3905 let token = tokio_util::sync::CancellationToken::new();
3906 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3907 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3908 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3909
3910 let client = reqwest::Client::new();
3911 let send_fut = client
3912 .get(format!("http://127.0.0.1:{port}/limit-xml"))
3913 .send();
3914
3915 let (http_result, _) = tokio::join!(send_fut, async {
3916 if let Some(mut envelope) = rx.recv().await {
3917 envelope.exchange.input.body = camel_component_api::Body::Xml(
3918 "<root><value>way-too-large</value></root>".into(),
3919 );
3920 if let Some(reply_tx) = envelope.reply_tx {
3921 let _ = reply_tx.send(Ok(envelope.exchange));
3922 }
3923 }
3924 });
3925
3926 let resp = http_result.unwrap();
3927 assert_eq!(resp.status().as_u16(), 500);
3928 let body = resp.text().await.unwrap();
3929 assert_eq!(body, "Response body exceeds configured limit");
3930 token.cancel();
3931 }
3932
3933 #[tokio::test]
3934 #[allow(clippy::await_holding_lock)]
3935 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
3936 use camel_component_api::{
3937 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
3938 };
3939 use futures::stream;
3940
3941 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3942
3943 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
3944 let port = listener.local_addr().unwrap().port();
3945 drop(listener);
3946
3947 let consumer_cfg = HttpServerConfig {
3948 scheme: "http".to_string(),
3949 host: "0.0.0.0".to_string(),
3950 port,
3951 path: "/limit-stream".to_string(),
3952 max_request_body: 2 * 1024 * 1024,
3953 max_response_body: 16,
3954 max_inflight_requests: 1024,
3955 method: None,
3956 tls_config: None,
3957 };
3958 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3959
3960 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3961 let token = tokio_util::sync::CancellationToken::new();
3962 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3963 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3964 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3965
3966 let client = reqwest::Client::new();
3967 let send_fut = client
3968 .get(format!("http://127.0.0.1:{port}/limit-stream"))
3969 .send();
3970
3971 let (http_result, _) = tokio::join!(send_fut, async {
3972 if let Some(mut envelope) = rx.recv().await {
3973 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
3974 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
3975 let stream = Box::pin(stream::iter(chunks));
3976 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
3977 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
3978 metadata: StreamMetadata {
3979 size_hint: Some(32),
3980 content_type: Some("application/octet-stream".into()),
3981 origin: None,
3982 },
3983 });
3984 if let Some(reply_tx) = envelope.reply_tx {
3985 let _ = reply_tx.send(Ok(envelope.exchange));
3986 }
3987 }
3988 });
3989
3990 let resp = http_result.unwrap();
3991 assert_eq!(resp.status().as_u16(), 200);
3992 let body = resp.bytes().await.unwrap();
3993 assert_eq!(body.len(), 32);
3994 token.cancel();
3995 }
3996
3997 #[tokio::test]
4002 #[allow(clippy::await_holding_lock)]
4003 async fn test_integration_single_consumer_round_trip() {
4004 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4005
4006 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4010
4011 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4013 let port = listener.local_addr().unwrap().port();
4014 drop(listener); let component = HttpComponent::new();
4017 let endpoint_ctx = NoOpComponentContext;
4018 let endpoint = component
4019 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
4020 .unwrap();
4021 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4022
4023 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4024 let token = tokio_util::sync::CancellationToken::new();
4025 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4026
4027 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4028 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4029
4030 let client = reqwest::Client::new();
4031 let send_fut = client
4032 .post(format!("http://127.0.0.1:{port}/echo"))
4033 .header("Content-Type", "text/plain")
4034 .body("ping")
4035 .send();
4036
4037 let (http_result, _) = tokio::join!(send_fut, async {
4038 if let Some(mut envelope) = rx.recv().await {
4039 assert_eq!(
4040 envelope.exchange.input.header("CamelHttpMethod"),
4041 Some(&serde_json::Value::String("POST".into()))
4042 );
4043 assert_eq!(
4044 envelope.exchange.input.header("CamelHttpPath"),
4045 Some(&serde_json::Value::String("/echo".into()))
4046 );
4047 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
4048 if let Some(reply_tx) = envelope.reply_tx {
4049 let _ = reply_tx.send(Ok(envelope.exchange));
4050 }
4051 }
4052 });
4053
4054 let resp = http_result.unwrap();
4055 assert_eq!(resp.status().as_u16(), 200);
4056 let body = resp.text().await.unwrap();
4057 assert_eq!(body, "pong");
4058
4059 token.cancel();
4060 }
4061
4062 #[tokio::test]
4063 #[allow(clippy::await_holding_lock)]
4064 async fn test_integration_two_consumers_shared_port() {
4065 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4066
4067 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4068
4069 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4071 let port = listener.local_addr().unwrap().port();
4072 drop(listener);
4073
4074 let component = HttpComponent::new();
4075 let endpoint_ctx = NoOpComponentContext;
4076
4077 let endpoint_a = component
4079 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
4080 .unwrap();
4081 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
4082
4083 let endpoint_b = component
4085 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
4086 .unwrap();
4087 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
4088
4089 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4090 let token_a = tokio_util::sync::CancellationToken::new();
4091 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
4092
4093 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4094 let token_b = tokio_util::sync::CancellationToken::new();
4095 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
4096
4097 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
4098 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
4099 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4100
4101 let client = reqwest::Client::new();
4102
4103 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
4105 let (resp_hello, _) = tokio::join!(fut_hello, async {
4106 if let Some(mut envelope) = rx_a.recv().await {
4107 envelope.exchange.input.body =
4108 camel_component_api::Body::Text("hello-response".to_string());
4109 if let Some(reply_tx) = envelope.reply_tx {
4110 let _ = reply_tx.send(Ok(envelope.exchange));
4111 }
4112 }
4113 });
4114
4115 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
4117 let (resp_world, _) = tokio::join!(fut_world, async {
4118 if let Some(mut envelope) = rx_b.recv().await {
4119 envelope.exchange.input.body =
4120 camel_component_api::Body::Text("world-response".to_string());
4121 if let Some(reply_tx) = envelope.reply_tx {
4122 let _ = reply_tx.send(Ok(envelope.exchange));
4123 }
4124 }
4125 });
4126
4127 let body_a = resp_hello.unwrap().text().await.unwrap();
4128 let body_b = resp_world.unwrap().text().await.unwrap();
4129
4130 assert_eq!(body_a, "hello-response");
4131 assert_eq!(body_b, "world-response");
4132
4133 token_a.cancel();
4134 token_b.cancel();
4135 }
4136
4137 #[tokio::test]
4138 #[allow(clippy::await_holding_lock)]
4139 async fn test_integration_unregistered_path_returns_404() {
4140 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4141
4142 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4143
4144 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4146 let port = listener.local_addr().unwrap().port();
4147 drop(listener);
4148
4149 let component = HttpComponent::new();
4150 let endpoint_ctx = NoOpComponentContext;
4151 let endpoint = component
4152 .create_endpoint(
4153 &format!("http://127.0.0.1:{port}/registered"),
4154 &endpoint_ctx,
4155 )
4156 .unwrap();
4157 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4158
4159 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4160 let token = tokio_util::sync::CancellationToken::new();
4161 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4162
4163 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4164
4165 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
4167 loop {
4168 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
4169 .await
4170 .is_ok()
4171 {
4172 break;
4173 }
4174 if std::time::Instant::now() >= deadline {
4175 panic!("HTTP server did not start within 5s on port {port}");
4176 }
4177 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4178 }
4179
4180 let client = reqwest::Client::new();
4181 let resp = client
4182 .get(format!("http://127.0.0.1:{port}/not-there"))
4183 .send()
4184 .await
4185 .unwrap();
4186 assert_eq!(resp.status().as_u16(), 404);
4187
4188 token.cancel();
4189 }
4190
4191 #[test]
4192 fn test_http_consumer_declares_concurrent() {
4193 use camel_component_api::ConcurrencyModel;
4194
4195 let config = HttpServerConfig {
4196 scheme: "http".to_string(),
4197 host: "127.0.0.1".to_string(),
4198 port: 19999,
4199 path: "/test".to_string(),
4200 max_request_body: 2 * 1024 * 1024,
4201 max_response_body: 10 * 1024 * 1024,
4202 max_inflight_requests: 1024,
4203 method: None,
4204 tls_config: None,
4205 };
4206 let consumer = HttpConsumer::new(config, test_rt());
4207 assert_eq!(
4208 consumer.concurrency_model(),
4209 ConcurrencyModel::Concurrent { max: None }
4210 );
4211 }
4212
4213 #[test]
4214 fn server_config_parses_tls_cert_and_key() {
4215 let cfg = HttpServerConfig::from_uri(
4216 "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
4217 )
4218 .unwrap();
4219 assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
4220 assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
4221 }
4222
4223 #[test]
4224 fn server_config_no_tls_when_params_absent() {
4225 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
4226 assert!(cfg.tls_config.is_none());
4227 }
4228
4229 #[tokio::test]
4234 async fn test_http_reply_body_stream_variant_exists() {
4235 use bytes::Bytes;
4236 use camel_component_api::CamelError;
4237 use futures::stream;
4238
4239 let chunks: Vec<Result<Bytes, CamelError>> =
4240 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
4241 let stream = Box::pin(stream::iter(chunks));
4242 let reply_body = HttpReplyBody::Stream(stream);
4243 match reply_body {
4245 HttpReplyBody::Stream(_) => {}
4246 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
4247 }
4248 }
4249
4250 #[cfg(feature = "otel")]
4255 mod otel_tests {
4256 use super::*;
4257 use camel_component_api::Message;
4258 use tower::ServiceExt;
4259
4260 #[tokio::test]
4261 async fn test_producer_injects_traceparent_header() {
4262 let (url, _handle) = start_test_server_with_header_capture().await;
4263 let ctx = test_producer_ctx();
4264
4265 let component = HttpComponent::new();
4266 let endpoint_ctx = NoOpComponentContext;
4267 let endpoint = component
4268 .create_endpoint(&format!("{url}/api?allowPrivateIps=true"), &endpoint_ctx)
4269 .unwrap();
4270 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4271
4272 let mut exchange = Exchange::new(Message::default());
4274 let mut headers = std::collections::HashMap::new();
4275 headers.insert(
4276 "traceparent".to_string(),
4277 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
4278 );
4279 camel_otel::extract_into_exchange(&mut exchange, &headers);
4280
4281 let result = producer.oneshot(exchange).await.unwrap();
4282
4283 let status = result
4285 .input
4286 .header("CamelHttpResponseCode")
4287 .and_then(|v| v.as_u64())
4288 .unwrap();
4289 assert_eq!(status, 200);
4290
4291 let traceparent = result.input.header("X-Received-Traceparent");
4293 assert!(
4294 traceparent.is_some(),
4295 "traceparent header should have been sent"
4296 );
4297
4298 let traceparent_str = traceparent.unwrap().as_str().unwrap();
4299 let parts: Vec<&str> = traceparent_str.split('-').collect();
4301 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
4302 assert_eq!(parts[0], "00", "version should be 00");
4303 assert_eq!(
4304 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
4305 "trace-id should match"
4306 );
4307 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
4308 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
4309 }
4310
4311 #[tokio::test]
4312 async fn test_consumer_extracts_traceparent_header() {
4313 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4314
4315 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4317 let port = listener.local_addr().unwrap().port();
4318 drop(listener);
4319
4320 let component = HttpComponent::new();
4321 let endpoint_ctx = NoOpComponentContext;
4322 let endpoint = component
4323 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
4324 .unwrap();
4325 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4326
4327 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4328 let token = tokio_util::sync::CancellationToken::new();
4329 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4330
4331 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4332 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4333
4334 let client = reqwest::Client::new();
4336 let send_fut = client
4337 .post(format!("http://127.0.0.1:{port}/trace"))
4338 .header(
4339 "traceparent",
4340 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
4341 )
4342 .body("test")
4343 .send();
4344
4345 let (http_result, _) = tokio::join!(send_fut, async {
4346 if let Some(envelope) = rx.recv().await {
4347 let mut injected_headers = std::collections::HashMap::new();
4350 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
4351
4352 assert!(
4353 injected_headers.contains_key("traceparent"),
4354 "Exchange should have traceparent after extraction"
4355 );
4356
4357 let traceparent = injected_headers.get("traceparent").unwrap();
4358 let parts: Vec<&str> = traceparent.split('-').collect();
4359 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
4360 assert_eq!(
4361 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
4362 "Trace ID should match the original traceparent header"
4363 );
4364
4365 if let Some(reply_tx) = envelope.reply_tx {
4366 let _ = reply_tx.send(Ok(envelope.exchange));
4367 }
4368 }
4369 });
4370
4371 let resp = http_result.unwrap();
4372 assert_eq!(resp.status().as_u16(), 200);
4373
4374 token.cancel();
4375 }
4376
4377 #[tokio::test]
4378 async fn test_consumer_extracts_mixed_case_traceparent_header() {
4379 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4380
4381 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4383 let port = listener.local_addr().unwrap().port();
4384 drop(listener);
4385
4386 let component = HttpComponent::new();
4387 let endpoint_ctx = NoOpComponentContext;
4388 let endpoint = component
4389 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
4390 .unwrap();
4391 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4392
4393 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4394 let token = tokio_util::sync::CancellationToken::new();
4395 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4396
4397 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4398 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4399
4400 let client = reqwest::Client::new();
4402 let send_fut = client
4403 .post(format!("http://127.0.0.1:{port}/trace"))
4404 .header(
4405 "TraceParent",
4406 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
4407 )
4408 .body("test")
4409 .send();
4410
4411 let (http_result, _) = tokio::join!(send_fut, async {
4412 if let Some(envelope) = rx.recv().await {
4413 let mut injected_headers = HashMap::new();
4416 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
4417
4418 assert!(
4419 injected_headers.contains_key("traceparent"),
4420 "Exchange should have traceparent after extraction from mixed-case header"
4421 );
4422
4423 let traceparent = injected_headers.get("traceparent").unwrap();
4424 let parts: Vec<&str> = traceparent.split('-').collect();
4425 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
4426 assert_eq!(
4427 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
4428 "Trace ID should match the original mixed-case TraceParent header"
4429 );
4430
4431 if let Some(reply_tx) = envelope.reply_tx {
4432 let _ = reply_tx.send(Ok(envelope.exchange));
4433 }
4434 }
4435 });
4436
4437 let resp = http_result.unwrap();
4438 assert_eq!(resp.status().as_u16(), 200);
4439
4440 token.cancel();
4441 }
4442
4443 #[tokio::test]
4444 async fn test_producer_no_trace_context_no_crash() {
4445 let (url, _handle) = start_test_server().await;
4446 let ctx = test_producer_ctx();
4447
4448 let component = HttpComponent::new();
4449 let endpoint_ctx = NoOpComponentContext;
4450 let endpoint = component
4451 .create_endpoint(&format!("{url}/api?allowPrivateIps=true"), &endpoint_ctx)
4452 .unwrap();
4453 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4454
4455 let exchange = Exchange::new(Message::default());
4457
4458 let result = producer.oneshot(exchange).await.unwrap();
4460
4461 let status = result
4463 .input
4464 .header("CamelHttpResponseCode")
4465 .and_then(|v| v.as_u64())
4466 .unwrap();
4467 assert_eq!(status, 200);
4468 }
4469
4470 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
4472 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4473 let addr = listener.local_addr().unwrap();
4474 let url = format!("http://127.0.0.1:{}", addr.port());
4475
4476 let handle = tokio::spawn(async move {
4477 loop {
4478 if let Ok((mut stream, _)) = listener.accept().await {
4479 tokio::spawn(async move {
4480 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4481 let mut buf = vec![0u8; 8192];
4482 let n = stream.read(&mut buf).await.unwrap_or(0);
4483 let request = String::from_utf8_lossy(&buf[..n]).to_string();
4484
4485 let traceparent = request
4487 .lines()
4488 .find(|line| line.to_lowercase().starts_with("traceparent:"))
4489 .map(|line| {
4490 line.split(':')
4491 .nth(1)
4492 .map(|s| s.trim().to_string())
4493 .unwrap_or_default()
4494 })
4495 .unwrap_or_default();
4496
4497 let body =
4498 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
4499 let response = format!(
4500 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
4501 body.len(),
4502 traceparent,
4503 body
4504 );
4505 let _ = stream.write_all(response.as_bytes()).await;
4506 });
4507 }
4508 }
4509 });
4510
4511 (url, handle)
4512 }
4513 }
4514
4515 #[tokio::test]
4524 async fn test_request_body_arrives_as_stream() {
4525 use camel_component_api::Body;
4526 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4527
4528 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4529 let port = listener.local_addr().unwrap().port();
4530 drop(listener);
4531
4532 let component = HttpComponent::new();
4533 let endpoint_ctx = NoOpComponentContext;
4534 let endpoint = component
4535 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
4536 .unwrap();
4537 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4538
4539 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4540 let token = tokio_util::sync::CancellationToken::new();
4541 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4542
4543 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4544 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4545
4546 let client = reqwest::Client::new();
4547 let send_fut = client
4548 .post(format!("http://127.0.0.1:{port}/upload"))
4549 .body("hello streaming world")
4550 .send();
4551
4552 let (http_result, _) = tokio::join!(send_fut, async {
4553 if let Some(mut envelope) = rx.recv().await {
4554 assert!(
4556 matches!(envelope.exchange.input.body, Body::Stream(_)),
4557 "expected Body::Stream, got discriminant {:?}",
4558 std::mem::discriminant(&envelope.exchange.input.body)
4559 );
4560 let bytes = envelope
4562 .exchange
4563 .input
4564 .body
4565 .into_bytes(1024 * 1024)
4566 .await
4567 .unwrap();
4568 assert_eq!(&bytes[..], b"hello streaming world");
4569
4570 envelope.exchange.input.body = camel_component_api::Body::Empty;
4571 if let Some(reply_tx) = envelope.reply_tx {
4572 let _ = reply_tx.send(Ok(envelope.exchange));
4573 }
4574 }
4575 });
4576
4577 let resp = http_result.unwrap();
4578 assert_eq!(resp.status().as_u16(), 200);
4579
4580 token.cancel();
4581 }
4582
4583 #[tokio::test]
4588 async fn test_streaming_response_chunked() {
4589 use bytes::Bytes;
4590 use camel_component_api::Body;
4591 use camel_component_api::CamelError;
4592 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4593 use camel_component_api::{StreamBody, StreamMetadata};
4594 use futures::stream;
4595 use std::sync::Arc;
4596 use tokio::sync::Mutex;
4597
4598 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4599 let port = listener.local_addr().unwrap().port();
4600 drop(listener);
4601
4602 let component = HttpComponent::new();
4603 let endpoint_ctx = NoOpComponentContext;
4604 let endpoint = component
4605 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
4606 .unwrap();
4607 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4608
4609 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4610 let token = tokio_util::sync::CancellationToken::new();
4611 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4612
4613 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4614 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4615
4616 let client = reqwest::Client::new();
4617 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
4618
4619 let (http_result, _) = tokio::join!(send_fut, async {
4620 if let Some(mut envelope) = rx.recv().await {
4621 let chunks: Vec<Result<Bytes, CamelError>> =
4623 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
4624 let stream = Box::pin(stream::iter(chunks));
4625 envelope.exchange.input.body = Body::Stream(StreamBody {
4626 stream: Arc::new(Mutex::new(Some(stream))),
4627 metadata: StreamMetadata::default(),
4628 });
4629 if let Some(reply_tx) = envelope.reply_tx {
4630 let _ = reply_tx.send(Ok(envelope.exchange));
4631 }
4632 }
4633 });
4634
4635 let resp = http_result.unwrap();
4636 assert_eq!(resp.status().as_u16(), 200);
4637 let body = resp.text().await.unwrap();
4638 assert_eq!(body, "chunk1chunk2");
4639
4640 token.cancel();
4641 }
4642
4643 #[tokio::test]
4648 async fn test_413_when_content_length_exceeds_limit() {
4649 use camel_component_api::ConsumerContext;
4650
4651 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4652 let port = listener.local_addr().unwrap().port();
4653 drop(listener);
4654
4655 let component = HttpComponent::new();
4657 let endpoint_ctx = NoOpComponentContext;
4658 let endpoint = component
4659 .create_endpoint(
4660 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
4661 &endpoint_ctx,
4662 )
4663 .unwrap();
4664 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4665
4666 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4667 let token = tokio_util::sync::CancellationToken::new();
4668 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4669
4670 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4671 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4672
4673 let client = reqwest::Client::new();
4674 let resp = client
4675 .post(format!("http://127.0.0.1:{port}/upload"))
4676 .header("Content-Length", "1000") .body("x".repeat(1000))
4678 .send()
4679 .await
4680 .unwrap();
4681
4682 assert_eq!(resp.status().as_u16(), 413);
4683
4684 token.cancel();
4685 }
4686
4687 #[tokio::test]
4691 async fn test_chunked_upload_without_content_length_bypasses_limit() {
4692 use bytes::Bytes;
4693 use camel_component_api::Body;
4694 use camel_component_api::ConsumerContext;
4695 use futures::stream;
4696
4697 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4698 let port = listener.local_addr().unwrap().port();
4699 drop(listener);
4700
4701 let component = HttpComponent::new();
4703 let endpoint_ctx = NoOpComponentContext;
4704 let endpoint = component
4705 .create_endpoint(
4706 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
4707 &endpoint_ctx,
4708 )
4709 .unwrap();
4710 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4711
4712 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4713 let token = tokio_util::sync::CancellationToken::new();
4714 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4715
4716 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4717 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4718
4719 let client = reqwest::Client::new();
4720
4721 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
4725 Ok(Bytes::from("y".repeat(50))),
4726 Ok(Bytes::from("y".repeat(50))),
4727 ];
4728 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
4729 let send_fut = client
4730 .post(format!("http://127.0.0.1:{port}/upload"))
4731 .body(stream_body)
4732 .send();
4733
4734 let consumer_fut = async {
4735 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
4737 Ok(Some(mut envelope)) => {
4738 assert!(
4739 matches!(envelope.exchange.input.body, Body::Stream(_)),
4740 "expected Body::Stream"
4741 );
4742 envelope.exchange.input.body = camel_component_api::Body::Empty;
4743 if let Some(reply_tx) = envelope.reply_tx {
4744 let _ = reply_tx.send(Ok(envelope.exchange));
4745 }
4746 }
4747 Ok(None) => panic!("consumer channel closed unexpectedly"),
4748 Err(_) => {
4749 }
4752 }
4753 };
4754
4755 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
4756
4757 let resp = http_result.unwrap();
4758 assert_ne!(
4760 resp.status().as_u16(),
4761 413,
4762 "chunked upload must not be rejected by maxRequestBody"
4763 );
4764 assert_eq!(resp.status().as_u16(), 200);
4765
4766 token.cancel();
4767 }
4768
4769 #[test]
4770 fn test_is_private_ip_ranges() {
4771 use camel_api::is_ssrf_blocked_ip;
4772 assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); assert!(!is_ssrf_blocked_ip(
4791 &"2001:4860:4860::8888".parse().unwrap()
4792 )); }
4794
4795 #[test]
4796 fn test_title_case_header() {
4797 assert_eq!(title_case_header("content-type"), "Content-Type");
4798 assert_eq!(title_case_header("authorization"), "Authorization");
4799 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
4800 assert_eq!(title_case_header("host"), "Host");
4801 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
4802 assert_eq!(title_case_header("single"), "Single");
4803 assert_eq!(title_case_header(""), "");
4804 }
4805
4806 #[test]
4807 fn test_resolve_url_combines_path_and_query_sources() {
4808 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
4809 let mut exchange = Exchange::new(Message::default());
4810 exchange.input.set_header(
4811 "CamelHttpPath",
4812 serde_json::Value::String("next".to_string()),
4813 );
4814 let url = HttpProducer::resolve_url(&exchange, &cfg);
4815 assert!(url.starts_with("http://example.com/base/next?"));
4816 assert!(url.contains("foo=bar"));
4817
4818 exchange.input.set_header(
4819 "CamelHttpUri",
4820 serde_json::Value::String("http://other.test/root".to_string()),
4821 );
4822 exchange.input.set_header(
4823 "CamelHttpQuery",
4824 serde_json::Value::String("a=1&b=2".to_string()),
4825 );
4826
4827 let override_url = HttpProducer::resolve_url(&exchange, &cfg);
4828 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
4829 }
4830
4831 #[test]
4832 fn test_http_producer_helpers_status_and_size_boundaries() {
4833 assert!(HttpProducer::is_ok_status(200, (200, 299)));
4834 assert!(HttpProducer::is_ok_status(299, (200, 299)));
4835 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
4836 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
4837
4838 assert!(!exceeds_max_response_body(10, 10));
4839 assert!(exceeds_max_response_body(11, 10));
4840 }
4841
4842 async fn setup_consumer_on_free_port(
4847 path: &str,
4848 ) -> (
4849 u16,
4850 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
4851 tokio_util::sync::CancellationToken,
4852 ) {
4853 use camel_component_api::ConsumerContext;
4854
4855 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4856 let port = listener.local_addr().unwrap().port();
4857 drop(listener);
4858
4859 let consumer_cfg = HttpServerConfig {
4860 scheme: "http".to_string(),
4861 host: "127.0.0.1".to_string(),
4862 port,
4863 path: path.to_string(),
4864 max_request_body: 2 * 1024 * 1024,
4865 max_response_body: 10 * 1024 * 1024,
4866 max_inflight_requests: 1024,
4867 method: None,
4868 tls_config: None,
4869 };
4870 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4871
4872 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4873 let token = tokio_util::sync::CancellationToken::new();
4874 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4875
4876 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4877 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4878
4879 (port, rx, token)
4880 }
4881
4882 #[tokio::test]
4883 async fn test_content_type_inferred_for_json_body() {
4884 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
4885
4886 let client = reqwest::Client::new();
4887 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
4888
4889 let (http_result, _) = tokio::join!(send_fut, async {
4890 if let Some(mut envelope) = rx.recv().await {
4891 envelope.exchange.input.body =
4892 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
4893 if let Some(reply_tx) = envelope.reply_tx {
4894 let _ = reply_tx.send(Ok(envelope.exchange));
4895 }
4896 }
4897 });
4898
4899 let resp = http_result.unwrap();
4900 assert_eq!(resp.status().as_u16(), 200);
4901 let ct = resp
4902 .headers()
4903 .get("content-type")
4904 .expect("Content-Type header should be present");
4905 assert_eq!(ct, "application/json");
4906 let body = resp.text().await.unwrap();
4907 assert_eq!(body, r#"{"message":"hello"}"#);
4908
4909 token.cancel();
4910 }
4911
4912 #[tokio::test]
4913 async fn test_content_type_inferred_for_text_body() {
4914 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
4915
4916 let client = reqwest::Client::new();
4917 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
4918
4919 let (http_result, _) = tokio::join!(send_fut, async {
4920 if let Some(mut envelope) = rx.recv().await {
4921 envelope.exchange.input.body =
4922 camel_component_api::Body::Text("plain text response".to_string());
4923 if let Some(reply_tx) = envelope.reply_tx {
4924 let _ = reply_tx.send(Ok(envelope.exchange));
4925 }
4926 }
4927 });
4928
4929 let resp = http_result.unwrap();
4930 assert_eq!(resp.status().as_u16(), 200);
4931 let ct = resp
4932 .headers()
4933 .get("content-type")
4934 .expect("Content-Type header should be present");
4935 assert_eq!(ct, "text/plain; charset=utf-8");
4936 let body = resp.text().await.unwrap();
4937 assert_eq!(body, "plain text response");
4938
4939 token.cancel();
4940 }
4941
4942 #[tokio::test]
4943 async fn test_content_type_inferred_for_xml_body() {
4944 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
4945
4946 let client = reqwest::Client::new();
4947 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
4948
4949 let (http_result, _) = tokio::join!(send_fut, async {
4950 if let Some(mut envelope) = rx.recv().await {
4951 envelope.exchange.input.body =
4952 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
4953 if let Some(reply_tx) = envelope.reply_tx {
4954 let _ = reply_tx.send(Ok(envelope.exchange));
4955 }
4956 }
4957 });
4958
4959 let resp = http_result.unwrap();
4960 assert_eq!(resp.status().as_u16(), 200);
4961 let ct = resp
4962 .headers()
4963 .get("content-type")
4964 .expect("Content-Type header should be present");
4965 assert_eq!(ct, "application/xml");
4966 let body = resp.text().await.unwrap();
4967 assert_eq!(body, "<root><item>value</item></root>");
4968
4969 token.cancel();
4970 }
4971
4972 #[tokio::test]
4973 async fn test_no_content_type_for_empty_body() {
4974 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
4975
4976 let client = reqwest::Client::new();
4977 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
4978
4979 let (http_result, _) = tokio::join!(send_fut, async {
4980 if let Some(mut envelope) = rx.recv().await {
4981 envelope.exchange.input.body = camel_component_api::Body::Empty;
4982 if let Some(reply_tx) = envelope.reply_tx {
4983 let _ = reply_tx.send(Ok(envelope.exchange));
4984 }
4985 }
4986 });
4987
4988 let resp = http_result.unwrap();
4989 assert_eq!(resp.status().as_u16(), 200);
4990 assert!(
4991 resp.headers().get("content-type").is_none(),
4992 "Empty body should not set Content-Type"
4993 );
4994
4995 token.cancel();
4996 }
4997
4998 #[tokio::test]
4999 async fn test_no_content_type_for_raw_bytes_body() {
5000 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
5001
5002 let client = reqwest::Client::new();
5003 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
5004
5005 let (http_result, _) = tokio::join!(send_fut, async {
5006 if let Some(mut envelope) = rx.recv().await {
5007 envelope.exchange.input.body =
5008 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
5009 if let Some(reply_tx) = envelope.reply_tx {
5010 let _ = reply_tx.send(Ok(envelope.exchange));
5011 }
5012 }
5013 });
5014
5015 let resp = http_result.unwrap();
5016 assert_eq!(resp.status().as_u16(), 200);
5017 assert!(
5018 resp.headers().get("content-type").is_none(),
5019 "Raw Bytes body should not set Content-Type"
5020 );
5021
5022 token.cancel();
5023 }
5024
5025 #[tokio::test]
5026 async fn test_content_type_from_stream_metadata() {
5027 use camel_component_api::{StreamBody, StreamMetadata};
5028 use futures::stream;
5029
5030 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
5031
5032 let client = reqwest::Client::new();
5033 let send_fut = client
5034 .get(format!("http://127.0.0.1:{port}/stream-ct"))
5035 .send();
5036
5037 let (http_result, _) = tokio::join!(send_fut, async {
5038 if let Some(mut envelope) = rx.recv().await {
5039 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5040 vec![Ok(bytes::Bytes::from("audio data"))];
5041 let stream = Box::pin(stream::iter(chunks));
5042 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
5043 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5044 metadata: StreamMetadata {
5045 size_hint: None,
5046 content_type: Some("audio/mpeg".to_string()),
5047 origin: None,
5048 },
5049 });
5050 if let Some(reply_tx) = envelope.reply_tx {
5051 let _ = reply_tx.send(Ok(envelope.exchange));
5052 }
5053 }
5054 });
5055
5056 let resp = http_result.unwrap();
5057 assert_eq!(resp.status().as_u16(), 200);
5058 let ct = resp
5059 .headers()
5060 .get("content-type")
5061 .expect("Content-Type header should be present");
5062 assert_eq!(ct, "audio/mpeg");
5063 let body = resp.text().await.unwrap();
5064 assert_eq!(body, "audio data");
5065
5066 token.cancel();
5067 }
5068
5069 #[tokio::test]
5070 async fn test_user_content_type_overrides_inferred() {
5071 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
5072
5073 let client = reqwest::Client::new();
5074 let send_fut = client
5075 .get(format!("http://127.0.0.1:{port}/override-ct"))
5076 .send();
5077
5078 let (http_result, _) = tokio::join!(send_fut, async {
5079 if let Some(mut envelope) = rx.recv().await {
5080 envelope.exchange.input.body =
5081 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
5082 envelope.exchange.input.set_header(
5083 "Content-Type",
5084 serde_json::Value::String("text/html".to_string()),
5085 );
5086 if let Some(reply_tx) = envelope.reply_tx {
5087 let _ = reply_tx.send(Ok(envelope.exchange));
5088 }
5089 }
5090 });
5091
5092 let resp = http_result.unwrap();
5093 assert_eq!(resp.status().as_u16(), 200);
5094 let ct = resp
5095 .headers()
5096 .get("content-type")
5097 .expect("Content-Type header should be present");
5098 assert_eq!(
5099 ct, "text/html",
5100 "User-set Content-Type should take precedence over inferred type"
5101 );
5102
5103 token.cancel();
5104 }
5105
5106 #[tokio::test]
5107 async fn test_user_content_type_with_bytes_body() {
5108 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
5109
5110 let client = reqwest::Client::new();
5111 let send_fut = client
5112 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
5113 .send();
5114
5115 let (http_result, _) = tokio::join!(send_fut, async {
5116 if let Some(mut envelope) = rx.recv().await {
5117 envelope.exchange.input.body =
5118 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
5119 envelope.exchange.input.set_header(
5120 "Content-Type",
5121 serde_json::Value::String("application/json".to_string()),
5122 );
5123 if let Some(reply_tx) = envelope.reply_tx {
5124 let _ = reply_tx.send(Ok(envelope.exchange));
5125 }
5126 }
5127 });
5128
5129 let resp = http_result.unwrap();
5130 assert_eq!(resp.status().as_u16(), 200);
5131 let ct = resp
5132 .headers()
5133 .get("content-type")
5134 .expect("Content-Type header should be present for Bytes body with user header");
5135 assert_eq!(
5136 ct, "application/json",
5137 "User Content-Type should be sent for Bytes body"
5138 );
5139
5140 token.cancel();
5141 }
5142
5143 #[tokio::test]
5148 async fn monitor_task_silent_on_clean_exit() {
5149 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
5150 monitor_axum_task(
5152 handle,
5153 "127.0.0.1:0".to_string(),
5154 noop_rt(),
5155 "test-monitor".into(),
5156 )
5157 .await;
5158 }
5159
5160 #[tokio::test]
5161 async fn monitor_task_handles_panicked_task() {
5162 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
5163 panic!("simulated server crash");
5164 });
5165 monitor_axum_task(
5167 handle,
5168 "127.0.0.1:9999".to_string(),
5169 noop_rt(),
5170 "test-monitor".into(),
5171 )
5172 .await;
5173 }
5174
5175 #[test]
5180 fn http_auth_basic_debug_redacts_password() {
5181 let auth = HttpAuth::Basic {
5182 username: "admin".to_string(),
5183 password: "hunter2".to_string(),
5184 };
5185 let debug = format!("{:?}", auth);
5186 assert!(
5187 !debug.contains("hunter2"),
5188 "password must be redacted: {debug}"
5189 );
5190 assert!(debug.contains("admin"), "username should appear: {debug}");
5191 }
5192
5193 #[test]
5194 fn http_auth_bearer_debug_redacts_token() {
5195 let auth = HttpAuth::Bearer {
5196 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
5197 };
5198 let debug = format!("{:?}", auth);
5199 assert!(
5200 !debug.contains("eyJhbGci"),
5201 "token must be redacted: {debug}"
5202 );
5203 }
5204
5205 #[test]
5206 fn http_auth_none_debug_shows_variant() {
5207 let debug = format!("{:?}", HttpAuth::None);
5208 assert!(
5209 debug.contains("None"),
5210 "None variant should appear: {debug}"
5211 );
5212 }
5213
5214 #[test]
5215 fn http_endpoint_config_debug_redacts_auth_credentials() {
5216 let config = HttpEndpointConfig::from_uri(
5217 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
5218 )
5219 .unwrap();
5220 let debug = format!("{:?}", config);
5221 assert!(
5222 !debug.contains("secret123"),
5223 "password must be redacted in HttpEndpointConfig debug: {debug}"
5224 );
5225 }
5226
5227 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
5232 use tower_http::services::ServeDir;
5233
5234 fn make_test_registry() -> HttpRouteRegistry {
5235 HttpRouteRegistry::new()
5236 }
5237
5238 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
5239 AppState {
5240 registry,
5241 max_request_body: 2 * 1024 * 1024,
5242 max_response_body: 10 * 1024 * 1024,
5243 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
5244 }
5245 }
5246
5247 #[allow(clippy::await_holding_lock)]
5248 #[tokio::test]
5249 async fn test_static_file_serving_serves_file_contents() {
5250 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5251 ServerRegistry::reset();
5252
5253 let temp_dir =
5255 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
5256 std::fs::create_dir_all(&temp_dir).unwrap();
5257 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
5258 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
5259
5260 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
5261
5262 let registry = make_test_registry();
5263 let serve_dir = ServeDir::new(&canonical_dir)
5264 .precompressed_gzip()
5265 .precompressed_br()
5266 .append_index_html_on_directories(true);
5267
5268 let mount = StaticMount {
5269 mount_path: "/".to_string(),
5270 mode: MountMode::Static,
5271 dir: canonical_dir.clone(),
5272 cache_control: "public, max-age=3600".to_string(),
5273 error_pages: std::collections::HashMap::new(),
5274 serve_dir,
5275 };
5276 registry.register_static_mount(mount).await.unwrap();
5277
5278 let state = make_test_state(registry);
5279
5280 let req = Request::builder()
5282 .uri("/hello.txt")
5283 .body(AxumBody::empty())
5284 .unwrap();
5285 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
5286 assert_eq!(resp.status(), StatusCode::OK);
5287 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5288 .await
5289 .unwrap();
5290 assert_eq!(&body[..], b"Hello, static world!");
5291
5292 let req = Request::builder()
5294 .uri("/style.css")
5295 .body(AxumBody::empty())
5296 .unwrap();
5297 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
5298 assert_eq!(resp.status(), StatusCode::OK);
5299 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5300 .await
5301 .unwrap();
5302 assert_eq!(&body[..], b"body { color: red; }");
5303
5304 let req = Request::builder()
5306 .uri("/missing.txt")
5307 .body(AxumBody::empty())
5308 .unwrap();
5309 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
5310 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5311
5312 std::fs::remove_dir_all(&temp_dir).ok();
5314 }
5315
5316 #[allow(clippy::await_holding_lock)]
5317 #[tokio::test]
5318 async fn test_spa_fallback_serves_index_for_unknown_paths() {
5319 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5320 ServerRegistry::reset();
5321
5322 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
5323 std::fs::create_dir_all(&temp_dir).unwrap();
5324 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
5325 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
5326
5327 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
5328
5329 let registry = make_test_registry();
5330 let serve_dir = ServeDir::new(&canonical_dir)
5331 .precompressed_gzip()
5332 .precompressed_br()
5333 .append_index_html_on_directories(true);
5334
5335 let mount = StaticMount {
5336 mount_path: "/".to_string(),
5337 mode: MountMode::Spa,
5338 dir: canonical_dir.clone(),
5339 cache_control: "public, max-age=0".to_string(),
5340 error_pages: std::collections::HashMap::new(),
5341 serve_dir,
5342 };
5343 registry.register_static_mount(mount).await.unwrap();
5345
5346 let state = make_test_state(registry);
5347
5348 let req = Request::builder()
5350 .method("GET")
5351 .uri("/dashboard")
5352 .header("Accept", "text/html")
5353 .body(AxumBody::empty())
5354 .unwrap();
5355 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
5356 assert_eq!(resp.status(), StatusCode::OK);
5357 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5358 .await
5359 .unwrap();
5360 assert_eq!(&body[..], b"<h1>SPA App</h1>");
5361
5362 let req = Request::builder()
5364 .method("GET")
5365 .uri("/app.js")
5366 .body(AxumBody::empty())
5367 .unwrap();
5368 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
5369 assert_eq!(resp.status(), StatusCode::OK);
5370 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5371 .await
5372 .unwrap();
5373 assert_eq!(&body[..], b"console.log('app')");
5374
5375 let req = Request::builder()
5377 .method("GET")
5378 .uri("/api/data")
5379 .header("Accept", "application/json")
5380 .body(AxumBody::empty())
5381 .unwrap();
5382 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
5383 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5384
5385 let req = Request::builder()
5387 .method("GET")
5388 .uri("/style.css")
5389 .header("Accept", "text/html")
5390 .body(AxumBody::empty())
5391 .unwrap();
5392 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
5393 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5394
5395 std::fs::remove_dir_all(&temp_dir).ok();
5397 }
5398
5399 #[allow(clippy::await_holding_lock)]
5400 #[tokio::test]
5401 async fn test_error_page_mapping_serves_custom_404() {
5402 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5403 ServerRegistry::reset();
5404
5405 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
5406 let errors_dir = temp_dir.join("errors");
5407 std::fs::create_dir_all(&errors_dir).unwrap();
5408 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
5409 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
5410
5411 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
5412 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
5413
5414 let registry = make_test_registry();
5415 let serve_dir = ServeDir::new(&canonical_dir)
5416 .precompressed_gzip()
5417 .precompressed_br()
5418 .append_index_html_on_directories(true);
5419
5420 let mut error_pages = std::collections::HashMap::new();
5421 error_pages.insert(404, canonical_404);
5422
5423 let mount = StaticMount {
5424 mount_path: "/".to_string(),
5425 mode: MountMode::Static,
5426 dir: canonical_dir.clone(),
5427 cache_control: "public, max-age=0".to_string(),
5428 error_pages,
5429 serve_dir,
5430 };
5431 registry.register_static_mount(mount).await.unwrap();
5432
5433 let state = make_test_state(registry);
5434
5435 let req = Request::builder()
5437 .method("GET")
5438 .uri("/missing.html")
5439 .body(AxumBody::empty())
5440 .unwrap();
5441 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
5442 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5443 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5444 .await
5445 .unwrap();
5446 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
5447
5448 let req = Request::builder()
5450 .method("GET")
5451 .uri("/index.html")
5452 .body(AxumBody::empty())
5453 .unwrap();
5454 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
5455 assert_eq!(resp.status(), StatusCode::OK);
5456 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5457 .await
5458 .unwrap();
5459 assert_eq!(&body[..], b"<h1>Home</h1>");
5460
5461 std::fs::remove_dir_all(&temp_dir).ok();
5463 }
5464
5465 #[tokio::test]
5466 async fn http_consumer_returns_body_and_code_on_stop() {
5467 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
5468 use camel_core::route::{CompiledStep, compose_pipeline_with_handler};
5469 use tower::ServiceExt;
5470
5471 let set_body_step = CompiledStep::Process {
5473 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
5474 ex.input.body = Body::Text("nope".into());
5475 Box::pin(async move { Ok(ex) })
5476 }),
5477 body_contract: None,
5478 lifecycle: None,
5479 };
5480 let set_status_step = CompiledStep::Process {
5481 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
5482 ex.input.set_header(
5483 "CamelHttpResponseCode",
5484 serde_json::Value::Number(409.into()),
5485 );
5486 Box::pin(async move { Ok(ex) })
5487 }),
5488 body_contract: None,
5489 lifecycle: None,
5490 };
5491 let pipeline = compose_pipeline_with_handler(
5492 vec![set_body_step, set_status_step, CompiledStep::Stop],
5493 None,
5494 );
5495
5496 let ex = Exchange::new(Message::default());
5497 let result = pipeline.oneshot(ex).await;
5498 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
5499 let returned = result.unwrap();
5500 assert_eq!(returned.input.body.as_text(), Some("nope"));
5501 assert_eq!(
5502 returned
5503 .input
5504 .header("CamelHttpResponseCode")
5505 .and_then(|v| v.as_u64()),
5506 Some(409)
5507 );
5508 }
5509
5510 #[tokio::test]
5511 async fn http_consumer_returns_200_when_body_empty_on_stop() {
5512 use camel_api::{Exchange, Message};
5520 use camel_core::route::{CompiledStep, compose_pipeline_with_handler};
5521 use tower::ServiceExt;
5522
5523 let pipeline = compose_pipeline_with_handler(vec![CompiledStep::Stop], None);
5524 let ex = Exchange::new(Message::default());
5525 let result = pipeline.oneshot(ex).await;
5526 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
5527 }
5530
5531 async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
5539 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5540 let port = listener.local_addr().unwrap().port();
5541 let registry = HttpRouteRegistry::new();
5542 tokio::spawn(run_axum_server(
5543 listener,
5544 registry.clone(),
5545 2 * 1024 * 1024,
5546 10 * 1024 * 1024,
5547 Arc::new(tokio::sync::Semaphore::new(1024)),
5548 test_rt(),
5549 "test-route".into(),
5550 ));
5551 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5553 (port, registry)
5554 }
5555
5556 fn spawn_responder(
5561 mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
5562 status: u16,
5563 body: String,
5564 ) -> tokio::task::JoinHandle<()> {
5565 tokio::spawn(async move {
5566 if let Some(envelope) = rx.recv().await {
5567 let _ = envelope.reply_tx.send(HttpReply {
5568 status,
5569 headers: vec![],
5570 body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
5571 });
5572 }
5573 })
5574 }
5575
5576 #[tokio::test]
5577 async fn method_aware_dispatch_same_path_different_verbs() {
5578 let (port, registry) = spawn_test_server().await;
5579
5580 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5585 registry
5586 .register_rest_endpoint(
5587 "GET".into(),
5588 vec![PathSegment::Literal("users".into())],
5589 get_tx,
5590 )
5591 .await;
5592
5593 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5594 registry
5595 .register_rest_endpoint(
5596 "POST".into(),
5597 vec![PathSegment::Literal("users".into())],
5598 post_tx,
5599 )
5600 .await;
5601
5602 let get_handle = spawn_responder(get_rx, 200, "list".into());
5603 let post_handle = spawn_responder(post_rx, 201, "create".into());
5604
5605 let client = reqwest::Client::new();
5606
5607 let resp = client
5609 .get(format!("http://127.0.0.1:{port}/users"))
5610 .send()
5611 .await
5612 .unwrap();
5613 assert_eq!(resp.status().as_u16(), 200);
5614 let body = resp.text().await.unwrap();
5615 assert_eq!(body, "list");
5616
5617 let resp = client
5619 .post(format!("http://127.0.0.1:{port}/users"))
5620 .send()
5621 .await
5622 .unwrap();
5623 assert_eq!(resp.status().as_u16(), 201);
5624 let body = resp.text().await.unwrap();
5625 assert_eq!(body, "create");
5626
5627 let _ = tokio::join!(get_handle, post_handle);
5628 }
5629
5630 #[tokio::test]
5631 async fn method_aware_dispatch_templated_path_extracts_params() {
5632 let (port, registry) = spawn_test_server().await;
5633
5634 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5638 registry
5639 .register_rest_endpoint(
5640 "GET".into(),
5641 vec![
5642 PathSegment::Literal("users".into()),
5643 PathSegment::Param("id".into()),
5644 ],
5645 tx,
5646 )
5647 .await;
5648
5649 let handle = tokio::spawn(async move {
5652 if let Some(envelope) = rx.recv().await {
5653 let id = envelope.path_params.get("id").cloned().unwrap_or_default();
5654 let _ = envelope.reply_tx.send(HttpReply {
5655 status: 200,
5656 headers: vec![],
5657 body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
5658 });
5659 }
5660 });
5661
5662 let client = reqwest::Client::new();
5663 let resp = client
5664 .get(format!("http://127.0.0.1:{port}/users/42"))
5665 .send()
5666 .await
5667 .unwrap();
5668 assert_eq!(resp.status().as_u16(), 200);
5669 let body = resp.text().await.unwrap();
5670 assert_eq!(body, "id=42");
5671
5672 let _ = handle.await;
5673 }
5674
5675 #[tokio::test]
5676 async fn method_aware_dispatch_unmatched_method_falls_through() {
5677 let (port, _registry) = spawn_test_server().await;
5682
5683 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5685 _registry
5686 .register_rest_endpoint(
5687 "GET".into(),
5688 vec![PathSegment::Literal("users".into())],
5689 get_tx,
5690 )
5691 .await;
5692
5693 let drain = tokio::spawn(async move {
5696 let mut get_rx = get_rx;
5697 while get_rx.recv().await.is_some() {}
5698 });
5699
5700 let client = reqwest::Client::new();
5701 let resp = client
5702 .delete(format!("http://127.0.0.1:{port}/users"))
5703 .send()
5704 .await
5705 .unwrap();
5706 assert_eq!(resp.status().as_u16(), 404);
5707
5708 drop(drain);
5709 }
5710
5711 #[tokio::test]
5712 async fn regression_legacy_exact_api_route_still_works() {
5713 let (port, registry) = spawn_test_server().await;
5718
5719 let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5720 registry.register_api_route("/legacy/path".into(), tx).await;
5721
5722 let handle = tokio::spawn(async move {
5723 if let Some(envelope) = rx.recv().await {
5724 let _ = envelope.reply_tx.send(HttpReply {
5725 status: 200,
5726 headers: vec![],
5727 body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
5728 });
5729 }
5730 });
5731
5732 let client = reqwest::Client::new();
5733 let resp = client
5734 .get(format!("http://127.0.0.1:{port}/legacy/path"))
5735 .send()
5736 .await
5737 .unwrap();
5738 assert_eq!(resp.status().as_u16(), 200);
5739 let body = resp.text().await.unwrap();
5740 assert_eq!(body, "legacy ok");
5741
5742 let _ = handle.await;
5743 }
5744
5745 #[allow(clippy::await_holding_lock)]
5746 #[tokio::test]
5747 async fn regression_static_mount_still_works() {
5748 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5752 ServerRegistry::reset();
5753
5754 let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
5755 std::fs::create_dir_all(&temp_dir).unwrap();
5756 std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
5757 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
5758
5759 let registry = make_test_registry();
5760 let serve_dir = ServeDir::new(&canonical_dir)
5761 .precompressed_gzip()
5762 .precompressed_br()
5763 .append_index_html_on_directories(true);
5764 let mount = StaticMount {
5765 mount_path: "/".to_string(),
5766 mode: MountMode::Static,
5767 dir: canonical_dir.clone(),
5768 cache_control: "public, max-age=3600".to_string(),
5769 error_pages: std::collections::HashMap::new(),
5770 serve_dir,
5771 };
5772 registry.register_static_mount(mount).await.unwrap();
5773
5774 let state = make_test_state(registry);
5775 let req = Request::builder()
5776 .uri("/regress.txt")
5777 .body(AxumBody::empty())
5778 .unwrap();
5779 let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
5780 assert_eq!(resp.status(), StatusCode::OK);
5781 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5782 .await
5783 .unwrap();
5784 assert_eq!(&body[..], b"static works");
5785
5786 std::fs::remove_dir_all(&temp_dir).ok();
5787 }
5788
5789 #[tokio::test]
5798 async fn deregister_one_method_keeps_sibling_verbs() {
5799 let (port, registry) = spawn_test_server().await;
5803
5804 let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5805 registry
5806 .register_rest_endpoint(
5807 "GET".into(),
5808 vec![PathSegment::Literal("users".into())],
5809 get_tx,
5810 )
5811 .await;
5812
5813 let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5814 registry
5815 .register_rest_endpoint(
5816 "POST".into(),
5817 vec![PathSegment::Literal("users".into())],
5818 post_tx,
5819 )
5820 .await;
5821
5822 let drain = tokio::spawn(async move {
5824 let mut get_rx = get_rx;
5825 while get_rx.recv().await.is_some() {}
5826 });
5827
5828 registry.unregister_rest_endpoint("GET", "/users").await;
5830 drop(drain);
5831
5832 let post_handle = spawn_responder(post_rx, 201, "create".into());
5833
5834 let client = reqwest::Client::new();
5835 let resp = client
5837 .post(format!("http://127.0.0.1:{port}/users"))
5838 .send()
5839 .await
5840 .unwrap();
5841 assert_eq!(resp.status().as_u16(), 201);
5842 assert_eq!(resp.text().await.unwrap(), "create");
5843
5844 let _ = post_handle.await;
5845 }
5846
5847 #[tokio::test]
5848 async fn dispatch_exact_legacy_beats_rest_template() {
5849 let (port, registry) = spawn_test_server().await;
5854
5855 let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5857 registry
5858 .register_api_route("/api/users".into(), exact_tx)
5859 .await;
5860 let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
5861
5862 let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5864 registry
5865 .register_rest_endpoint(
5866 "GET".into(),
5867 vec![
5868 PathSegment::Literal("api".into()),
5869 PathSegment::Param("resource".into()),
5870 ],
5871 tpl_tx,
5872 )
5873 .await;
5874 let _tpl_drain = tokio::spawn(async move {
5880 let mut tpl_rx = tpl_rx;
5881 if let Some(env) = tpl_rx.recv().await {
5882 let _ = env.reply_tx.send(HttpReply {
5883 status: 200,
5884 headers: vec![],
5885 body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
5886 });
5887 }
5888 });
5889
5890 let client = reqwest::Client::new();
5891 let resp = client
5892 .get(format!("http://127.0.0.1:{port}/api/users"))
5893 .send()
5894 .await
5895 .unwrap();
5896 assert_eq!(resp.status().as_u16(), 200);
5897 assert_eq!(resp.text().await.unwrap(), "exact");
5899
5900 let _ = exact_handle.await;
5901 }
5902
5903 #[tokio::test]
5904 async fn ambiguous_rest_templates_return_500_not_silent_404() {
5905 let (port, registry) = spawn_test_server().await;
5910
5911 let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5912 registry
5913 .register_rest_endpoint(
5914 "GET".into(),
5915 vec![
5916 PathSegment::Literal("users".into()),
5917 PathSegment::Param("id".into()),
5918 ],
5919 a_tx,
5920 )
5921 .await;
5922
5923 let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5924 registry
5925 .register_rest_endpoint(
5926 "GET".into(),
5927 vec![
5928 PathSegment::Literal("users".into()),
5929 PathSegment::Param("name".into()),
5930 ],
5931 b_tx,
5932 )
5933 .await;
5934
5935 let client = reqwest::Client::new();
5936 let resp = client
5937 .get(format!("http://127.0.0.1:{port}/users/42"))
5938 .send()
5939 .await
5940 .unwrap();
5941 assert_eq!(resp.status().as_u16(), 500);
5943 }
5944
5945 #[test]
5946 fn from_uri_round_trips_templated_path_with_http_method() {
5947 use crate::UriConfig;
5953 let cfg =
5954 HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
5955 assert_eq!(cfg.host, "0.0.0.0");
5956 assert_eq!(cfg.port, 8080);
5957 assert_eq!(cfg.path, "/users/{id}");
5958 assert_eq!(cfg.method.as_deref(), Some("GET"));
5959
5960 let cfg_lc =
5962 HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
5963 assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
5964 assert_eq!(cfg_lc.path, "/orders");
5965 }
5966
5967 #[test]
5972 fn type_conversion_failed_maps_to_400() {
5973 let reply = pipeline_error_to_reply(
5974 CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
5975 "/api/users",
5976 );
5977 assert_eq!(reply.status, 400);
5978 let ct = reply
5980 .headers
5981 .iter()
5982 .find(|(k, _)| k == "Content-Type")
5983 .map(|(_, v)| v.as_str());
5984 assert_eq!(ct, Some("application/json"));
5985 let body = match &reply.body {
5987 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
5988 _ => panic!("expected bytes body"),
5989 };
5990 assert!(body.contains("\"error\""));
5991 assert!(body.contains("bad_request"));
5992 assert!(body.contains("invalid JSON at line 1"));
5993 }
5994
5995 #[test]
5996 fn other_error_still_maps_to_500() {
5997 let reply =
5998 pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
5999 assert_eq!(reply.status, 500);
6000 }
6001
6002 #[test]
6003 fn unauthenticated_maps_to_401() {
6004 let reply = pipeline_error_to_reply(
6005 CamelError::Unauthenticated("no token".to_string()),
6006 "/api/users",
6007 );
6008 assert_eq!(reply.status, 401);
6009 }
6010
6011 #[test]
6012 fn unauthorized_maps_to_403() {
6013 let reply = pipeline_error_to_reply(
6014 CamelError::Unauthorized("forbidden".to_string()),
6015 "/api/users",
6016 );
6017 assert_eq!(reply.status, 403);
6018 }
6019
6020 #[test]
6021 fn validation_error_maps_to_400() {
6022 let reply = pipeline_error_to_reply(
6023 CamelError::ValidationError("body does not match schema".to_string()),
6024 "/api/users",
6025 );
6026 assert_eq!(reply.status, 400);
6027 let ct = reply
6028 .headers
6029 .iter()
6030 .find(|(k, _)| k == "Content-Type")
6031 .map(|(_, v)| v.as_str());
6032 assert_eq!(ct, Some("application/json"));
6033 let body = match &reply.body {
6034 HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
6035 _ => panic!("expected bytes body"),
6036 };
6037 assert!(body.contains("\"error\""));
6038 assert!(body.contains("validation_error"));
6039 assert!(body.contains("body does not match schema"));
6040 }
6041
6042 #[test]
6043 fn https_consumer_without_tls_cert_errors() {
6044 let endpoint = HttpEndpoint {
6045 uri: "https://0.0.0.0:8443/api".to_string(),
6046 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
6047 server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
6048 client: reqwest::Client::new(),
6049 http_config: HttpConfig::default(),
6050 };
6051 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6052 let result = endpoint.create_consumer(rt);
6053 assert!(result.is_err(), "expected error for https without tls cert");
6054 if let Err(e) = result {
6055 let msg = e.to_string();
6056 assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
6057 }
6058 }
6059
6060 #[test]
6061 fn http_consumer_with_tls_config_errors() {
6062 let endpoint = HttpEndpoint {
6063 uri: "http://0.0.0.0:8080/api".to_string(),
6064 config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
6065 server_config: HttpServerConfig::from_uri(
6066 "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
6067 )
6068 .unwrap(),
6069 client: reqwest::Client::new(),
6070 http_config: HttpConfig::default(),
6071 };
6072 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6073 let result = endpoint.create_consumer(rt);
6074 assert!(result.is_err(), "expected error for http with tls config");
6075 if let Err(e) = result {
6076 let msg = e.to_string();
6077 assert!(msg.contains("https"), "error must mention https: {msg}");
6078 }
6079 }
6080
6081 #[test]
6082 fn https_consumer_with_partial_tls_cert_only_errors() {
6083 let server_config =
6086 HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
6087 assert!(
6088 server_config.tls_config.is_none(),
6089 "partial tlsCert must not create ServerTlsConfig"
6090 );
6091 let endpoint = HttpEndpoint {
6092 uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
6093 config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
6094 .unwrap(),
6095 server_config,
6096 client: reqwest::Client::new(),
6097 http_config: HttpConfig::default(),
6098 };
6099 let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6100 let result = endpoint.create_consumer(rt);
6101 assert!(
6102 result.is_err(),
6103 "must error: https:// requires both tlsCert and tlsKey"
6104 );
6105 }
6106
6107 #[test]
6108 fn load_tls_config_parses_valid_pem() {
6109 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6111 use camel_component_api::test_support::tls;
6112 let (_, cert_pem, key_pem) = tls::gen_server_cert();
6113 let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
6114 let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
6115
6116 let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
6117 assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
6118 }
6119
6120 #[tokio::test(flavor = "multi_thread")]
6121 #[allow(clippy::await_holding_lock)]
6122 async fn consumer_tls_handshake_roundtrip() {
6123 use camel_component_api::test_support::tls;
6124 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6125
6126 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6128
6129 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6131
6132 let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
6134 let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
6135 let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
6136 let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
6137
6138 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6140 let port = probe.local_addr().unwrap().port();
6141 drop(probe);
6142
6143 ServerRegistry::reset();
6144
6145 let component = HttpComponent::new();
6147 let endpoint_ctx = NoOpComponentContext;
6148 let uri = format!(
6149 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
6150 cert_path.to_string_lossy(),
6151 key_path.to_string_lossy(),
6152 );
6153 let endpoint = component
6154 .create_endpoint(&uri, &endpoint_ctx)
6155 .expect("create TLS endpoint");
6156 let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
6157
6158 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6160 let token = tokio_util::sync::CancellationToken::new();
6161 let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
6162 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6163
6164 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
6166
6167 let ca_bytes = std::fs::read(&ca_path).unwrap();
6169 let client = reqwest::Client::builder()
6170 .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
6171 .build()
6172 .unwrap();
6173
6174 let send_fut = client
6175 .post(format!("https://localhost:{port}/test"))
6176 .body("ping")
6177 .send();
6178
6179 let (http_result, _) = tokio::join!(send_fut, async {
6181 if let Some(mut envelope) = rx.recv().await {
6182 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
6183 if let Some(reply_tx) = envelope.reply_tx {
6184 let _ = reply_tx.send(Ok(envelope.exchange));
6185 }
6186 }
6187 });
6188
6189 let resp = http_result.expect("TLS handshake + request must succeed");
6190
6191 assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
6192 let body = resp.text().await.unwrap();
6193 assert_eq!(body, "pong");
6194
6195 token.cancel();
6196 }
6197
6198 #[tokio::test(flavor = "multi_thread")]
6199 #[allow(clippy::await_holding_lock)]
6200 async fn consumer_tls_rejects_client_without_ca() {
6201 use camel_component_api::test_support::tls;
6202 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6203
6204 let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6205
6206 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6208
6209 let (_, cert_pem, key_pem) = tls::gen_server_cert();
6210 let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
6211 let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
6212
6213 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6214 let port = probe.local_addr().unwrap().port();
6215 drop(probe);
6216
6217 ServerRegistry::reset();
6218
6219 let component = HttpComponent::new();
6221 let endpoint_ctx = NoOpComponentContext;
6222 let uri = format!(
6223 "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
6224 cert_path.to_string_lossy(),
6225 key_path.to_string_lossy(),
6226 );
6227 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
6228 let mut consumer = endpoint.create_consumer(rt()).unwrap();
6229 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6230 let token = tokio_util::sync::CancellationToken::new();
6231 let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
6232 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6233
6234 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
6235
6236 let client = reqwest::Client::builder().build().unwrap();
6238
6239 let result = client
6240 .get(format!("https://localhost:{port}/test"))
6241 .send()
6242 .await;
6243
6244 assert!(
6245 result.is_err(),
6246 "must reject without CA — proves real verification"
6247 );
6248
6249 token.cancel();
6250 }
6251
6252 #[test]
6253 fn server_config_partial_tls_cert_without_key() {
6254 let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
6256 assert!(cfg.tls_config.is_none());
6258 }
6259}