1pub mod auth;
2pub mod bundle;
3pub mod config;
4pub mod health;
5pub mod registry;
6pub mod static_config;
7pub mod static_dispatch;
8pub mod static_endpoint;
9use crate::config::parse_ok_status_code_range;
10pub use bundle::HttpBundle;
11pub use bundle::HttpStaticBundle;
12pub use config::HttpConfig;
13pub use health::HttpHealthCheck;
14pub use registry::HttpRouteRegistry;
15pub use static_config::HttpStaticConfig;
16pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
17
18use std::collections::HashMap;
19use std::future::Future;
20use std::net::IpAddr;
21use std::pin::Pin;
22use std::sync::{Arc, Mutex, OnceLock};
23use std::task::{Context, Poll};
24use std::time::Duration;
25
26use tokio::sync::OnceCell;
27use tower::Layer;
28use tower::Service;
29use tracing::debug;
30
31use axum::body::BodyDataStream;
32use camel_auth::bearer_token_layer::BearerTokenLayer;
33use camel_auth::oauth2::TokenProvider;
34use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
35use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
36use camel_component_api::{UriComponents, UriConfig, parse_uri};
37use futures::TryStreamExt;
38use futures::stream::BoxStream;
39
40#[derive(Debug, Clone)]
101pub struct HttpEndpointConfig {
102 pub base_url: String,
103 pub http_method: Option<String>,
104 pub throw_exception_on_failure: bool,
105 pub ok_status_code_range: (u16, u16),
106 pub response_timeout: Option<Duration>,
107 pub query_params: HashMap<String, String>,
108 pub allow_private_ips: bool,
109 pub blocked_hosts: Vec<String>,
110 pub max_body_size: usize,
111 pub read_timeout_ms: u64,
112 pub max_response_bytes: usize,
113 pub auth: HttpAuth,
114 pub token_provider: Option<Arc<dyn TokenProvider>>,
115 pub user_agent: Option<String>,
116 pub cookie_handling: CookieHandling,
117 pub bridge_endpoint: bool,
118 pub connection_close: bool,
119 pub skip_request_headers: Vec<String>,
120 pub skip_response_headers: Vec<String>,
121}
122
123#[derive(Clone, PartialEq)]
124pub enum HttpAuth {
125 None,
126 Basic { username: String, password: String },
127 Bearer { token: String },
128}
129
130impl std::fmt::Debug for HttpAuth {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 match self {
133 HttpAuth::None => f.write_str("None"),
134 HttpAuth::Basic { username, .. } => f
135 .debug_struct("Basic")
136 .field("username", username)
137 .field("password", &"***")
138 .finish(),
139 HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
140 }
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum CookieHandling {
146 Disabled,
147 InMemory,
148}
149
150const HTTP_CAMEL_OPTIONS: &[&str] = &[
152 "httpMethod",
153 "throwExceptionOnFailure",
154 "okStatusCodeRange",
155 "followRedirects",
156 "connectTimeout",
157 "responseTimeout",
158 "allowPrivateIps",
159 "blockedHosts",
160 "maxBodySize",
161 "readTimeout",
162 "maxResponseBytes",
163 "authMethod",
164 "authUsername",
165 "authPassword",
166 "authBearerToken",
167 "userAgent",
168 "cookieHandling",
169 "bridgeEndpoint",
170 "connectionClose",
171 "skipRequestHeaders",
172 "skipResponseHeaders",
173];
174
175impl UriConfig for HttpEndpointConfig {
176 fn scheme() -> &'static str {
178 "http"
179 }
180
181 fn from_uri(uri: &str) -> Result<Self, CamelError> {
182 let parts = parse_uri(uri)?;
183 Self::from_components(parts)
184 }
185
186 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
187 if parts.scheme != "http" && parts.scheme != "https" {
189 return Err(CamelError::InvalidUri(format!(
190 "expected scheme 'http' or 'https', got '{}'",
191 parts.scheme
192 )));
193 }
194
195 let base_url = format!("{}:{}", parts.scheme, parts.path);
198
199 let http_method = parts.params.get("httpMethod").cloned();
200
201 let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
202 Some(v) => parse_bool_param_http(v).map_err(|e| {
203 CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
204 })?,
205 None => true,
206 };
207
208 let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
210 Some(v) => parse_ok_status_code_range(v)?,
211 None => (200, 299),
212 };
213
214 let response_timeout = match parts.params.get("responseTimeout") {
215 Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
216 CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
217 })?),
218 None => None,
219 };
220
221 let allow_private_ips = match parts.params.get("allowPrivateIps") {
223 Some(v) => parse_bool_param_http(v).map_err(|e| {
224 CamelError::InvalidUri(format!("invalid value for allowPrivateIps: {e}"))
225 })?,
226 None => false, };
228
229 let blocked_hosts = parts
231 .params
232 .get("blockedHosts")
233 .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
234 .unwrap_or_default();
235
236 let max_body_size = match parts.params.get("maxBodySize") {
237 Some(v) => v.parse::<usize>().map_err(|e| {
238 CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
239 })?,
240 None => 10 * 1024 * 1024, };
242
243 let read_timeout_ms = match parts.params.get("readTimeout") {
244 Some(v) => v.parse::<u64>().map_err(|e| {
245 CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
246 })?,
247 None => 30_000, };
249
250 let max_response_bytes = match parts.params.get("maxResponseBytes") {
251 Some(v) => v.parse::<usize>().map_err(|e| {
252 CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
253 })?,
254 None => 10 * 1024 * 1024, };
256
257 let auth = parse_auth_from_params(&parts.params)?;
258
259 let user_agent = parts.params.get("userAgent").cloned();
260
261 let cookie_handling = match parts.params.get("cookieHandling") {
262 Some(v) if v.eq_ignore_ascii_case("inmemory") => CookieHandling::InMemory,
263 Some(v) if v.eq_ignore_ascii_case("disabled") => CookieHandling::Disabled,
264 Some(v) => {
265 return Err(CamelError::InvalidUri(format!(
266 "invalid value for cookieHandling: {v} (expected Disabled or InMemory)"
267 )));
268 }
269 None => CookieHandling::Disabled,
270 };
271
272 let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
273 Some(v) => parse_bool_param_http(v).map_err(|e| {
274 CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
275 })?,
276 None => false,
277 };
278
279 let connection_close = match parts.params.get("connectionClose") {
280 Some(v) => parse_bool_param_http(v).map_err(|e| {
281 CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
282 })?,
283 None => false,
284 };
285
286 let skip_request_headers = parts
287 .params
288 .get("skipRequestHeaders")
289 .map(|v| {
290 v.split(',')
291 .map(str::trim)
292 .filter(|s| !s.is_empty())
293 .map(|s| s.to_ascii_lowercase())
294 .collect::<Vec<_>>()
295 })
296 .unwrap_or_default();
297
298 let skip_response_headers = parts
299 .params
300 .get("skipResponseHeaders")
301 .map(|v| {
302 v.split(',')
303 .map(str::trim)
304 .filter(|s| !s.is_empty())
305 .map(|s| s.to_ascii_lowercase())
306 .collect::<Vec<_>>()
307 })
308 .unwrap_or_default();
309
310 let query_params: HashMap<String, String> = parts
312 .params
313 .into_iter()
314 .filter(|(k, _)| !HTTP_CAMEL_OPTIONS.contains(&k.as_str()))
315 .collect();
316
317 Ok(Self {
318 base_url,
319 http_method,
320 throw_exception_on_failure,
321 ok_status_code_range,
322 response_timeout,
323 query_params,
324 allow_private_ips,
325 blocked_hosts,
326 max_body_size,
327 read_timeout_ms,
328 max_response_bytes,
329 auth,
330 token_provider: None,
331 user_agent,
332 cookie_handling,
333 bridge_endpoint,
334 connection_close,
335 skip_request_headers,
336 skip_response_headers,
337 })
338 }
339}
340
341fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
342 let Some(method) = params.get("authMethod") else {
343 return Ok(HttpAuth::None);
344 };
345
346 if method.eq_ignore_ascii_case("none") {
347 return Ok(HttpAuth::None);
348 }
349
350 if method.eq_ignore_ascii_case("basic") {
351 let username = params.get("authUsername").cloned().ok_or_else(|| {
352 CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
353 })?;
354 let password = params.get("authPassword").cloned().ok_or_else(|| {
355 CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
356 })?;
357 return Ok(HttpAuth::Basic { username, password });
358 }
359
360 if method.eq_ignore_ascii_case("bearer") {
361 let token = params.get("authBearerToken").cloned().ok_or_else(|| {
362 CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
363 })?;
364 return Ok(HttpAuth::Bearer { token });
365 }
366
367 Err(CamelError::InvalidUri(format!(
368 "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
369 )))
370}
371
372fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
373 match value.to_ascii_lowercase().as_str() {
374 "true" | "1" | "yes" => Ok(true),
375 "false" | "0" | "no" => Ok(false),
376 _ => Err(CamelError::InvalidUri(format!(
377 "invalid boolean value: '{value}'"
378 ))),
379 }
380}
381
382impl HttpEndpointConfig {
383 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
384 let parts = parse_uri(uri)?;
385 let mut endpoint = Self::from_components(parts.clone())?;
386 if endpoint.response_timeout.is_none() {
387 endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
388 }
389 if !parts.params.contains_key("allowPrivateIps") {
390 endpoint.allow_private_ips = config.allow_private_ips;
391 }
392 if !parts.params.contains_key("blockedHosts") {
393 endpoint.blocked_hosts = config.blocked_hosts.clone();
394 }
395 if !parts.params.contains_key("maxBodySize") {
396 endpoint.max_body_size = config.max_body_size;
397 }
398 if !parts.params.contains_key("readTimeout") {
399 endpoint.read_timeout_ms = config.read_timeout_ms;
400 }
401 if !parts.params.contains_key("maxResponseBytes") {
402 endpoint.max_response_bytes = config.max_response_bytes;
403 }
404 if !parts.params.contains_key("okStatusCodeRange")
405 && let Some(range) = &config.ok_status_code_range
406 {
407 endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
408 }
409
410 Ok(endpoint)
411 }
412}
413
414#[derive(Debug, Clone)]
420pub struct HttpServerConfig {
421 pub host: String,
423 pub port: u16,
425 pub path: String,
427 pub max_request_body: usize,
429 pub max_response_body: usize,
431 pub max_inflight_requests: usize,
433}
434
435impl UriConfig for HttpServerConfig {
436 fn scheme() -> &'static str {
438 "http"
439 }
440
441 fn from_uri(uri: &str) -> Result<Self, CamelError> {
442 let parts = parse_uri(uri)?;
443 Self::from_components(parts)
444 }
445
446 fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
447 if parts.scheme != "http" && parts.scheme != "https" {
449 return Err(CamelError::InvalidUri(format!(
450 "expected scheme 'http' or 'https', got '{}'",
451 parts.scheme
452 )));
453 }
454
455 let authority_and_path = parts.path.trim_start_matches('/');
458
459 let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
461 (&authority_and_path[..idx], &authority_and_path[idx..])
462 } else {
463 (authority_and_path, "/")
464 };
465
466 let path = if path_suffix.is_empty() {
467 "/"
468 } else {
469 path_suffix
470 }
471 .to_string();
472
473 let (host, port) = if let Some(colon) = authority.rfind(':') {
475 let port_str = &authority[colon + 1..];
476 match port_str.parse::<u16>() {
477 Ok(p) => (authority[..colon].to_string(), p),
478 Err(_) => {
479 return Err(CamelError::InvalidUri(format!(
480 "invalid port '{}' in authority",
481 port_str
482 )));
483 }
484 }
485 } else {
486 let default_port = if parts.scheme == "https" { 443 } else { 80 };
488 (authority.to_string(), default_port)
489 };
490
491 let max_request_body = parts
492 .params
493 .get("maxRequestBody")
494 .and_then(|v| v.parse::<usize>().ok())
495 .unwrap_or(2 * 1024 * 1024); let max_response_body = parts
498 .params
499 .get("maxResponseBody")
500 .and_then(|v| v.parse::<usize>().ok())
501 .unwrap_or(10 * 1024 * 1024); let max_inflight_requests = parts
504 .params
505 .get("maxInflightRequests")
506 .and_then(|v| v.parse::<usize>().ok())
507 .unwrap_or(1024);
508
509 Ok(Self {
510 host,
511 port,
512 path,
513 max_request_body,
514 max_response_body,
515 max_inflight_requests,
516 })
517 }
518}
519
520impl HttpServerConfig {
521 pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
522 let parts = parse_uri(uri)?;
523 let mut server = Self::from_components(parts.clone())?;
524 if !parts.params.contains_key("maxRequestBody") {
525 server.max_request_body = config.max_request_body;
526 }
527 if !parts.params.contains_key("maxResponseBody") {
528 server.max_response_body = config.max_body_size;
530 }
531 Ok(server)
532 }
533}
534
535pub enum HttpReplyBody {
543 Bytes(bytes::Bytes),
544 Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
545}
546
547pub struct RequestEnvelope {
552 pub method: String,
553 pub path: String,
554 pub query: String,
555 pub headers: http::HeaderMap,
556 pub body: StreamBody,
557 pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
558}
559
560pub struct HttpReply {
564 pub status: u16,
565 pub headers: Vec<(String, String)>,
566 pub body: HttpReplyBody,
567}
568
569type ServerKey = (String, u16);
574
575struct ServerHandle {
577 registry: HttpRouteRegistry,
578 max_request_body: usize,
579 max_response_body: usize,
580 max_inflight_requests: usize,
581}
582
583pub struct ServerRegistry {
585 inner: Mutex<HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>>,
586}
587
588impl ServerRegistry {
589 pub fn global() -> &'static Self {
591 static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
592 INSTANCE.get_or_init(|| ServerRegistry {
593 inner: Mutex::new(HashMap::new()),
594 })
595 }
596
597 #[allow(clippy::too_many_arguments)]
600 pub async fn get_or_spawn(
601 &'static self,
602 host: &str,
603 port: u16,
604 max_request_body: usize,
605 max_response_body: usize,
606 max_inflight_requests: usize,
607 runtime: Arc<dyn RuntimeObservability>,
608 route_id: String,
609 ) -> Result<HttpRouteRegistry, CamelError> {
610 let host_owned = host.to_string();
611
612 let cell = {
613 let mut guard = self.inner.lock().map_err(|_| {
614 CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
615 })?;
616 let key = (host.to_string(), port);
617 guard
618 .entry(key)
619 .or_insert_with(|| Arc::new(OnceCell::new()))
620 .clone()
621 };
622
623 if let Some(existing) = cell.get()
624 && existing.max_request_body != max_request_body
625 {
626 return Err(CamelError::EndpointCreationFailed(format!(
627 "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
628 existing.max_request_body, max_request_body
629 )));
630 }
631
632 if let Some(existing) = cell.get()
633 && existing.max_response_body != max_response_body
634 {
635 return Err(CamelError::EndpointCreationFailed(format!(
636 "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
637 existing.max_response_body, max_response_body
638 )));
639 }
640
641 if let Some(existing) = cell.get()
642 && existing.max_inflight_requests != max_inflight_requests
643 {
644 return Err(CamelError::EndpointCreationFailed(format!(
645 "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
646 existing.max_inflight_requests, max_inflight_requests
647 )));
648 }
649
650 let handle = cell
651 .get_or_try_init(|| {
652 let rt = Arc::clone(&runtime);
653 let rid = route_id.clone();
654 async move {
655 let addr = format!("{host_owned}:{port}");
656 let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
657 CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
658 })?;
659 let registry = HttpRouteRegistry::new();
660 let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
661 let task = tokio::spawn(run_axum_server(
662 listener,
663 registry.clone(),
664 max_request_body,
665 max_response_body,
666 Arc::clone(&inflight),
667 Arc::clone(&rt),
668 rid.clone(),
669 ));
670 let addr_for_monitor = format!("{host_owned}:{port}");
671 tokio::spawn(monitor_axum_task(
672 task,
673 addr_for_monitor,
674 Arc::clone(&rt),
675 rid,
676 ));
677 Ok::<ServerHandle, CamelError>(ServerHandle {
678 registry,
679 max_request_body,
680 max_response_body,
681 max_inflight_requests,
682 })
683 }
684 })
685 .await?;
686
687 Ok(handle.registry.clone())
688 }
689
690 #[cfg(test)]
697 pub fn reset() {
698 let instance = Self::global();
699 let mut guard = instance
700 .inner
701 .lock()
702 .expect("ServerRegistry lock poisoned during test reset");
703 guard.clear();
704 }
705}
706
707use axum::{
712 Router,
713 body::Body as AxumBody,
714 extract::{Request, State},
715 http::{Response, StatusCode},
716 response::IntoResponse,
717};
718
719#[derive(Clone)]
720pub(crate) struct AppState {
721 registry: HttpRouteRegistry,
722 max_request_body: usize,
723 max_response_body: usize,
724 inflight: Arc<tokio::sync::Semaphore>,
725}
726
727async fn run_axum_server(
728 listener: tokio::net::TcpListener,
729 registry: HttpRouteRegistry,
730 max_request_body: usize,
731 max_response_body: usize,
732 inflight: Arc<tokio::sync::Semaphore>,
733 runtime: Arc<dyn RuntimeObservability>,
734 route_id: String,
735) {
736 let state = AppState {
737 registry,
738 max_request_body,
739 max_response_body,
740 inflight,
741 };
742 let app = Router::new().fallback(dispatch_handler).with_state(state);
743
744 axum::serve(listener, app).await.unwrap_or_else(|e| {
745 runtime
746 .metrics()
747 .increment_errors(&route_id, "e:http:accept");
748 tracing::error!(error = %e, "Axum server error");
750 });
751}
752
753async fn monitor_axum_task(
761 handle: tokio::task::JoinHandle<()>,
762 addr: String,
763 runtime: Arc<dyn RuntimeObservability>,
764 route_id: String,
765) {
766 match handle.await {
767 Ok(()) => {
768 }
770 Err(join_err) => {
771 runtime
772 .metrics()
773 .increment_errors(&route_id, "e:http:server-task-exited");
774 tracing::error!(
776 addr = %addr,
777 error = %join_err,
778 "Axum server task exited unexpectedly — all routes on this port are now dead"
779 );
780 }
781 }
782}
783
784async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
785 let path = req.uri().path().to_owned();
786
787 let api_sender = {
789 let inner = state.registry.inner.read().await;
790 inner.api_routes.get(&path).cloned()
791 }; if let Some(sender) = api_sender {
794 let method = req.method().to_string();
795 let query = req.uri().query().unwrap_or("").to_string();
796 let headers = req.headers().clone();
797
798 let content_length: Option<u64> = headers
800 .get(http::header::CONTENT_LENGTH)
801 .and_then(|v| v.to_str().ok())
802 .and_then(|s| s.parse().ok());
803
804 if let Some(len) = content_length
805 && len > state.max_request_body as u64
806 {
807 return Response::builder()
808 .status(StatusCode::PAYLOAD_TOO_LARGE)
809 .body(AxumBody::from("Request body exceeds configured limit"))
810 .expect("infallible"); }
812
813 let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
814 Ok(permit) => permit,
815 Err(_) => {
816 return Response::builder()
817 .status(StatusCode::SERVICE_UNAVAILABLE)
818 .body(AxumBody::from("Service Unavailable"))
819 .expect("infallible"); }
821 };
822
823 let content_type = headers
825 .get(http::header::CONTENT_TYPE)
826 .and_then(|v| v.to_str().ok())
827 .map(|s| s.to_string());
828
829 let data_stream: BodyDataStream = req.into_body().into_data_stream();
830 let mapped_stream = data_stream.map_err(|e| CamelError::Io(e.to_string()));
831 let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(mapped_stream);
832
833 let stream_body = StreamBody {
834 stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
835 metadata: StreamMetadata {
836 size_hint: content_length,
837 content_type,
838 origin: None,
839 },
840 };
841
842 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
843 let envelope = RequestEnvelope {
844 method,
845 path,
846 query,
847 headers,
848 body: stream_body,
849 reply_tx,
850 };
851
852 if sender.send(envelope).await.is_err() {
853 return Response::builder()
854 .status(StatusCode::SERVICE_UNAVAILABLE)
855 .body(AxumBody::from("Consumer unavailable"))
856 .expect("infallible"); }
858
859 match reply_rx.await {
860 Ok(reply) => {
861 let reply = match reply.body {
862 HttpReplyBody::Bytes(b)
863 if exceeds_max_response_body(b.len(), state.max_response_body) =>
864 {
865 HttpReply {
866 status: 500,
867 headers: vec![],
868 body: HttpReplyBody::Bytes(bytes::Bytes::from(
869 "Response body exceeds configured limit",
870 )),
871 }
872 }
873 _ => reply,
874 };
875
876 let status =
877 StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
878 let mut builder = Response::builder().status(status);
879 for (k, v) in &reply.headers {
880 builder = builder.header(k.as_str(), v.as_str());
881 }
882 match reply.body {
883 HttpReplyBody::Bytes(b) => {
884 builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
885 Response::builder()
886 .status(StatusCode::INTERNAL_SERVER_ERROR)
887 .body(AxumBody::from("Invalid response headers from consumer"))
888 .expect("infallible") })
890 }
891 HttpReplyBody::Stream(stream) => builder
892 .body(AxumBody::from_stream(stream))
893 .unwrap_or_else(|_| {
894 Response::builder()
895 .status(StatusCode::INTERNAL_SERVER_ERROR)
896 .body(AxumBody::from("Invalid response headers from consumer"))
897 .expect("infallible") }),
899 }
900 }
901 Err(_) => Response::builder()
902 .status(StatusCode::INTERNAL_SERVER_ERROR)
903 .body(AxumBody::from("Pipeline error"))
904 .expect("infallible"), }
906 } else {
907 static_dispatch::dispatch_static(&state, req, &path).await
909 }
910}
911
912fn exceeds_max_response_body(len: usize, max: usize) -> bool {
913 len > max
914}
915
916fn title_case_header(name: &str) -> String {
917 name.split('-')
918 .map(|part| {
919 let mut chars = part.chars();
920 match chars.next() {
921 None => String::new(),
922 Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
923 }
924 })
925 .collect::<Vec<_>>()
926 .join("-")
927}
928
929pub struct HttpConsumer {
934 config: HttpServerConfig,
935 runtime: Arc<dyn RuntimeObservability>,
937}
938
939impl HttpConsumer {
940 pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
941 Self { config, runtime }
942 }
943}
944
945#[async_trait::async_trait]
946impl Consumer for HttpConsumer {
947 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
948 use camel_component_api::{Body, Exchange, Message};
949
950 let registry = ServerRegistry::global()
951 .get_or_spawn(
952 &self.config.host,
953 self.config.port,
954 self.config.max_request_body,
955 self.config.max_response_body,
956 self.config.max_inflight_requests,
957 self.runtime.clone(),
958 ctx.route_id().to_string(),
959 )
960 .await?;
961
962 let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(64);
964 registry
965 .register_api_route(self.config.path.clone(), env_tx)
966 .await;
967
968 let path = self.config.path.clone();
969 let registry_for_cleanup = registry.clone();
970 let cancel_token = ctx.cancel_token();
971 loop {
972 tokio::select! {
973 _ = ctx.cancelled() => {
974 break;
975 }
976 envelope = env_rx.recv() => {
977 let Some(envelope) = envelope else { break; };
978
979 let mut msg = Message::default();
981
982 msg.set_header("CamelHttpMethod",
984 serde_json::Value::String(envelope.method.clone()));
985 msg.set_header("CamelHttpPath",
986 serde_json::Value::String(envelope.path.clone()));
987 msg.set_header("CamelHttpQuery",
988 serde_json::Value::String(envelope.query.clone()));
989
990 for (k, v) in &envelope.headers {
992 if let Ok(val_str) = v.to_str() {
993 msg.set_header(
994 title_case_header(k.as_str()),
995 serde_json::Value::String(val_str.to_string()),
996 );
997 }
998 }
999
1000 msg.body = Body::Stream(envelope.body);
1003
1004 #[allow(unused_mut)]
1005 let mut exchange = Exchange::new(msg);
1006
1007 #[cfg(feature = "otel")]
1009 {
1010 let headers: HashMap<String, String> = envelope
1011 .headers
1012 .iter()
1013 .filter_map(|(k, v)| {
1014 Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1015 })
1016 .collect();
1017 camel_otel::extract_into_exchange(&mut exchange, &headers);
1018 }
1019
1020 let reply_tx = envelope.reply_tx;
1021 let sender = ctx.sender().clone();
1022 let path_clone = path.clone();
1023 let cancel = cancel_token.clone();
1024
1025 tokio::spawn(async move {
1045 if cancel.is_cancelled() {
1053 let _ = reply_tx.send(HttpReply {
1054 status: 503,
1055 headers: vec![],
1056 body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1057 });
1058 return;
1059 }
1060
1061 let (tx, rx) = tokio::sync::oneshot::channel();
1063 let envelope = camel_component_api::consumer::ExchangeEnvelope {
1064 exchange,
1065 reply_tx: Some(tx),
1066 };
1067
1068 let result = match sender.send(envelope).await {
1069 Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1070 Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1071 }
1072 .and_then(|r| r);
1073
1074 let reply = match result {
1075 Ok(out) => {
1076 let status = out
1077 .input
1078 .header("CamelHttpResponseCode")
1079 .and_then(|v| {
1080 let raw = v.as_u64()
1081 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
1082 let code = raw as u16;
1083 (100..1000).contains(&code).then_some(code)
1084 })
1085 .unwrap_or(200);
1086
1087 let user_content_type = out
1088 .input
1089 .header("Content-Type")
1090 .and_then(|v| v.as_str().map(|s| s.to_string()));
1091
1092 let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
1093 Body::Empty => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
1094 Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
1095 Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
1096 Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
1097 Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
1098 v.to_string().into_bytes(),
1099 )), Some("application/json".to_string())),
1100 Body::Stream(s) => {
1101 let ct = s.metadata.content_type.clone();
1102 match s.stream.lock().await.take() {
1103 Some(stream) => (
1104 HttpReplyBody::Stream(stream),
1105 ct,
1106 ),
1107 None => {
1108 tracing::error!(
1110 "Body::Stream already consumed before HTTP reply — returning 500"
1111 );
1112 let error_reply = HttpReply {
1113 status: 500,
1114 headers: vec![],
1115 body: HttpReplyBody::Bytes(bytes::Bytes::new()),
1116 };
1117 if reply_tx.send(error_reply).is_err() {
1118 debug!("reply_tx dropped before error reply could be sent");
1119 }
1120 return;
1121 }
1122 }
1123 }
1124 };
1125
1126 let mut resp_headers: Vec<(String, String)> = out
1127 .input
1128 .headers
1129 .iter()
1130 .filter(|(k, _)| !k.starts_with("Camel"))
1131 .filter(|(k, _)| {
1132 !matches!(
1133 k.to_lowercase().as_str(),
1134 "content-length"
1135 | "content-type"
1136 | "transfer-encoding"
1137 | "connection"
1138 | "cache-control"
1139 | "date"
1140 | "pragma"
1141 | "trailer"
1142 | "upgrade"
1143 | "via"
1144 | "warning"
1145 | "host"
1146 | "user-agent"
1147 | "accept"
1148 | "accept-encoding"
1149 | "accept-language"
1150 | "accept-charset"
1151 | "authorization"
1152 | "proxy-authorization"
1153 | "cookie"
1154 | "expect"
1155 | "from"
1156 | "if-match"
1157 | "if-modified-since"
1158 | "if-none-match"
1159 | "if-range"
1160 | "if-unmodified-since"
1161 | "max-forwards"
1162 | "proxy-connection"
1163 | "range"
1164 | "referer"
1165 | "te"
1166 )
1167 })
1168 .filter_map(|(k, v)| {
1169 v.as_str().map(|s| (k.clone(), s.to_string()))
1170 })
1171 .collect();
1172
1173 let content_type = user_content_type
1174 .or(inferred_content_type);
1175 if let Some(ct) = content_type {
1176 resp_headers.push(("Content-Type".to_string(), ct));
1177 }
1178
1179 HttpReply {
1180 status,
1181 headers: resp_headers,
1182 body: reply_body,
1183 }
1184 }
1185 Err(CamelError::Unauthenticated(msg)) => {
1186 tracing::warn!(error = %msg, path = %path_clone, "Authentication failed");
1187 HttpReply {
1188 status: 401,
1189 headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
1190 body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
1191 }
1192 }
1193 Err(CamelError::Unauthorized(msg)) => {
1194 tracing::warn!(error = %msg, path = %path_clone, "Authorization failed");
1195 HttpReply {
1196 status: 403,
1197 headers: vec![],
1198 body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
1199 }
1200 }
1201 Err(e) => {
1202 tracing::warn!(error = %e, path = %path_clone, "Pipeline error processing HTTP request");
1204 HttpReply {
1205 status: 500,
1206 headers: vec![],
1207 body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
1208 }
1209 }
1210 };
1211
1212 let _ = reply_tx.send(reply);
1214 });
1215 }
1216 }
1217 }
1218
1219 registry_for_cleanup.unregister_api_route(&path).await;
1221
1222 Ok(())
1223 }
1224
1225 async fn stop(&mut self) -> Result<(), CamelError> {
1226 Ok(())
1227 }
1228
1229 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
1230 camel_component_api::ConcurrencyModel::Concurrent { max: None }
1231 }
1232}
1233
1234pub struct HttpComponent {
1239 config: HttpConfig,
1240}
1241
1242fn build_client(config: &HttpConfig, cookie_handling: CookieHandling) -> reqwest::Client {
1243 let mut builder = reqwest::Client::builder()
1244 .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
1245 .pool_max_idle_per_host(config.pool_max_idle_per_host)
1246 .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
1247
1248 if !config.follow_redirects {
1249 builder = builder.redirect(reqwest::redirect::Policy::none());
1250 } else if let Some(max_redirects) = config.max_redirects {
1251 builder = builder.redirect(reqwest::redirect::Policy::limited(max_redirects));
1252 }
1253
1254 if let Some(proxy_url) = &config.proxy_url
1255 && let Ok(proxy) = reqwest::Proxy::all(proxy_url)
1256 {
1257 builder = builder.proxy(proxy);
1258 }
1259
1260 if matches!(cookie_handling, CookieHandling::InMemory) {
1261 }
1263
1264 if let Some(tls) = &config.tls
1265 && tls.enabled
1266 {
1267 if tls.insecure || !tls.verify_peer {
1268 builder = builder.danger_accept_invalid_certs(true);
1269 }
1270
1271 if let Some(ca_path) = &tls.ca_cert_path
1272 && let Ok(ca_bytes) = std::fs::read(ca_path)
1273 {
1274 let cert = reqwest::Certificate::from_pem(&ca_bytes)
1275 .or_else(|_| reqwest::Certificate::from_der(&ca_bytes));
1276 if let Ok(ca_cert) = cert {
1277 builder = builder.add_root_certificate(ca_cert);
1278 }
1279 }
1280
1281 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path)
1282 && let (Ok(cert_bytes), Ok(key_bytes)) =
1283 (std::fs::read(cert_path), std::fs::read(key_path))
1284 {
1285 let mut identity_pem = cert_bytes;
1286 identity_pem.extend_from_slice(&key_bytes);
1287 if let Ok(identity) = reqwest::Identity::from_pem(&identity_pem) {
1288 builder = builder.identity(identity);
1289 }
1290 }
1291 }
1292
1293 builder
1294 .build()
1295 .expect("reqwest::Client::build() with valid config should not fail") }
1297
1298impl HttpComponent {
1299 pub fn new() -> Self {
1300 let config = HttpConfig::default();
1301 Self { config }
1302 }
1303
1304 pub fn with_config(config: HttpConfig) -> Self {
1305 Self { config }
1306 }
1307
1308 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1309 match config {
1310 Some(cfg) => Self::with_config(cfg),
1311 None => Self::new(),
1312 }
1313 }
1314}
1315
1316impl Default for HttpComponent {
1317 fn default() -> Self {
1318 Self::new()
1319 }
1320}
1321
1322impl Component for HttpComponent {
1323 fn scheme(&self) -> &str {
1324 "http"
1325 }
1326
1327 fn create_endpoint(
1328 &self,
1329 uri: &str,
1330 ctx: &dyn camel_component_api::ComponentContext,
1331 ) -> Result<Box<dyn Endpoint>, CamelError> {
1332 self.config.validate()?;
1333 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1334 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1335 let client = build_client(&self.config, config.cookie_handling);
1336 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1337 server_config.host.clone(),
1338 server_config.port,
1339 )));
1340 Ok(Box::new(HttpEndpoint {
1341 uri: uri.to_string(),
1342 config,
1343 server_config,
1344 client,
1345 }))
1346 }
1347}
1348
1349pub struct HttpsComponent {
1350 config: HttpConfig,
1351}
1352
1353impl HttpsComponent {
1354 pub fn new() -> Self {
1355 let config = HttpConfig::default();
1356 Self { config }
1357 }
1358
1359 pub fn with_config(config: HttpConfig) -> Self {
1360 Self { config }
1361 }
1362
1363 pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1364 match config {
1365 Some(cfg) => Self::with_config(cfg),
1366 None => Self::new(),
1367 }
1368 }
1369}
1370
1371impl Default for HttpsComponent {
1372 fn default() -> Self {
1373 Self::new()
1374 }
1375}
1376
1377impl Component for HttpsComponent {
1378 fn scheme(&self) -> &str {
1379 "https"
1380 }
1381
1382 fn create_endpoint(
1383 &self,
1384 uri: &str,
1385 ctx: &dyn camel_component_api::ComponentContext,
1386 ) -> Result<Box<dyn Endpoint>, CamelError> {
1387 self.config.validate()?;
1388 let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1389 let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1390 let client = build_client(&self.config, config.cookie_handling);
1391 ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1392 server_config.host.clone(),
1393 server_config.port,
1394 )));
1395 Ok(Box::new(HttpEndpoint {
1396 uri: uri.to_string(),
1397 config,
1398 server_config,
1399 client,
1400 }))
1401 }
1402}
1403
1404struct HttpEndpoint {
1409 uri: String,
1410 config: HttpEndpointConfig,
1411 server_config: HttpServerConfig,
1412 client: reqwest::Client,
1413}
1414
1415impl Endpoint for HttpEndpoint {
1416 fn uri(&self) -> &str {
1417 &self.uri
1418 }
1419
1420 fn create_consumer(
1421 &self,
1422 rt: Arc<dyn camel_component_api::RuntimeObservability>,
1423 ) -> Result<Box<dyn Consumer>, CamelError> {
1424 Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
1425 }
1426
1427 fn create_producer(
1428 &self,
1429 _rt: Arc<dyn camel_component_api::RuntimeObservability>,
1430 _ctx: &ProducerContext,
1431 ) -> Result<BoxProcessor, CamelError> {
1432 let producer = HttpProducer {
1433 config: Arc::new(self.config.clone()),
1434 client: self.client.clone(),
1435 };
1436 if let Some(ref provider) = self.config.token_provider {
1437 let layer = BearerTokenLayer::new(Arc::clone(provider));
1438 Ok(BoxProcessor::new(layer.layer(producer)))
1439 } else {
1440 Ok(BoxProcessor::new(producer))
1441 }
1442 }
1443}
1444
1445fn validate_url_for_ssrf(url: &str, config: &HttpEndpointConfig) -> Result<(), CamelError> {
1450 let parsed = url::Url::parse(url)
1451 .map_err(|e| CamelError::ProcessorError(format!("Invalid URL: {}", e)))?;
1452
1453 if let Some(host) = parsed.host_str()
1455 && config.blocked_hosts.iter().any(|blocked| host == blocked)
1456 {
1457 return Err(CamelError::ProcessorError(format!(
1458 "Host '{}' is blocked",
1459 host
1460 )));
1461 }
1462
1463 if !config.allow_private_ips
1465 && let Some(host) = parsed.host()
1466 {
1467 match host {
1468 url::Host::Ipv4(ip) => {
1469 if ip.is_private() || ip.is_loopback() || ip.is_link_local() {
1470 return Err(CamelError::ProcessorError(format!(
1471 "Private IP '{}' not allowed (set allowPrivateIps=true to override)",
1472 ip
1473 )));
1474 }
1475 }
1476 url::Host::Ipv6(ip) => {
1477 if ip.is_loopback() {
1478 return Err(CamelError::ProcessorError(format!(
1479 "Loopback IP '{}' not allowed",
1480 ip
1481 )));
1482 }
1483 }
1484 url::Host::Domain(domain) => {
1485 let blocked_domains = ["localhost", "127.0.0.1", "0.0.0.0", "local"];
1487 if blocked_domains.contains(&domain) {
1488 return Err(CamelError::ProcessorError(format!(
1489 "Domain '{}' is not allowed",
1490 domain
1491 )));
1492 }
1493 }
1494 }
1495 }
1496
1497 Ok(())
1498}
1499
1500fn is_private_ip(ip: &IpAddr) -> bool {
1501 match ip {
1502 IpAddr::V4(ipv4) => {
1503 ipv4.is_private() || ipv4.is_loopback() || ipv4.is_link_local() || ipv4.octets()[0] == 0
1504 }
1505 IpAddr::V6(ipv6) => {
1506 let seg0 = ipv6.segments()[0];
1507 ipv6.is_loopback()
1508 || (seg0 & 0xfe00) == 0xfc00
1510 || (seg0 & 0xffc0) == 0xfe80
1512 || ipv6
1514 .to_ipv4_mapped()
1515 .map(|v4| {
1516 v4.is_private()
1517 || v4.is_loopback()
1518 || v4.is_link_local()
1519 || v4.octets()[0] == 0
1520 })
1521 .unwrap_or(false)
1522 }
1523 }
1524}
1525
1526async fn validate_resolved_host_for_ssrf(
1527 url: &str,
1528 config: &HttpEndpointConfig,
1529) -> Result<(), CamelError> {
1530 if config.allow_private_ips {
1531 return Ok(());
1532 }
1533
1534 let parsed = url::Url::parse(url)
1535 .map_err(|e| CamelError::ProcessorError(format!("Invalid URL: {}", e)))?;
1536 let Some(host) = parsed.host_str() else {
1537 return Ok(());
1538 };
1539 let Some(port) = parsed.port_or_known_default() else {
1540 return Ok(());
1541 };
1542
1543 let resolved = tokio::net::lookup_host((host, port)).await.map_err(|e| {
1544 CamelError::ProcessorError(format!("Failed to resolve host '{}': {}", host, e))
1545 })?;
1546
1547 for addr in resolved {
1548 let ip = addr.ip();
1549 if is_private_ip(&ip) {
1550 return Err(CamelError::ProcessorError(format!(
1551 "Target resolved to private IP: {}",
1552 ip
1553 )));
1554 }
1555 }
1556
1557 Ok(())
1558}
1559
1560#[derive(Clone)]
1565struct HttpProducer {
1566 config: Arc<HttpEndpointConfig>,
1567 client: reqwest::Client,
1568}
1569
1570impl HttpProducer {
1571 fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1572 if let Some(ref method) = config.http_method {
1573 return method.to_uppercase();
1574 }
1575 if let Some(method) = exchange
1576 .input
1577 .header("CamelHttpMethod")
1578 .and_then(|v| v.as_str())
1579 {
1580 return method.to_uppercase();
1581 }
1582 if !exchange.input.body.is_empty() {
1583 return "POST".to_string();
1584 }
1585 "GET".to_string()
1586 }
1587
1588 fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1589 if let Some(uri) = exchange
1590 .input
1591 .header("CamelHttpUri")
1592 .and_then(|v| v.as_str())
1593 {
1594 let mut url = uri.to_string();
1595 if let Some(path) = exchange
1596 .input
1597 .header("CamelHttpPath")
1598 .and_then(|v| v.as_str())
1599 {
1600 if !url.ends_with('/') && !path.starts_with('/') {
1601 url.push('/');
1602 }
1603 url.push_str(path);
1604 }
1605 if let Some(query) = exchange
1606 .input
1607 .header("CamelHttpQuery")
1608 .and_then(|v| v.as_str())
1609 {
1610 url.push('?');
1611 url.push_str(query);
1612 }
1613 return url;
1614 }
1615
1616 let mut url = config.base_url.clone();
1617
1618 if let Some(path) = exchange
1619 .input
1620 .header("CamelHttpPath")
1621 .and_then(|v| v.as_str())
1622 {
1623 if !url.ends_with('/') && !path.starts_with('/') {
1624 url.push('/');
1625 }
1626 url.push_str(path);
1627 }
1628
1629 if let Some(query) = exchange
1630 .input
1631 .header("CamelHttpQuery")
1632 .and_then(|v| v.as_str())
1633 {
1634 url.push('?');
1635 url.push_str(query);
1636 } else if !config.query_params.is_empty() {
1637 let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); for (k, v) in &config.query_params {
1639 parsed.query_pairs_mut().append_pair(k, v);
1640 }
1641 url = parsed.to_string();
1642 }
1643
1644 url
1645 }
1646
1647 fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
1648 status >= range.0 && status <= range.1
1649 }
1650}
1651
1652impl Service<Exchange> for HttpProducer {
1653 type Response = Exchange;
1654 type Error = CamelError;
1655 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1656
1657 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1658 Poll::Ready(Ok(()))
1659 }
1660
1661 fn call(&mut self, mut exchange: Exchange) -> Self::Future {
1662 let config = self.config.clone();
1663 let client = self.client.clone();
1664
1665 Box::pin(async move {
1666 let method_str = HttpProducer::resolve_method(&exchange, &config);
1667 let url = HttpProducer::resolve_url(&exchange, &config);
1668
1669 validate_url_for_ssrf(&url, &config)?;
1671 validate_resolved_host_for_ssrf(&url, &config).await?;
1672
1673 debug!(
1674 correlation_id = %exchange.correlation_id(),
1675 method = %method_str,
1676 url = %url,
1677 "HTTP request"
1678 );
1679
1680 let method = method_str.parse::<reqwest::Method>().map_err(|e| {
1681 CamelError::ProcessorError(format!("Invalid HTTP method '{}': {}", method_str, e))
1682 })?;
1683
1684 let mut request = client.request(method, &url);
1685
1686 if let Some(timeout) = config.response_timeout {
1687 request = request.timeout(timeout);
1688 }
1689
1690 if let Some(user_agent) = &config.user_agent
1691 && !config.bridge_endpoint
1692 {
1693 request = request.header("User-Agent", user_agent.clone());
1694 }
1695
1696 #[cfg(feature = "otel")]
1698 let should_inject_otel = !config.bridge_endpoint;
1699 #[cfg(feature = "otel")]
1700 if should_inject_otel {
1701 let mut otel_headers = HashMap::new();
1702 camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
1703 for (k, v) in otel_headers {
1704 if let (Ok(name), Ok(val)) = (
1705 reqwest::header::HeaderName::from_bytes(k.as_bytes()),
1706 reqwest::header::HeaderValue::from_str(&v),
1707 ) {
1708 request = request.header(name, val);
1709 }
1710 }
1711 }
1712
1713 for (key, value) in &exchange.input.headers {
1714 if !key.starts_with("Camel")
1715 && !config
1716 .skip_request_headers
1717 .iter()
1718 .any(|h| h.eq_ignore_ascii_case(key))
1719 && let Some(val_str) = value.as_str()
1720 && let (Ok(name), Ok(val)) = (
1721 reqwest::header::HeaderName::from_bytes(key.as_bytes()),
1722 reqwest::header::HeaderValue::from_str(val_str),
1723 )
1724 {
1725 request = request.header(name, val);
1726 }
1727 }
1728
1729 if !config.bridge_endpoint {
1730 match &config.auth {
1731 HttpAuth::None => {}
1732 HttpAuth::Basic { username, password } => {
1733 request = request.basic_auth(username, Some(password));
1734 }
1735 HttpAuth::Bearer { token } => {
1736 request = request.bearer_auth(token);
1737 }
1738 }
1739
1740 if config.connection_close {
1741 request = request.header("Connection", "close");
1742 }
1743 }
1744
1745 match exchange.input.body {
1746 Body::Stream(ref s) => {
1747 let mut stream_lock = s.stream.lock().await;
1748 if let Some(stream) = stream_lock.take() {
1749 request = request.body(reqwest::Body::wrap_stream(stream));
1750 } else {
1751 return Err(CamelError::AlreadyConsumed);
1752 }
1753 }
1754 _ => {
1755 let body = std::mem::take(&mut exchange.input.body);
1757 let bytes = body.into_bytes(config.max_body_size).await?;
1758 if !bytes.is_empty() {
1759 request = request.body(bytes);
1760 }
1761 }
1762 }
1763
1764 let response = request
1765 .send()
1766 .await
1767 .map_err(|e| CamelError::ProcessorError(format!("HTTP request failed: {e}")))?;
1768
1769 let status_code = response.status().as_u16();
1770 let status_text = response
1771 .status()
1772 .canonical_reason()
1773 .unwrap_or("Unknown")
1774 .to_string();
1775
1776 for (key, value) in response.headers() {
1777 if config
1778 .skip_response_headers
1779 .iter()
1780 .any(|h| h.eq_ignore_ascii_case(key.as_str()))
1781 {
1782 continue;
1783 }
1784 if let Ok(val_str) = value.to_str() {
1785 exchange.input.set_header(
1786 title_case_header(key.as_str()),
1787 serde_json::Value::String(val_str.to_string()),
1788 );
1789 }
1790 }
1791
1792 exchange.input.set_header(
1793 "CamelHttpResponseCode",
1794 serde_json::Value::Number(status_code.into()),
1795 );
1796 exchange.input.set_header(
1797 "CamelHttpResponseText",
1798 serde_json::Value::String(status_text.clone()),
1799 );
1800
1801 let read_timeout = Duration::from_millis(config.read_timeout_ms);
1803 let response_body = tokio::time::timeout(read_timeout, async {
1804 if let Some(content_len) = response.content_length()
1806 && content_len > config.max_response_bytes as u64
1807 {
1808 return Err(CamelError::ProcessorError(format!(
1809 "Response body too large: {} bytes exceeds limit of {} bytes",
1810 content_len, config.max_response_bytes
1811 )));
1812 }
1813 use futures::TryStreamExt;
1815 let mut stream = response.bytes_stream();
1816 let mut total: usize = 0;
1817 let mut collected = Vec::new();
1818 while let Some(chunk) = stream.try_next().await.map_err(|e| {
1819 CamelError::ProcessorError(format!("Failed to read response body: {e}"))
1820 })? {
1821 total += chunk.len();
1822 if total > config.max_response_bytes {
1823 return Err(CamelError::ProcessorError(format!(
1824 "Response body too large: {} bytes exceeds limit of {} bytes",
1825 total, config.max_response_bytes
1826 )));
1827 }
1828 collected.push(chunk);
1829 }
1830 let mut result = bytes::BytesMut::with_capacity(total);
1831 for chunk in collected {
1832 result.extend_from_slice(&chunk);
1833 }
1834 Ok::<bytes::Bytes, CamelError>(result.freeze())
1835 })
1836 .await
1837 .map_err(|_| {
1838 CamelError::ProcessorError(format!(
1839 "Read timeout after {}ms",
1840 config.read_timeout_ms
1841 ))
1842 })??;
1843
1844 if config.throw_exception_on_failure
1845 && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
1846 {
1847 return Err(CamelError::HttpOperationFailed {
1848 method: method_str,
1849 url,
1850 status_code,
1851 status_text,
1852 response_body: Some(String::from_utf8_lossy(&response_body).to_string()),
1853 });
1854 }
1855
1856 if !response_body.is_empty() {
1857 exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
1858 }
1859
1860 debug!(
1861 correlation_id = %exchange.correlation_id(),
1862 status = status_code,
1863 url = %url,
1864 "HTTP response"
1865 );
1866 Ok(exchange)
1867 })
1868 }
1869}
1870
1871#[cfg(test)]
1881pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1882
1883#[cfg(test)]
1884mod tests {
1885 use camel_component_api::test_support::{NoopRuntimeObservability, PanicRuntimeObservability};
1886 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1887 std::sync::Arc::new(PanicRuntimeObservability)
1888 }
1889 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1890 std::sync::Arc::new(PanicRuntimeObservability)
1891 }
1892 fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1893 std::sync::Arc::new(NoopRuntimeObservability)
1894 }
1895
1896 use super::*;
1897 use camel_component_api::{Message, NoOpComponentContext};
1898 use std::sync::Arc;
1899 use std::time::Duration;
1900
1901 fn test_producer_ctx() -> ProducerContext {
1902 ProducerContext::new()
1903 }
1904
1905 #[test]
1906 fn test_http_config_defaults() {
1907 let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
1908 assert_eq!(config.base_url, "http://localhost:8080/api");
1909 assert!(config.http_method.is_none());
1910 assert!(config.throw_exception_on_failure);
1911 assert_eq!(config.ok_status_code_range, (200, 299));
1912 assert!(config.response_timeout.is_none());
1913 assert!(matches!(config.auth, HttpAuth::None));
1914 assert!(matches!(config.cookie_handling, CookieHandling::Disabled));
1915 assert!(!config.bridge_endpoint);
1916 assert!(!config.connection_close);
1917 }
1918
1919 #[test]
1920 fn test_http_config_scheme() {
1921 assert_eq!(HttpEndpointConfig::scheme(), "http");
1923 }
1924
1925 #[test]
1926 fn test_http_config_from_components() {
1927 let components = camel_component_api::UriComponents {
1929 scheme: "https".to_string(),
1930 path: "//api.example.com/v1".to_string(),
1931 params: std::collections::HashMap::from([(
1932 "httpMethod".to_string(),
1933 "POST".to_string(),
1934 )]),
1935 };
1936 let config = HttpEndpointConfig::from_components(components).unwrap();
1937 assert_eq!(config.base_url, "https://api.example.com/v1");
1938 assert_eq!(config.http_method, Some("POST".to_string()));
1939 }
1940
1941 #[test]
1942 fn test_http_config_with_options() {
1943 let config = HttpEndpointConfig::from_uri(
1944 "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
1945 ).unwrap();
1946 assert_eq!(config.base_url, "https://api.example.com/v1");
1947 assert_eq!(config.http_method, Some("PUT".to_string()));
1948 assert!(!config.throw_exception_on_failure);
1949 assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
1950 }
1951
1952 #[test]
1953 fn test_http_endpoint_config_auth_and_headers_options() {
1954 let config = HttpEndpointConfig::from_uri(
1955 "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",
1956 )
1957 .unwrap();
1958
1959 assert!(matches!(
1960 config.auth,
1961 HttpAuth::Basic { username, password } if username == "u" && password == "p"
1962 ));
1963 assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
1964 assert!(matches!(config.cookie_handling, CookieHandling::InMemory));
1965 assert!(config.bridge_endpoint);
1966 assert!(config.connection_close);
1967 assert_eq!(
1968 config.skip_request_headers,
1969 vec!["authorization".to_string(), "x-secret".to_string()]
1970 );
1971 assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
1972 }
1973
1974 #[test]
1975 fn test_http_endpoint_config_bearer_auth() {
1976 let config = HttpEndpointConfig::from_uri(
1977 "http://localhost/api?authMethod=Bearer&authBearerToken=t",
1978 )
1979 .unwrap();
1980 assert!(matches!(
1981 config.auth,
1982 HttpAuth::Bearer { token } if token == "t"
1983 ));
1984 }
1985
1986 #[test]
1987 fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
1988 let config = HttpConfig::default()
1989 .with_response_timeout_ms(999)
1990 .with_allow_private_ips(true)
1991 .with_blocked_hosts(vec!["evil.com".to_string()])
1992 .with_max_body_size(12345);
1993 let endpoint =
1994 HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
1995 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
1996 assert!(endpoint.allow_private_ips);
1997 assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
1998 assert_eq!(endpoint.max_body_size, 12345);
1999 }
2000
2001 #[test]
2002 fn test_from_uri_with_defaults_uri_overrides_config() {
2003 let config = HttpConfig::default()
2004 .with_response_timeout_ms(999)
2005 .with_allow_private_ips(true)
2006 .with_blocked_hosts(vec!["evil.com".to_string()])
2007 .with_max_body_size(12345);
2008 let endpoint = HttpEndpointConfig::from_uri_with_defaults(
2009 "http://example.com/api?responseTimeout=500&allowPrivateIps=false&blockedHosts=bad.net&maxBodySize=99",
2010 &config,
2011 )
2012 .unwrap();
2013 assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
2014 assert!(!endpoint.allow_private_ips);
2015 assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
2016 assert_eq!(endpoint.max_body_size, 99);
2017 }
2018
2019 #[test]
2020 fn test_http_config_ok_status_range() {
2021 let config =
2022 HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
2023 assert_eq!(config.ok_status_code_range, (200, 204));
2024 }
2025
2026 #[test]
2027 fn test_http_config_wrong_scheme() {
2028 let result = HttpEndpointConfig::from_uri("file:/tmp");
2029 assert!(result.is_err());
2030 }
2031
2032 #[test]
2033 fn test_http_component_scheme() {
2034 let component = HttpComponent::new();
2035 assert_eq!(component.scheme(), "http");
2036 }
2037
2038 #[test]
2039 fn test_https_component_scheme() {
2040 let component = HttpsComponent::new();
2041 assert_eq!(component.scheme(), "https");
2042 }
2043
2044 #[test]
2045 fn test_http_endpoint_creates_consumer() {
2046 let component = HttpComponent::new();
2047 let ctx = NoOpComponentContext;
2048 let endpoint = component
2049 .create_endpoint("http://0.0.0.0:19100/test", &ctx)
2050 .unwrap();
2051 assert!(endpoint.create_consumer(rt()).is_ok());
2052 }
2053
2054 #[test]
2055 fn test_https_endpoint_creates_consumer() {
2056 let component = HttpsComponent::new();
2057 let ctx = NoOpComponentContext;
2058 let endpoint = component
2059 .create_endpoint("https://0.0.0.0:8443/test", &ctx)
2060 .unwrap();
2061 assert!(endpoint.create_consumer(rt()).is_ok());
2062 }
2063
2064 #[test]
2065 fn test_http_endpoint_creates_producer() {
2066 let ctx = test_producer_ctx();
2067 let component = HttpComponent::new();
2068 let endpoint_ctx = NoOpComponentContext;
2069 let endpoint = component
2070 .create_endpoint("http://localhost/api", &endpoint_ctx)
2071 .unwrap();
2072 assert!(endpoint.create_producer(rt(), &ctx).is_ok());
2073 }
2074
2075 #[tokio::test]
2080 async fn test_producer_with_token_provider() {
2081 use camel_auth::oauth2::TokenProvider;
2082 use tower::ServiceExt;
2083
2084 let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
2085 Arc::new(std::sync::Mutex::new(None));
2086 let captured_clone = Arc::clone(&captured_auth);
2087
2088 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2089 let port = listener.local_addr().unwrap().port();
2090
2091 let _handle = tokio::spawn(async move {
2092 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2093 if let Ok((mut stream, _)) = listener.accept().await {
2094 let mut buf = vec![0u8; 8192];
2095 let n = stream.read(&mut buf).await.unwrap_or(0);
2096 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2097 let auth = request
2098 .lines()
2099 .find(|l| l.to_lowercase().starts_with("authorization:"))
2100 .map(|l| {
2101 l.split(':')
2102 .nth(1)
2103 .map(|s| s.trim().to_string())
2104 .unwrap_or_default()
2105 });
2106 *captured_clone.lock().unwrap() = auth;
2107 let body = r#"{"echo":"ok"}"#;
2108 let resp = format!(
2109 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2110 body.len(),
2111 body
2112 );
2113 let _ = stream.write_all(resp.as_bytes()).await;
2114 }
2115 });
2116
2117 #[derive(Debug)]
2118 struct StaticProvider;
2119 #[async_trait::async_trait]
2120 impl TokenProvider for StaticProvider {
2121 async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
2122 Ok("injected-token".into())
2123 }
2124 }
2125
2126 let uri = format!("http://127.0.0.1:{}/api?allowPrivateIps=true", port);
2127 let ctx = test_producer_ctx();
2128 let component = HttpComponent::new();
2129 let endpoint_ctx = NoOpComponentContext;
2130 let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
2131 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2132
2133 let exchange = Exchange::new(Message::new("hello"));
2134
2135 let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
2136 let mut layered = layer.layer(producer);
2137 let result = layered.ready().await.unwrap().call(exchange).await;
2138 assert!(result.is_ok(), "producer call failed: {:?}", result);
2139
2140 tokio::time::sleep(Duration::from_millis(100)).await;
2141 let auth = captured_auth.lock().unwrap().take();
2142 assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
2143 }
2144
2145 async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
2146 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2147 let addr = listener.local_addr().unwrap();
2148 let url = format!("http://127.0.0.1:{}", addr.port());
2149
2150 let handle = tokio::spawn(async move {
2151 loop {
2152 if let Ok((mut stream, _)) = listener.accept().await {
2153 tokio::spawn(async move {
2154 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2155 let mut buf = vec![0u8; 4096];
2156 let n = stream.read(&mut buf).await.unwrap_or(0);
2157 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2158
2159 let method = request.split_whitespace().next().unwrap_or("GET");
2160
2161 let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
2162 let response = format!(
2163 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
2164 body.len(),
2165 body
2166 );
2167 let _ = stream.write_all(response.as_bytes()).await;
2168 });
2169 }
2170 }
2171 });
2172
2173 (url, handle)
2174 }
2175
2176 async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
2177 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2178 let addr = listener.local_addr().unwrap();
2179 let url = format!("http://127.0.0.1:{}", addr.port());
2180
2181 let handle = tokio::spawn(async move {
2182 loop {
2183 if let Ok((mut stream, _)) = listener.accept().await {
2184 let status = status;
2185 tokio::spawn(async move {
2186 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2187 let mut buf = vec![0u8; 4096];
2188 let _ = stream.read(&mut buf).await;
2189
2190 let status_text = match status {
2191 404 => "Not Found",
2192 500 => "Internal Server Error",
2193 _ => "Error",
2194 };
2195 let body = "error body";
2196 let response = format!(
2197 "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
2198 status,
2199 status_text,
2200 body.len(),
2201 body
2202 );
2203 let _ = stream.write_all(response.as_bytes()).await;
2204 });
2205 }
2206 }
2207 });
2208
2209 (url, handle)
2210 }
2211
2212 #[tokio::test]
2213 async fn test_http_producer_get_request() {
2214 use tower::ServiceExt;
2215
2216 let (url, _handle) = start_test_server().await;
2217 let ctx = test_producer_ctx();
2218
2219 let component = HttpComponent::new();
2220 let endpoint_ctx = NoOpComponentContext;
2221 let endpoint = component
2222 .create_endpoint(
2223 &format!("{url}/api/test?allowPrivateIps=true"),
2224 &endpoint_ctx,
2225 )
2226 .unwrap();
2227 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2228
2229 let exchange = Exchange::new(Message::default());
2230 let result = producer.oneshot(exchange).await.unwrap();
2231
2232 let status = result
2233 .input
2234 .header("CamelHttpResponseCode")
2235 .and_then(|v| v.as_u64())
2236 .unwrap();
2237 assert_eq!(status, 200);
2238
2239 assert!(!result.input.body.is_empty());
2240 }
2241
2242 #[tokio::test]
2243 async fn test_http_producer_post_with_body() {
2244 use tower::ServiceExt;
2245
2246 let (url, _handle) = start_test_server().await;
2247 let ctx = test_producer_ctx();
2248
2249 let component = HttpComponent::new();
2250 let endpoint_ctx = NoOpComponentContext;
2251 let endpoint = component
2252 .create_endpoint(
2253 &format!("{url}/api/data?allowPrivateIps=true"),
2254 &endpoint_ctx,
2255 )
2256 .unwrap();
2257 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2258
2259 let exchange = Exchange::new(Message::new("request body"));
2260 let result = producer.oneshot(exchange).await.unwrap();
2261
2262 let status = result
2263 .input
2264 .header("CamelHttpResponseCode")
2265 .and_then(|v| v.as_u64())
2266 .unwrap();
2267 assert_eq!(status, 200);
2268 }
2269
2270 #[tokio::test]
2271 async fn test_http_producer_method_from_header() {
2272 use tower::ServiceExt;
2273
2274 let (url, _handle) = start_test_server().await;
2275 let ctx = test_producer_ctx();
2276
2277 let component = HttpComponent::new();
2278 let endpoint_ctx = NoOpComponentContext;
2279 let endpoint = component
2280 .create_endpoint(&format!("{url}/api?allowPrivateIps=true"), &endpoint_ctx)
2281 .unwrap();
2282 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2283
2284 let mut exchange = Exchange::new(Message::default());
2285 exchange.input.set_header(
2286 "CamelHttpMethod",
2287 serde_json::Value::String("DELETE".to_string()),
2288 );
2289
2290 let result = producer.oneshot(exchange).await.unwrap();
2291 let status = result
2292 .input
2293 .header("CamelHttpResponseCode")
2294 .and_then(|v| v.as_u64())
2295 .unwrap();
2296 assert_eq!(status, 200);
2297 }
2298
2299 #[tokio::test]
2300 async fn test_http_producer_forced_method() {
2301 use tower::ServiceExt;
2302
2303 let (url, _handle) = start_test_server().await;
2304 let ctx = test_producer_ctx();
2305
2306 let component = HttpComponent::new();
2307 let endpoint_ctx = NoOpComponentContext;
2308 let endpoint = component
2309 .create_endpoint(
2310 &format!("{url}/api?httpMethod=PUT&allowPrivateIps=true"),
2311 &endpoint_ctx,
2312 )
2313 .unwrap();
2314 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2315
2316 let exchange = Exchange::new(Message::default());
2317 let result = producer.oneshot(exchange).await.unwrap();
2318
2319 let status = result
2320 .input
2321 .header("CamelHttpResponseCode")
2322 .and_then(|v| v.as_u64())
2323 .unwrap();
2324 assert_eq!(status, 200);
2325 }
2326
2327 #[tokio::test]
2328 async fn test_http_producer_throw_exception_on_failure() {
2329 use tower::ServiceExt;
2330
2331 let (url, _handle) = start_status_server(404).await;
2332 let ctx = test_producer_ctx();
2333
2334 let component = HttpComponent::new();
2335 let endpoint_ctx = NoOpComponentContext;
2336 let endpoint = component
2337 .create_endpoint(
2338 &format!("{url}/not-found?allowPrivateIps=true"),
2339 &endpoint_ctx,
2340 )
2341 .unwrap();
2342 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2343
2344 let exchange = Exchange::new(Message::default());
2345 let result = producer.oneshot(exchange).await;
2346 assert!(result.is_err());
2347
2348 match result.unwrap_err() {
2349 CamelError::HttpOperationFailed { status_code, .. } => {
2350 assert_eq!(status_code, 404);
2351 }
2352 e => panic!("Expected HttpOperationFailed, got: {e}"),
2353 }
2354 }
2355
2356 #[tokio::test]
2357 async fn test_http_producer_no_throw_on_failure() {
2358 use tower::ServiceExt;
2359
2360 let (url, _handle) = start_status_server(500).await;
2361 let ctx = test_producer_ctx();
2362
2363 let component = HttpComponent::new();
2364 let endpoint_ctx = NoOpComponentContext;
2365 let endpoint = component
2366 .create_endpoint(
2367 &format!("{url}/error?throwExceptionOnFailure=false&allowPrivateIps=true"),
2368 &endpoint_ctx,
2369 )
2370 .unwrap();
2371 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2372
2373 let exchange = Exchange::new(Message::default());
2374 let result = producer.oneshot(exchange).await.unwrap();
2375
2376 let status = result
2377 .input
2378 .header("CamelHttpResponseCode")
2379 .and_then(|v| v.as_u64())
2380 .unwrap();
2381 assert_eq!(status, 500);
2382 }
2383
2384 #[tokio::test]
2385 async fn test_http_producer_uri_override() {
2386 use tower::ServiceExt;
2387
2388 let (url, _handle) = start_test_server().await;
2389 let ctx = test_producer_ctx();
2390
2391 let component = HttpComponent::new();
2392 let endpoint_ctx = NoOpComponentContext;
2393 let endpoint = component
2394 .create_endpoint(
2395 "http://localhost:1/does-not-exist?allowPrivateIps=true",
2396 &endpoint_ctx,
2397 )
2398 .unwrap();
2399 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2400
2401 let mut exchange = Exchange::new(Message::default());
2402 exchange.input.set_header(
2403 "CamelHttpUri",
2404 serde_json::Value::String(format!("{url}/api")),
2405 );
2406
2407 let result = producer.oneshot(exchange).await.unwrap();
2408 let status = result
2409 .input
2410 .header("CamelHttpResponseCode")
2411 .and_then(|v| v.as_u64())
2412 .unwrap();
2413 assert_eq!(status, 200);
2414 }
2415
2416 #[tokio::test]
2417 async fn test_http_producer_response_headers_mapped() {
2418 use tower::ServiceExt;
2419
2420 let (url, _handle) = start_test_server().await;
2421 let ctx = test_producer_ctx();
2422
2423 let component = HttpComponent::new();
2424 let endpoint_ctx = NoOpComponentContext;
2425 let endpoint = component
2426 .create_endpoint(&format!("{url}/api?allowPrivateIps=true"), &endpoint_ctx)
2427 .unwrap();
2428 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2429
2430 let exchange = Exchange::new(Message::default());
2431 let result = producer.oneshot(exchange).await.unwrap();
2432
2433 assert!(
2434 result.input.header("Content-Type").is_some(),
2435 "Response should have Content-Type header"
2436 );
2437 assert!(result.input.header("CamelHttpResponseText").is_some());
2438 }
2439
2440 async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
2445 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2446 let addr = listener.local_addr().unwrap();
2447 let url = format!("http://127.0.0.1:{}", addr.port());
2448
2449 let handle = tokio::spawn(async move {
2450 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2451 loop {
2452 if let Ok((mut stream, _)) = listener.accept().await {
2453 tokio::spawn(async move {
2454 let mut buf = vec![0u8; 4096];
2455 let n = stream.read(&mut buf).await.unwrap_or(0);
2456 let request = String::from_utf8_lossy(&buf[..n]).to_string();
2457
2458 if request.contains("GET /final") {
2460 let body = r#"{"status":"final"}"#;
2461 let response = format!(
2462 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2463 body.len(),
2464 body
2465 );
2466 let _ = stream.write_all(response.as_bytes()).await;
2467 } else {
2468 let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n";
2470 let _ = stream.write_all(response.as_bytes()).await;
2471 }
2472 });
2473 }
2474 }
2475 });
2476
2477 (url, handle)
2478 }
2479
2480 #[tokio::test]
2481 async fn test_follow_redirects_false_does_not_follow() {
2482 use tower::ServiceExt;
2483
2484 let (url, _handle) = start_redirect_server().await;
2485 let ctx = test_producer_ctx();
2486
2487 let component =
2488 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
2489 let endpoint_ctx = NoOpComponentContext;
2490 let endpoint = component
2491 .create_endpoint(
2492 &format!("{url}?throwExceptionOnFailure=false&allowPrivateIps=true"),
2493 &endpoint_ctx,
2494 )
2495 .unwrap();
2496 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2497
2498 let exchange = Exchange::new(Message::default());
2499 let result = producer.oneshot(exchange).await.unwrap();
2500
2501 let status = result
2503 .input
2504 .header("CamelHttpResponseCode")
2505 .and_then(|v| v.as_u64())
2506 .unwrap();
2507 assert_eq!(
2508 status, 302,
2509 "Should NOT follow redirect when followRedirects=false"
2510 );
2511 }
2512
2513 #[tokio::test]
2514 async fn test_follow_redirects_true_follows_redirect() {
2515 use tower::ServiceExt;
2516
2517 let (url, _handle) = start_redirect_server().await;
2518 let ctx = test_producer_ctx();
2519
2520 let component =
2521 HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
2522 let endpoint_ctx = NoOpComponentContext;
2523 let endpoint = component
2524 .create_endpoint(&format!("{url}?allowPrivateIps=true"), &endpoint_ctx)
2525 .unwrap();
2526 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2527
2528 let exchange = Exchange::new(Message::default());
2529 let result = producer.oneshot(exchange).await.unwrap();
2530
2531 let status = result
2533 .input
2534 .header("CamelHttpResponseCode")
2535 .and_then(|v| v.as_u64())
2536 .unwrap();
2537 assert_eq!(
2538 status, 200,
2539 "Should follow redirect when followRedirects=true"
2540 );
2541 }
2542
2543 #[tokio::test]
2544 async fn test_query_params_forwarded_to_http_request() {
2545 use tower::ServiceExt;
2546
2547 let (url, _handle) = start_test_server().await;
2548 let ctx = test_producer_ctx();
2549
2550 let component = HttpComponent::new();
2551 let endpoint_ctx = NoOpComponentContext;
2552 let endpoint = component
2554 .create_endpoint(
2555 &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowPrivateIps=true"),
2556 &endpoint_ctx,
2557 )
2558 .unwrap();
2559 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2560
2561 let exchange = Exchange::new(Message::default());
2562 let result = producer.oneshot(exchange).await.unwrap();
2563
2564 let status = result
2567 .input
2568 .header("CamelHttpResponseCode")
2569 .and_then(|v| v.as_u64())
2570 .unwrap();
2571 assert_eq!(status, 200);
2572 }
2573
2574 #[tokio::test]
2575 async fn test_non_camel_query_params_are_forwarded() {
2576 let config = HttpEndpointConfig::from_uri(
2579 "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
2580 )
2581 .unwrap();
2582
2583 assert!(
2585 config.query_params.contains_key("apiKey"),
2586 "apiKey should be preserved"
2587 );
2588 assert!(
2589 config.query_params.contains_key("token"),
2590 "token should be preserved"
2591 );
2592 assert_eq!(config.query_params.get("apiKey").unwrap(), "secret123");
2593 assert_eq!(config.query_params.get("token").unwrap(), "abc456");
2594
2595 assert!(
2597 !config.query_params.contains_key("httpMethod"),
2598 "httpMethod should not be forwarded"
2599 );
2600 }
2601
2602 #[test]
2603 fn test_query_params_are_url_encoded_when_resolving_url() {
2604 let config =
2605 HttpEndpointConfig::from_uri("http://example.com/api?q=hello world&tag=a+b").unwrap();
2606 let exchange = Exchange::new(Message::default());
2607
2608 let url = HttpProducer::resolve_url(&exchange, &config);
2609
2610 assert!(url.contains("q=hello+world"), "url was: {url}");
2611 assert!(url.contains("tag=a%2Bb"), "url was: {url}");
2612 }
2613
2614 async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
2619 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2620 let addr = listener.local_addr().unwrap();
2621 let url = format!("http://127.0.0.1:{}", addr.port());
2622
2623 let handle = tokio::spawn(async move {
2624 loop {
2625 if let Ok((mut stream, _)) = listener.accept().await {
2626 let delay = delay_ms;
2627 tokio::spawn(async move {
2628 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2629 let mut buf = vec![0u8; 4096];
2630 let _ = stream.read(&mut buf).await;
2631 let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
2633 let _ = stream.write_all(headers.as_bytes()).await;
2634 tokio::time::sleep(Duration::from_millis(delay)).await;
2636 let body = r#"{"status":"slow"}"#;
2637 let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
2638 let _ = stream.write_all(chunk.as_bytes()).await;
2639 });
2640 }
2641 }
2642 });
2643
2644 (url, handle)
2645 }
2646
2647 #[tokio::test]
2648 async fn test_http_producer_timeout() {
2649 use tower::ServiceExt;
2650
2651 let (url, _handle) = start_slow_server(500).await;
2653 let ctx = test_producer_ctx();
2654
2655 let component = HttpComponent::with_config(
2656 HttpConfig::default()
2657 .with_read_timeout_ms(100)
2658 .with_response_timeout_ms(30_000), );
2660 let endpoint_ctx = NoOpComponentContext;
2661 let endpoint = component
2662 .create_endpoint(&format!("{url}/slow?allowPrivateIps=true"), &endpoint_ctx)
2663 .unwrap();
2664 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2665
2666 let exchange = Exchange::new(Message::default());
2667 let result = producer.oneshot(exchange).await;
2668
2669 assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
2670 let err = result.unwrap_err().to_string();
2671 assert!(
2672 err.contains("Read timeout") || err.contains("timeout"),
2673 "Error should mention timeout, got: {}",
2674 err
2675 );
2676 }
2677
2678 #[tokio::test]
2679 async fn test_http_producer_no_timeout_when_fast() {
2680 use tower::ServiceExt;
2681
2682 let (url, _handle) = start_test_server().await;
2683 let ctx = test_producer_ctx();
2684
2685 let component =
2686 HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
2687 let endpoint_ctx = NoOpComponentContext;
2688 let endpoint = component
2689 .create_endpoint(&format!("{url}/api?allowPrivateIps=true"), &endpoint_ctx)
2690 .unwrap();
2691 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2692
2693 let exchange = Exchange::new(Message::default());
2694 let result = producer.oneshot(exchange).await.unwrap();
2695
2696 let status = result
2697 .input
2698 .header("CamelHttpResponseCode")
2699 .and_then(|v| v.as_u64())
2700 .unwrap();
2701 assert_eq!(status, 200);
2702 }
2703
2704 #[tokio::test]
2709 async fn test_http_producer_blocks_metadata_endpoint() {
2710 use tower::ServiceExt;
2711
2712 let ctx = test_producer_ctx();
2713 let component = HttpComponent::new();
2714 let endpoint_ctx = NoOpComponentContext;
2715 let endpoint = component
2716 .create_endpoint(
2717 "http://example.com/api?allowPrivateIps=false",
2718 &endpoint_ctx,
2719 )
2720 .unwrap();
2721 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2722
2723 let mut exchange = Exchange::new(Message::default());
2724 exchange.input.set_header(
2725 "CamelHttpUri",
2726 serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
2727 );
2728
2729 let result = producer.oneshot(exchange).await;
2730 assert!(result.is_err(), "Should block AWS metadata endpoint");
2731
2732 let err = result.unwrap_err();
2733 assert!(
2734 err.to_string().contains("Private IP"),
2735 "Error should mention private IP blocking, got: {}",
2736 err
2737 );
2738 }
2739
2740 #[test]
2741 fn test_ssrf_config_defaults() {
2742 let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
2743 assert!(
2744 !config.allow_private_ips,
2745 "Private IPs should be blocked by default"
2746 );
2747 assert!(
2748 config.blocked_hosts.is_empty(),
2749 "Blocked hosts should be empty by default"
2750 );
2751 }
2752
2753 #[test]
2754 fn test_ssrf_config_allow_private_ips() {
2755 let config =
2756 HttpEndpointConfig::from_uri("http://example.com/api?allowPrivateIps=true").unwrap();
2757 assert!(
2758 config.allow_private_ips,
2759 "Private IPs should be allowed when explicitly set"
2760 );
2761 }
2762
2763 #[test]
2764 fn test_ssrf_config_blocked_hosts() {
2765 let config = HttpEndpointConfig::from_uri(
2766 "http://example.com/api?blockedHosts=evil.com,malware.net",
2767 )
2768 .unwrap();
2769 assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
2770 }
2771
2772 #[tokio::test]
2773 async fn test_http_producer_blocks_localhost() {
2774 use tower::ServiceExt;
2775
2776 let ctx = test_producer_ctx();
2777 let component = HttpComponent::new();
2778 let endpoint_ctx = NoOpComponentContext;
2779 let endpoint = component
2780 .create_endpoint("http://example.com/api", &endpoint_ctx)
2781 .unwrap();
2782 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2783
2784 let mut exchange = Exchange::new(Message::default());
2785 exchange.input.set_header(
2786 "CamelHttpUri",
2787 serde_json::Value::String("http://localhost:8080/internal".to_string()),
2788 );
2789
2790 let result = producer.oneshot(exchange).await;
2791 assert!(result.is_err(), "Should block localhost");
2792 }
2793
2794 #[tokio::test]
2795 async fn test_http_producer_blocks_loopback_ip() {
2796 use tower::ServiceExt;
2797
2798 let ctx = test_producer_ctx();
2799 let component = HttpComponent::new();
2800 let endpoint_ctx = NoOpComponentContext;
2801 let endpoint = component
2802 .create_endpoint("http://example.com/api", &endpoint_ctx)
2803 .unwrap();
2804 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2805
2806 let mut exchange = Exchange::new(Message::default());
2807 exchange.input.set_header(
2808 "CamelHttpUri",
2809 serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
2810 );
2811
2812 let result = producer.oneshot(exchange).await;
2813 assert!(result.is_err(), "Should block loopback IP");
2814 }
2815
2816 #[tokio::test]
2817 async fn test_http_producer_allows_private_ip_when_enabled() {
2818 use tower::ServiceExt;
2819
2820 let ctx = test_producer_ctx();
2821 let component = HttpComponent::new();
2822 let endpoint_ctx = NoOpComponentContext;
2823 let endpoint = component
2826 .create_endpoint("http://192.168.1.1/api?allowPrivateIps=true", &endpoint_ctx)
2827 .unwrap();
2828 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2829
2830 let exchange = Exchange::new(Message::default());
2831
2832 let result = producer.oneshot(exchange).await;
2835 if let Err(ref e) = result {
2837 let err_str = e.to_string();
2838 assert!(
2839 !err_str.contains("Private IP") && !err_str.contains("not allowed"),
2840 "Should not be SSRF error, got: {}",
2841 err_str
2842 );
2843 }
2844 }
2845
2846 #[test]
2851 fn test_http_server_config_parse() {
2852 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
2853 assert_eq!(cfg.host, "0.0.0.0");
2854 assert_eq!(cfg.port, 8080);
2855 assert_eq!(cfg.path, "/orders");
2856 assert_eq!(cfg.max_inflight_requests, 1024);
2857 }
2858
2859 #[test]
2860 fn test_http_server_config_scheme() {
2861 assert_eq!(HttpServerConfig::scheme(), "http");
2863 }
2864
2865 #[test]
2866 fn test_http_server_config_from_components() {
2867 let components = camel_component_api::UriComponents {
2869 scheme: "https".to_string(),
2870 path: "//0.0.0.0:8443/api".to_string(),
2871 params: std::collections::HashMap::from([
2872 ("maxRequestBody".to_string(), "5242880".to_string()),
2873 ("maxInflightRequests".to_string(), "7".to_string()),
2874 ]),
2875 };
2876 let cfg = HttpServerConfig::from_components(components).unwrap();
2877 assert_eq!(cfg.host, "0.0.0.0");
2878 assert_eq!(cfg.port, 8443);
2879 assert_eq!(cfg.path, "/api");
2880 assert_eq!(cfg.max_request_body, 5242880);
2881 assert_eq!(cfg.max_inflight_requests, 7);
2882 }
2883
2884 #[test]
2885 fn test_http_server_config_default_path() {
2886 let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
2887 assert_eq!(cfg.path, "/");
2888 }
2889
2890 #[test]
2891 fn test_http_server_config_wrong_scheme() {
2892 assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
2893 }
2894
2895 #[test]
2896 fn test_http_server_config_invalid_port() {
2897 assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
2898 }
2899
2900 #[test]
2901 fn test_http_server_config_default_port_by_scheme() {
2902 let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
2904 assert_eq!(cfg_http.port, 80);
2905
2906 let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
2908 assert_eq!(cfg_https.port, 443);
2909 }
2910
2911 #[test]
2912 fn test_request_envelope_and_reply_are_send() {
2913 fn assert_send<T: Send>() {}
2914 assert_send::<RequestEnvelope>();
2915 assert_send::<HttpReply>();
2916 }
2917
2918 #[test]
2923 fn test_server_registry_global_is_singleton() {
2924 let r1 = ServerRegistry::global();
2925 let r2 = ServerRegistry::global();
2926 assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
2927 }
2928
2929 #[allow(clippy::await_holding_lock)]
2930 #[tokio::test]
2931 async fn test_concurrent_get_or_spawn_returns_same_registry() {
2932 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
2933 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2934 let port = listener.local_addr().unwrap().port();
2935 drop(listener);
2936
2937 let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
2938 Arc::new(std::sync::Mutex::new(Vec::new()));
2939
2940 let mut handles = Vec::new();
2941 for _ in 0..4 {
2942 let results = results.clone();
2943 handles.push(tokio::spawn(async move {
2944 let registry = ServerRegistry::global()
2945 .get_or_spawn(
2946 "127.0.0.1",
2947 port,
2948 2 * 1024 * 1024,
2949 10 * 1024 * 1024,
2950 1024,
2951 test_rt(),
2952 "test-route".into(),
2953 )
2954 .await
2955 .unwrap();
2956 results.lock().unwrap().push(registry);
2957 }));
2958 }
2959
2960 for h in handles {
2961 h.await.unwrap();
2962 }
2963
2964 let registries = results.lock().unwrap();
2965 assert_eq!(registries.len(), 4);
2966 for i in 1..registries.len() {
2967 assert!(
2968 Arc::ptr_eq(®istries[0].inner, ®istries[i].inner),
2969 "all concurrent callers should get same route registry"
2970 );
2971 }
2972 }
2973
2974 #[test]
2975 fn test_server_registry_distinguishes_host_and_port() {
2976 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
2977 let rt = tokio::runtime::Runtime::new().expect("runtime");
2978 rt.block_on(async {
2979 let registry = ServerRegistry::global();
2980 let d1 = registry
2984 .get_or_spawn(
2985 "127.0.0.1",
2986 0,
2987 1024 * 1024,
2988 10 * 1024 * 1024,
2989 1024,
2990 test_rt(),
2991 "test-route-1".into(),
2992 )
2993 .await;
2994 let d2 = registry
2995 .get_or_spawn(
2996 "0.0.0.0",
2997 0,
2998 1024 * 1024,
2999 10 * 1024 * 1024,
3000 1024,
3001 test_rt(),
3002 "test-route-2".into(),
3003 )
3004 .await;
3005 assert!(d1.is_ok());
3006 assert!(d2.is_ok());
3007 assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
3008 });
3009 }
3010
3011 #[allow(clippy::await_holding_lock)]
3012 #[tokio::test]
3013 async fn test_shared_server_max_request_body_policy_is_deterministic() {
3014 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3015 let registry = ServerRegistry::global();
3016 let d1 = registry
3018 .get_or_spawn(
3019 "127.0.0.1",
3020 9991,
3021 1024 * 1024,
3022 10 * 1024 * 1024,
3023 1024,
3024 test_rt(),
3025 "test-route".into(),
3026 )
3027 .await;
3028 assert!(d1.is_ok());
3029
3030 let d2 = registry
3033 .get_or_spawn(
3034 "127.0.0.1",
3035 9991,
3036 2 * 1024 * 1024,
3037 10 * 1024 * 1024,
3038 1024,
3039 test_rt(),
3040 "test-route-2".into(),
3041 )
3042 .await;
3043 assert!(d2.is_err());
3044 let err = d2.unwrap_err();
3045 assert!(
3046 err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
3047 "Expected incompatible maxRequestBody error, got: {}",
3048 err
3049 );
3050 }
3051
3052 #[test]
3053 fn test_server_registry_reset_clears_entries() {
3054 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3055 let rt = tokio::runtime::Runtime::new().expect("runtime");
3056 rt.block_on(async {
3057 let d1 = ServerRegistry::global()
3059 .get_or_spawn(
3060 "127.0.0.1",
3061 9992,
3062 1024 * 1024,
3063 10 * 1024 * 1024,
3064 1024,
3065 test_rt(),
3066 "test-route".into(),
3067 )
3068 .await;
3069 assert!(d1.is_ok());
3070
3071 let guard = ServerRegistry::global().inner.lock().expect("lock");
3073 assert!(guard.contains_key(&("127.0.0.1".to_string(), 9992)));
3074 drop(guard);
3075
3076 ServerRegistry::reset();
3078
3079 let guard = ServerRegistry::global().inner.lock().expect("lock");
3081 assert!(
3082 guard.is_empty(),
3083 "registry should be empty after reset, has {} entries",
3084 guard.len()
3085 );
3086 });
3087 }
3088
3089 #[tokio::test]
3094 async fn test_dispatch_handler_returns_404_for_unknown_path() {
3095 let registry = HttpRouteRegistry::new();
3096 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3098 let port = listener.local_addr().unwrap().port();
3099 tokio::spawn(run_axum_server(
3100 listener,
3101 registry,
3102 2 * 1024 * 1024,
3103 10 * 1024 * 1024,
3104 Arc::new(tokio::sync::Semaphore::new(1024)),
3105 test_rt(),
3106 "test-route".into(),
3107 ));
3108
3109 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3111
3112 let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
3113 .await
3114 .unwrap();
3115 assert_eq!(resp.status().as_u16(), 404);
3116 }
3117
3118 #[tokio::test]
3123 async fn test_http_consumer_start_registers_path() {
3124 use camel_component_api::ConsumerContext;
3125
3126 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3128 let port = listener.local_addr().unwrap().port();
3129 drop(listener); let consumer_cfg = HttpServerConfig {
3132 host: "127.0.0.1".to_string(),
3133 port,
3134 path: "/ping".to_string(),
3135 max_request_body: 2 * 1024 * 1024,
3136 max_response_body: 10 * 1024 * 1024,
3137 max_inflight_requests: 1024,
3138 };
3139 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3140
3141 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
3142 let token = tokio_util::sync::CancellationToken::new();
3143 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3144
3145 tokio::spawn(async move {
3146 consumer.start(ctx).await.unwrap();
3147 });
3148
3149 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3150
3151 let client = reqwest::Client::new();
3152 let resp_future = client
3153 .post(format!("http://127.0.0.1:{port}/ping"))
3154 .body("hello world")
3155 .send();
3156
3157 let (http_result, _) = tokio::join!(resp_future, async {
3158 if let Some(mut envelope) = rx.recv().await {
3159 envelope.exchange.input.set_header(
3161 "CamelHttpResponseCode",
3162 serde_json::Value::Number(201.into()),
3163 );
3164 if let Some(reply_tx) = envelope.reply_tx {
3165 let _ = reply_tx.send(Ok(envelope.exchange));
3166 }
3167 }
3168 });
3169
3170 let resp = http_result.unwrap();
3171 assert_eq!(resp.status().as_u16(), 201);
3172
3173 token.cancel();
3174 }
3175
3176 #[tokio::test]
3177 async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
3178 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3179
3180 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3181 let port = listener.local_addr().unwrap().port();
3182 drop(listener);
3183
3184 let consumer_cfg = HttpServerConfig {
3185 host: "127.0.0.1".to_string(),
3186 port,
3187 path: "/saturation".to_string(),
3188 max_request_body: 2 * 1024 * 1024,
3189 max_response_body: 10 * 1024 * 1024,
3190 max_inflight_requests: 1,
3191 };
3192 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3193
3194 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3195 let token = tokio_util::sync::CancellationToken::new();
3196 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3197 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3198 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3199
3200 let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
3201 let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
3202
3203 tokio::spawn(async move {
3204 let mut first_seen_tx = Some(first_seen_tx);
3205 let mut unblock_first_rx = Some(unblock_first_rx);
3206
3207 while let Some(envelope) = rx.recv().await {
3208 if let Some(tx) = first_seen_tx.take() {
3209 let _ = tx.send(());
3210 if let Some(rx_unblock) = unblock_first_rx.take() {
3211 let _ = rx_unblock.await;
3212 }
3213 }
3214
3215 if let Some(reply_tx) = envelope.reply_tx {
3216 let _ = reply_tx.send(Ok(envelope.exchange));
3217 }
3218 }
3219 });
3220
3221 let client = reqwest::Client::new();
3222 let first_req = {
3223 let client = client.clone();
3224 async move {
3225 client
3226 .get(format!("http://127.0.0.1:{port}/saturation"))
3227 .send()
3228 .await
3229 .unwrap()
3230 }
3231 };
3232
3233 let first_handle = tokio::spawn(first_req);
3234 first_seen_rx.await.unwrap();
3235
3236 let second_resp = client
3237 .get(format!("http://127.0.0.1:{port}/saturation"))
3238 .send()
3239 .await
3240 .unwrap();
3241
3242 assert_eq!(second_resp.status().as_u16(), 503);
3243
3244 let _ = unblock_first_tx.send(());
3245 let first_resp = first_handle.await.unwrap();
3246 assert_eq!(first_resp.status().as_u16(), 200);
3247
3248 token.cancel();
3249 }
3250
3251 #[tokio::test]
3252 async fn test_http_consumer_enforces_max_response_body_for_bytes() {
3253 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3254
3255 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3256
3257 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3258 let port = listener.local_addr().unwrap().port();
3259 drop(listener);
3260
3261 let consumer_cfg = HttpServerConfig {
3262 host: "127.0.0.1".to_string(),
3263 port,
3264 path: "/limit-bytes".to_string(),
3265 max_request_body: 2 * 1024 * 1024,
3266 max_response_body: 16,
3267 max_inflight_requests: 1024,
3268 };
3269 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3270
3271 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3272 let token = tokio_util::sync::CancellationToken::new();
3273 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3274 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3275 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3276
3277 let client = reqwest::Client::new();
3278 let send_fut = client
3279 .get(format!("http://127.0.0.1:{port}/limit-bytes"))
3280 .send();
3281
3282 let (http_result, _) = tokio::join!(send_fut, async {
3283 if let Some(mut envelope) = rx.recv().await {
3284 envelope.exchange.input.body =
3285 camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
3286 if let Some(reply_tx) = envelope.reply_tx {
3287 let _ = reply_tx.send(Ok(envelope.exchange));
3288 }
3289 }
3290 });
3291
3292 let resp = http_result.unwrap();
3293 assert_eq!(resp.status().as_u16(), 500);
3294 let body = resp.text().await.unwrap();
3295 assert_eq!(body, "Response body exceeds configured limit");
3296 token.cancel();
3297 }
3298
3299 #[tokio::test]
3300 async fn test_http_consumer_enforces_max_response_body_for_json() {
3301 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3302
3303 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3304
3305 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3306 let port = listener.local_addr().unwrap().port();
3307 drop(listener);
3308
3309 let consumer_cfg = HttpServerConfig {
3310 host: "127.0.0.1".to_string(),
3311 port,
3312 path: "/limit-json".to_string(),
3313 max_request_body: 2 * 1024 * 1024,
3314 max_response_body: 16,
3315 max_inflight_requests: 1024,
3316 };
3317 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3318
3319 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3320 let token = tokio_util::sync::CancellationToken::new();
3321 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3322 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3323 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3324
3325 let client = reqwest::Client::new();
3326 let send_fut = client
3327 .get(format!("http://127.0.0.1:{port}/limit-json"))
3328 .send();
3329
3330 let (http_result, _) = tokio::join!(send_fut, async {
3331 if let Some(mut envelope) = rx.recv().await {
3332 envelope.exchange.input.body = camel_component_api::Body::Json(
3333 serde_json::json!({"message":"this response is bigger than sixteen"}),
3334 );
3335 if let Some(reply_tx) = envelope.reply_tx {
3336 let _ = reply_tx.send(Ok(envelope.exchange));
3337 }
3338 }
3339 });
3340
3341 let resp = http_result.unwrap();
3342 assert_eq!(resp.status().as_u16(), 500);
3343 let body = resp.text().await.unwrap();
3344 assert_eq!(body, "Response body exceeds configured limit");
3345 token.cancel();
3346 }
3347
3348 #[tokio::test]
3349 async fn test_http_consumer_enforces_max_response_body_for_xml() {
3350 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3351
3352 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3353
3354 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3355 let port = listener.local_addr().unwrap().port();
3356 drop(listener);
3357
3358 let consumer_cfg = HttpServerConfig {
3359 host: "127.0.0.1".to_string(),
3360 port,
3361 path: "/limit-xml".to_string(),
3362 max_request_body: 2 * 1024 * 1024,
3363 max_response_body: 16,
3364 max_inflight_requests: 1024,
3365 };
3366 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3367
3368 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3369 let token = tokio_util::sync::CancellationToken::new();
3370 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3371 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3372 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3373
3374 let client = reqwest::Client::new();
3375 let send_fut = client
3376 .get(format!("http://127.0.0.1:{port}/limit-xml"))
3377 .send();
3378
3379 let (http_result, _) = tokio::join!(send_fut, async {
3380 if let Some(mut envelope) = rx.recv().await {
3381 envelope.exchange.input.body = camel_component_api::Body::Xml(
3382 "<root><value>way-too-large</value></root>".into(),
3383 );
3384 if let Some(reply_tx) = envelope.reply_tx {
3385 let _ = reply_tx.send(Ok(envelope.exchange));
3386 }
3387 }
3388 });
3389
3390 let resp = http_result.unwrap();
3391 assert_eq!(resp.status().as_u16(), 500);
3392 let body = resp.text().await.unwrap();
3393 assert_eq!(body, "Response body exceeds configured limit");
3394 token.cancel();
3395 }
3396
3397 #[tokio::test]
3398 async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
3399 use camel_component_api::{
3400 CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
3401 };
3402 use futures::stream;
3403
3404 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3405
3406 let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
3407 let port = listener.local_addr().unwrap().port();
3408 drop(listener);
3409
3410 let consumer_cfg = HttpServerConfig {
3411 host: "0.0.0.0".to_string(),
3412 port,
3413 path: "/limit-stream".to_string(),
3414 max_request_body: 2 * 1024 * 1024,
3415 max_response_body: 16,
3416 max_inflight_requests: 1024,
3417 };
3418 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3419
3420 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3421 let token = tokio_util::sync::CancellationToken::new();
3422 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3423 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3424 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3425
3426 let client = reqwest::Client::new();
3427 let send_fut = client
3428 .get(format!("http://127.0.0.1:{port}/limit-stream"))
3429 .send();
3430
3431 let (http_result, _) = tokio::join!(send_fut, async {
3432 if let Some(mut envelope) = rx.recv().await {
3433 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
3434 vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
3435 let stream = Box::pin(stream::iter(chunks));
3436 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
3437 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
3438 metadata: StreamMetadata {
3439 size_hint: Some(32),
3440 content_type: Some("application/octet-stream".into()),
3441 origin: None,
3442 },
3443 });
3444 if let Some(reply_tx) = envelope.reply_tx {
3445 let _ = reply_tx.send(Ok(envelope.exchange));
3446 }
3447 }
3448 });
3449
3450 let resp = http_result.unwrap();
3451 assert_eq!(resp.status().as_u16(), 200);
3452 let body = resp.bytes().await.unwrap();
3453 assert_eq!(body.len(), 32);
3454 token.cancel();
3455 }
3456
3457 #[tokio::test]
3462 async fn test_integration_single_consumer_round_trip() {
3463 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3464
3465 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3467 let port = listener.local_addr().unwrap().port();
3468 drop(listener); let component = HttpComponent::new();
3471 let endpoint_ctx = NoOpComponentContext;
3472 let endpoint = component
3473 .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
3474 .unwrap();
3475 let mut consumer = endpoint.create_consumer(rt()).unwrap();
3476
3477 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3478 let token = tokio_util::sync::CancellationToken::new();
3479 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3480
3481 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3482 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3483
3484 let client = reqwest::Client::new();
3485 let send_fut = client
3486 .post(format!("http://127.0.0.1:{port}/echo"))
3487 .header("Content-Type", "text/plain")
3488 .body("ping")
3489 .send();
3490
3491 let (http_result, _) = tokio::join!(send_fut, async {
3492 if let Some(mut envelope) = rx.recv().await {
3493 assert_eq!(
3494 envelope.exchange.input.header("CamelHttpMethod"),
3495 Some(&serde_json::Value::String("POST".into()))
3496 );
3497 assert_eq!(
3498 envelope.exchange.input.header("CamelHttpPath"),
3499 Some(&serde_json::Value::String("/echo".into()))
3500 );
3501 envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
3502 if let Some(reply_tx) = envelope.reply_tx {
3503 let _ = reply_tx.send(Ok(envelope.exchange));
3504 }
3505 }
3506 });
3507
3508 let resp = http_result.unwrap();
3509 assert_eq!(resp.status().as_u16(), 200);
3510 let body = resp.text().await.unwrap();
3511 assert_eq!(body, "pong");
3512
3513 token.cancel();
3514 }
3515
3516 #[tokio::test]
3517 async fn test_integration_two_consumers_shared_port() {
3518 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3519
3520 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3521
3522 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3524 let port = listener.local_addr().unwrap().port();
3525 drop(listener);
3526
3527 let component = HttpComponent::new();
3528 let endpoint_ctx = NoOpComponentContext;
3529
3530 let endpoint_a = component
3532 .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
3533 .unwrap();
3534 let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
3535
3536 let endpoint_b = component
3538 .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
3539 .unwrap();
3540 let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
3541
3542 let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3543 let token_a = tokio_util::sync::CancellationToken::new();
3544 let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
3545
3546 let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3547 let token_b = tokio_util::sync::CancellationToken::new();
3548 let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
3549
3550 tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
3551 tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
3552 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3553
3554 let client = reqwest::Client::new();
3555
3556 let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
3558 let (resp_hello, _) = tokio::join!(fut_hello, async {
3559 if let Some(mut envelope) = rx_a.recv().await {
3560 envelope.exchange.input.body =
3561 camel_component_api::Body::Text("hello-response".to_string());
3562 if let Some(reply_tx) = envelope.reply_tx {
3563 let _ = reply_tx.send(Ok(envelope.exchange));
3564 }
3565 }
3566 });
3567
3568 let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
3570 let (resp_world, _) = tokio::join!(fut_world, async {
3571 if let Some(mut envelope) = rx_b.recv().await {
3572 envelope.exchange.input.body =
3573 camel_component_api::Body::Text("world-response".to_string());
3574 if let Some(reply_tx) = envelope.reply_tx {
3575 let _ = reply_tx.send(Ok(envelope.exchange));
3576 }
3577 }
3578 });
3579
3580 let body_a = resp_hello.unwrap().text().await.unwrap();
3581 let body_b = resp_world.unwrap().text().await.unwrap();
3582
3583 assert_eq!(body_a, "hello-response");
3584 assert_eq!(body_b, "world-response");
3585
3586 token_a.cancel();
3587 token_b.cancel();
3588 }
3589
3590 #[tokio::test]
3591 async fn test_integration_unregistered_path_returns_404() {
3592 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3593
3594 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3596 let port = listener.local_addr().unwrap().port();
3597 drop(listener);
3598
3599 let component = HttpComponent::new();
3600 let endpoint_ctx = NoOpComponentContext;
3601 let endpoint = component
3602 .create_endpoint(
3603 &format!("http://127.0.0.1:{port}/registered"),
3604 &endpoint_ctx,
3605 )
3606 .unwrap();
3607 let mut consumer = endpoint.create_consumer(rt()).unwrap();
3608
3609 let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3610 let token = tokio_util::sync::CancellationToken::new();
3611 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3612
3613 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3614
3615 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3617 loop {
3618 if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
3619 .await
3620 .is_ok()
3621 {
3622 break;
3623 }
3624 if std::time::Instant::now() >= deadline {
3625 panic!("HTTP server did not start within 5s on port {port}");
3626 }
3627 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3628 }
3629
3630 let client = reqwest::Client::new();
3631 let resp = client
3632 .get(format!("http://127.0.0.1:{port}/not-there"))
3633 .send()
3634 .await
3635 .unwrap();
3636 assert_eq!(resp.status().as_u16(), 404);
3637
3638 token.cancel();
3639 }
3640
3641 #[test]
3642 fn test_http_consumer_declares_concurrent() {
3643 use camel_component_api::ConcurrencyModel;
3644
3645 let config = HttpServerConfig {
3646 host: "127.0.0.1".to_string(),
3647 port: 19999,
3648 path: "/test".to_string(),
3649 max_request_body: 2 * 1024 * 1024,
3650 max_response_body: 10 * 1024 * 1024,
3651 max_inflight_requests: 1024,
3652 };
3653 let consumer = HttpConsumer::new(config, test_rt());
3654 assert_eq!(
3655 consumer.concurrency_model(),
3656 ConcurrencyModel::Concurrent { max: None }
3657 );
3658 }
3659
3660 #[tokio::test]
3665 async fn test_http_reply_body_stream_variant_exists() {
3666 use bytes::Bytes;
3667 use camel_component_api::CamelError;
3668 use futures::stream;
3669
3670 let chunks: Vec<Result<Bytes, CamelError>> =
3671 vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
3672 let stream = Box::pin(stream::iter(chunks));
3673 let reply_body = HttpReplyBody::Stream(stream);
3674 match reply_body {
3676 HttpReplyBody::Stream(_) => {}
3677 HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
3678 }
3679 }
3680
3681 #[cfg(feature = "otel")]
3686 mod otel_tests {
3687 use super::*;
3688 use camel_component_api::Message;
3689 use tower::ServiceExt;
3690
3691 #[tokio::test]
3692 async fn test_producer_injects_traceparent_header() {
3693 let (url, _handle) = start_test_server_with_header_capture().await;
3694 let ctx = test_producer_ctx();
3695
3696 let component = HttpComponent::new();
3697 let endpoint_ctx = NoOpComponentContext;
3698 let endpoint = component
3699 .create_endpoint(&format!("{url}/api?allowPrivateIps=true"), &endpoint_ctx)
3700 .unwrap();
3701 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3702
3703 let mut exchange = Exchange::new(Message::default());
3705 let mut headers = std::collections::HashMap::new();
3706 headers.insert(
3707 "traceparent".to_string(),
3708 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
3709 );
3710 camel_otel::extract_into_exchange(&mut exchange, &headers);
3711
3712 let result = producer.oneshot(exchange).await.unwrap();
3713
3714 let status = result
3716 .input
3717 .header("CamelHttpResponseCode")
3718 .and_then(|v| v.as_u64())
3719 .unwrap();
3720 assert_eq!(status, 200);
3721
3722 let traceparent = result.input.header("X-Received-Traceparent");
3724 assert!(
3725 traceparent.is_some(),
3726 "traceparent header should have been sent"
3727 );
3728
3729 let traceparent_str = traceparent.unwrap().as_str().unwrap();
3730 let parts: Vec<&str> = traceparent_str.split('-').collect();
3732 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
3733 assert_eq!(parts[0], "00", "version should be 00");
3734 assert_eq!(
3735 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
3736 "trace-id should match"
3737 );
3738 assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
3739 assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
3740 }
3741
3742 #[tokio::test]
3743 async fn test_consumer_extracts_traceparent_header() {
3744 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3745
3746 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3748 let port = listener.local_addr().unwrap().port();
3749 drop(listener);
3750
3751 let component = HttpComponent::new();
3752 let endpoint_ctx = NoOpComponentContext;
3753 let endpoint = component
3754 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
3755 .unwrap();
3756 let mut consumer = endpoint.create_consumer(rt()).unwrap();
3757
3758 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3759 let token = tokio_util::sync::CancellationToken::new();
3760 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3761
3762 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3763 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3764
3765 let client = reqwest::Client::new();
3767 let send_fut = client
3768 .post(format!("http://127.0.0.1:{port}/trace"))
3769 .header(
3770 "traceparent",
3771 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
3772 )
3773 .body("test")
3774 .send();
3775
3776 let (http_result, _) = tokio::join!(send_fut, async {
3777 if let Some(envelope) = rx.recv().await {
3778 let mut injected_headers = std::collections::HashMap::new();
3781 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
3782
3783 assert!(
3784 injected_headers.contains_key("traceparent"),
3785 "Exchange should have traceparent after extraction"
3786 );
3787
3788 let traceparent = injected_headers.get("traceparent").unwrap();
3789 let parts: Vec<&str> = traceparent.split('-').collect();
3790 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
3791 assert_eq!(
3792 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
3793 "Trace ID should match the original traceparent header"
3794 );
3795
3796 if let Some(reply_tx) = envelope.reply_tx {
3797 let _ = reply_tx.send(Ok(envelope.exchange));
3798 }
3799 }
3800 });
3801
3802 let resp = http_result.unwrap();
3803 assert_eq!(resp.status().as_u16(), 200);
3804
3805 token.cancel();
3806 }
3807
3808 #[tokio::test]
3809 async fn test_consumer_extracts_mixed_case_traceparent_header() {
3810 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3811
3812 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3814 let port = listener.local_addr().unwrap().port();
3815 drop(listener);
3816
3817 let component = HttpComponent::new();
3818 let endpoint_ctx = NoOpComponentContext;
3819 let endpoint = component
3820 .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
3821 .unwrap();
3822 let mut consumer = endpoint.create_consumer(rt()).unwrap();
3823
3824 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3825 let token = tokio_util::sync::CancellationToken::new();
3826 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3827
3828 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3829 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3830
3831 let client = reqwest::Client::new();
3833 let send_fut = client
3834 .post(format!("http://127.0.0.1:{port}/trace"))
3835 .header(
3836 "TraceParent",
3837 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
3838 )
3839 .body("test")
3840 .send();
3841
3842 let (http_result, _) = tokio::join!(send_fut, async {
3843 if let Some(envelope) = rx.recv().await {
3844 let mut injected_headers = HashMap::new();
3847 camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
3848
3849 assert!(
3850 injected_headers.contains_key("traceparent"),
3851 "Exchange should have traceparent after extraction from mixed-case header"
3852 );
3853
3854 let traceparent = injected_headers.get("traceparent").unwrap();
3855 let parts: Vec<&str> = traceparent.split('-').collect();
3856 assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
3857 assert_eq!(
3858 parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
3859 "Trace ID should match the original mixed-case TraceParent header"
3860 );
3861
3862 if let Some(reply_tx) = envelope.reply_tx {
3863 let _ = reply_tx.send(Ok(envelope.exchange));
3864 }
3865 }
3866 });
3867
3868 let resp = http_result.unwrap();
3869 assert_eq!(resp.status().as_u16(), 200);
3870
3871 token.cancel();
3872 }
3873
3874 #[tokio::test]
3875 async fn test_producer_no_trace_context_no_crash() {
3876 let (url, _handle) = start_test_server().await;
3877 let ctx = test_producer_ctx();
3878
3879 let component = HttpComponent::new();
3880 let endpoint_ctx = NoOpComponentContext;
3881 let endpoint = component
3882 .create_endpoint(&format!("{url}/api?allowPrivateIps=true"), &endpoint_ctx)
3883 .unwrap();
3884 let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3885
3886 let exchange = Exchange::new(Message::default());
3888
3889 let result = producer.oneshot(exchange).await.unwrap();
3891
3892 let status = result
3894 .input
3895 .header("CamelHttpResponseCode")
3896 .and_then(|v| v.as_u64())
3897 .unwrap();
3898 assert_eq!(status, 200);
3899 }
3900
3901 async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
3903 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3904 let addr = listener.local_addr().unwrap();
3905 let url = format!("http://127.0.0.1:{}", addr.port());
3906
3907 let handle = tokio::spawn(async move {
3908 loop {
3909 if let Ok((mut stream, _)) = listener.accept().await {
3910 tokio::spawn(async move {
3911 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3912 let mut buf = vec![0u8; 8192];
3913 let n = stream.read(&mut buf).await.unwrap_or(0);
3914 let request = String::from_utf8_lossy(&buf[..n]).to_string();
3915
3916 let traceparent = request
3918 .lines()
3919 .find(|line| line.to_lowercase().starts_with("traceparent:"))
3920 .map(|line| {
3921 line.split(':')
3922 .nth(1)
3923 .map(|s| s.trim().to_string())
3924 .unwrap_or_default()
3925 })
3926 .unwrap_or_default();
3927
3928 let body =
3929 format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
3930 let response = format!(
3931 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
3932 body.len(),
3933 traceparent,
3934 body
3935 );
3936 let _ = stream.write_all(response.as_bytes()).await;
3937 });
3938 }
3939 }
3940 });
3941
3942 (url, handle)
3943 }
3944 }
3945
3946 #[tokio::test]
3955 async fn test_request_body_arrives_as_stream() {
3956 use camel_component_api::Body;
3957 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3958
3959 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3960 let port = listener.local_addr().unwrap().port();
3961 drop(listener);
3962
3963 let component = HttpComponent::new();
3964 let endpoint_ctx = NoOpComponentContext;
3965 let endpoint = component
3966 .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
3967 .unwrap();
3968 let mut consumer = endpoint.create_consumer(rt()).unwrap();
3969
3970 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3971 let token = tokio_util::sync::CancellationToken::new();
3972 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3973
3974 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3975 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3976
3977 let client = reqwest::Client::new();
3978 let send_fut = client
3979 .post(format!("http://127.0.0.1:{port}/upload"))
3980 .body("hello streaming world")
3981 .send();
3982
3983 let (http_result, _) = tokio::join!(send_fut, async {
3984 if let Some(mut envelope) = rx.recv().await {
3985 assert!(
3987 matches!(envelope.exchange.input.body, Body::Stream(_)),
3988 "expected Body::Stream, got discriminant {:?}",
3989 std::mem::discriminant(&envelope.exchange.input.body)
3990 );
3991 let bytes = envelope
3993 .exchange
3994 .input
3995 .body
3996 .into_bytes(1024 * 1024)
3997 .await
3998 .unwrap();
3999 assert_eq!(&bytes[..], b"hello streaming world");
4000
4001 envelope.exchange.input.body = camel_component_api::Body::Empty;
4002 if let Some(reply_tx) = envelope.reply_tx {
4003 let _ = reply_tx.send(Ok(envelope.exchange));
4004 }
4005 }
4006 });
4007
4008 let resp = http_result.unwrap();
4009 assert_eq!(resp.status().as_u16(), 200);
4010
4011 token.cancel();
4012 }
4013
4014 #[tokio::test]
4019 async fn test_streaming_response_chunked() {
4020 use bytes::Bytes;
4021 use camel_component_api::Body;
4022 use camel_component_api::CamelError;
4023 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4024 use camel_component_api::{StreamBody, StreamMetadata};
4025 use futures::stream;
4026 use std::sync::Arc;
4027 use tokio::sync::Mutex;
4028
4029 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4030 let port = listener.local_addr().unwrap().port();
4031 drop(listener);
4032
4033 let component = HttpComponent::new();
4034 let endpoint_ctx = NoOpComponentContext;
4035 let endpoint = component
4036 .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
4037 .unwrap();
4038 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4039
4040 let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4041 let token = tokio_util::sync::CancellationToken::new();
4042 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4043
4044 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4045 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4046
4047 let client = reqwest::Client::new();
4048 let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
4049
4050 let (http_result, _) = tokio::join!(send_fut, async {
4051 if let Some(mut envelope) = rx.recv().await {
4052 let chunks: Vec<Result<Bytes, CamelError>> =
4054 vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
4055 let stream = Box::pin(stream::iter(chunks));
4056 envelope.exchange.input.body = Body::Stream(StreamBody {
4057 stream: Arc::new(Mutex::new(Some(stream))),
4058 metadata: StreamMetadata::default(),
4059 });
4060 if let Some(reply_tx) = envelope.reply_tx {
4061 let _ = reply_tx.send(Ok(envelope.exchange));
4062 }
4063 }
4064 });
4065
4066 let resp = http_result.unwrap();
4067 assert_eq!(resp.status().as_u16(), 200);
4068 let body = resp.text().await.unwrap();
4069 assert_eq!(body, "chunk1chunk2");
4070
4071 token.cancel();
4072 }
4073
4074 #[tokio::test]
4079 async fn test_413_when_content_length_exceeds_limit() {
4080 use camel_component_api::ConsumerContext;
4081
4082 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4083 let port = listener.local_addr().unwrap().port();
4084 drop(listener);
4085
4086 let component = HttpComponent::new();
4088 let endpoint_ctx = NoOpComponentContext;
4089 let endpoint = component
4090 .create_endpoint(
4091 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
4092 &endpoint_ctx,
4093 )
4094 .unwrap();
4095 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4096
4097 let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4098 let token = tokio_util::sync::CancellationToken::new();
4099 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4100
4101 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4102 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4103
4104 let client = reqwest::Client::new();
4105 let resp = client
4106 .post(format!("http://127.0.0.1:{port}/upload"))
4107 .header("Content-Length", "1000") .body("x".repeat(1000))
4109 .send()
4110 .await
4111 .unwrap();
4112
4113 assert_eq!(resp.status().as_u16(), 413);
4114
4115 token.cancel();
4116 }
4117
4118 #[tokio::test]
4122 async fn test_chunked_upload_without_content_length_bypasses_limit() {
4123 use bytes::Bytes;
4124 use camel_component_api::Body;
4125 use camel_component_api::ConsumerContext;
4126 use futures::stream;
4127
4128 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4129 let port = listener.local_addr().unwrap().port();
4130 drop(listener);
4131
4132 let component = HttpComponent::new();
4134 let endpoint_ctx = NoOpComponentContext;
4135 let endpoint = component
4136 .create_endpoint(
4137 &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
4138 &endpoint_ctx,
4139 )
4140 .unwrap();
4141 let mut consumer = endpoint.create_consumer(rt()).unwrap();
4142
4143 let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4144 let token = tokio_util::sync::CancellationToken::new();
4145 let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4146
4147 tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4148 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4149
4150 let client = reqwest::Client::new();
4151
4152 let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
4156 Ok(Bytes::from("y".repeat(50))),
4157 Ok(Bytes::from("y".repeat(50))),
4158 ];
4159 let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
4160 let send_fut = client
4161 .post(format!("http://127.0.0.1:{port}/upload"))
4162 .body(stream_body)
4163 .send();
4164
4165 let consumer_fut = async {
4166 match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
4168 Ok(Some(mut envelope)) => {
4169 assert!(
4170 matches!(envelope.exchange.input.body, Body::Stream(_)),
4171 "expected Body::Stream"
4172 );
4173 envelope.exchange.input.body = camel_component_api::Body::Empty;
4174 if let Some(reply_tx) = envelope.reply_tx {
4175 let _ = reply_tx.send(Ok(envelope.exchange));
4176 }
4177 }
4178 Ok(None) => panic!("consumer channel closed unexpectedly"),
4179 Err(_) => {
4180 }
4183 }
4184 };
4185
4186 let (http_result, _) = tokio::join!(send_fut, consumer_fut);
4187
4188 let resp = http_result.unwrap();
4189 assert_ne!(
4191 resp.status().as_u16(),
4192 413,
4193 "chunked upload must not be rejected by maxRequestBody"
4194 );
4195 assert_eq!(resp.status().as_u16(), 200);
4196
4197 token.cancel();
4198 }
4199
4200 #[test]
4201 fn test_validate_url_for_ssrf_blocks_and_allows_hosts() {
4202 let mut cfg = HttpEndpointConfig::from_uri("http://example.com").unwrap();
4203 cfg.blocked_hosts = vec!["blocked.local".to_string()];
4204 cfg.allow_private_ips = false;
4205
4206 let blocked = validate_url_for_ssrf("http://blocked.local/api", &cfg);
4207 assert!(blocked.is_err());
4208
4209 let private_ip = validate_url_for_ssrf("http://127.0.0.1/api", &cfg);
4210 assert!(private_ip.is_err());
4211
4212 cfg.allow_private_ips = true;
4213 let allowed = validate_url_for_ssrf("http://127.0.0.1/api", &cfg);
4214 assert!(allowed.is_ok());
4215 }
4216
4217 #[test]
4218 fn test_is_private_ip_ranges() {
4219 assert!(is_private_ip(&"10.0.0.1".parse().unwrap())); assert!(is_private_ip(&"172.16.1.10".parse().unwrap())); assert!(is_private_ip(&"192.168.1.1".parse().unwrap())); assert!(is_private_ip(&"127.0.0.1".parse().unwrap())); assert!(is_private_ip(&"169.254.1.1".parse().unwrap())); assert!(is_private_ip(&"0.1.2.3".parse().unwrap())); assert!(is_private_ip(&"::1".parse().unwrap())); assert!(is_private_ip(&"fc00::1".parse().unwrap())); assert!(is_private_ip(&"fd12::1".parse().unwrap())); assert!(is_private_ip(&"fe80::1".parse().unwrap())); assert!(is_private_ip(&"::ffff:10.0.0.1".parse().unwrap())); assert!(is_private_ip(&"::ffff:192.168.1.1".parse().unwrap())); assert!(is_private_ip(&"::ffff:127.0.0.1".parse().unwrap())); assert!(!is_private_ip(&"8.8.8.8".parse().unwrap())); assert!(!is_private_ip(&"::ffff:8.8.8.8".parse().unwrap())); assert!(!is_private_ip(&"2001:4860:4860::8888".parse().unwrap())); }
4239
4240 #[tokio::test]
4241 async fn test_validate_resolved_host_for_ssrf_blocks_resolved_private_ip() {
4242 let mut cfg = HttpEndpointConfig::from_uri("http://example.com").unwrap(); cfg.allow_private_ips = false;
4244
4245 let err = validate_resolved_host_for_ssrf("http://localhost", &cfg)
4246 .await
4247 .expect_err("localhost must resolve to loopback and be blocked");
4248
4249 let msg = err.to_string();
4250 assert!(msg.contains("Target resolved to private IP"));
4251 }
4252
4253 #[test]
4254 fn test_title_case_header() {
4255 assert_eq!(title_case_header("content-type"), "Content-Type");
4256 assert_eq!(title_case_header("authorization"), "Authorization");
4257 assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
4258 assert_eq!(title_case_header("host"), "Host");
4259 assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
4260 assert_eq!(title_case_header("single"), "Single");
4261 assert_eq!(title_case_header(""), "");
4262 }
4263
4264 #[test]
4265 fn test_resolve_url_combines_path_and_query_sources() {
4266 let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
4267 let mut exchange = Exchange::new(Message::default());
4268 exchange.input.set_header(
4269 "CamelHttpPath",
4270 serde_json::Value::String("next".to_string()),
4271 );
4272 let url = HttpProducer::resolve_url(&exchange, &cfg);
4273 assert!(url.starts_with("http://example.com/base/next?"));
4274 assert!(url.contains("foo=bar"));
4275
4276 exchange.input.set_header(
4277 "CamelHttpUri",
4278 serde_json::Value::String("http://other.test/root".to_string()),
4279 );
4280 exchange.input.set_header(
4281 "CamelHttpQuery",
4282 serde_json::Value::String("a=1&b=2".to_string()),
4283 );
4284
4285 let override_url = HttpProducer::resolve_url(&exchange, &cfg);
4286 assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
4287 }
4288
4289 #[test]
4290 fn test_http_producer_helpers_status_and_size_boundaries() {
4291 assert!(HttpProducer::is_ok_status(200, (200, 299)));
4292 assert!(HttpProducer::is_ok_status(299, (200, 299)));
4293 assert!(!HttpProducer::is_ok_status(199, (200, 299)));
4294 assert!(!HttpProducer::is_ok_status(300, (200, 299)));
4295
4296 assert!(!exceeds_max_response_body(10, 10));
4297 assert!(exceeds_max_response_body(11, 10));
4298 }
4299
4300 async fn setup_consumer_on_free_port(
4305 path: &str,
4306 ) -> (
4307 u16,
4308 tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
4309 tokio_util::sync::CancellationToken,
4310 ) {
4311 use camel_component_api::ConsumerContext;
4312
4313 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4314 let port = listener.local_addr().unwrap().port();
4315 drop(listener);
4316
4317 let consumer_cfg = HttpServerConfig {
4318 host: "127.0.0.1".to_string(),
4319 port,
4320 path: path.to_string(),
4321 max_request_body: 2 * 1024 * 1024,
4322 max_response_body: 10 * 1024 * 1024,
4323 max_inflight_requests: 1024,
4324 };
4325 let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4326
4327 let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::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 (port, rx, token)
4335 }
4336
4337 #[tokio::test]
4338 async fn test_content_type_inferred_for_json_body() {
4339 let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
4340
4341 let client = reqwest::Client::new();
4342 let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
4343
4344 let (http_result, _) = tokio::join!(send_fut, async {
4345 if let Some(mut envelope) = rx.recv().await {
4346 envelope.exchange.input.body =
4347 camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
4348 if let Some(reply_tx) = envelope.reply_tx {
4349 let _ = reply_tx.send(Ok(envelope.exchange));
4350 }
4351 }
4352 });
4353
4354 let resp = http_result.unwrap();
4355 assert_eq!(resp.status().as_u16(), 200);
4356 let ct = resp
4357 .headers()
4358 .get("content-type")
4359 .expect("Content-Type header should be present");
4360 assert_eq!(ct, "application/json");
4361 let body = resp.text().await.unwrap();
4362 assert_eq!(body, r#"{"message":"hello"}"#);
4363
4364 token.cancel();
4365 }
4366
4367 #[tokio::test]
4368 async fn test_content_type_inferred_for_text_body() {
4369 let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
4370
4371 let client = reqwest::Client::new();
4372 let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
4373
4374 let (http_result, _) = tokio::join!(send_fut, async {
4375 if let Some(mut envelope) = rx.recv().await {
4376 envelope.exchange.input.body =
4377 camel_component_api::Body::Text("plain text response".to_string());
4378 if let Some(reply_tx) = envelope.reply_tx {
4379 let _ = reply_tx.send(Ok(envelope.exchange));
4380 }
4381 }
4382 });
4383
4384 let resp = http_result.unwrap();
4385 assert_eq!(resp.status().as_u16(), 200);
4386 let ct = resp
4387 .headers()
4388 .get("content-type")
4389 .expect("Content-Type header should be present");
4390 assert_eq!(ct, "text/plain; charset=utf-8");
4391 let body = resp.text().await.unwrap();
4392 assert_eq!(body, "plain text response");
4393
4394 token.cancel();
4395 }
4396
4397 #[tokio::test]
4398 async fn test_content_type_inferred_for_xml_body() {
4399 let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
4400
4401 let client = reqwest::Client::new();
4402 let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
4403
4404 let (http_result, _) = tokio::join!(send_fut, async {
4405 if let Some(mut envelope) = rx.recv().await {
4406 envelope.exchange.input.body =
4407 camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
4408 if let Some(reply_tx) = envelope.reply_tx {
4409 let _ = reply_tx.send(Ok(envelope.exchange));
4410 }
4411 }
4412 });
4413
4414 let resp = http_result.unwrap();
4415 assert_eq!(resp.status().as_u16(), 200);
4416 let ct = resp
4417 .headers()
4418 .get("content-type")
4419 .expect("Content-Type header should be present");
4420 assert_eq!(ct, "application/xml");
4421 let body = resp.text().await.unwrap();
4422 assert_eq!(body, "<root><item>value</item></root>");
4423
4424 token.cancel();
4425 }
4426
4427 #[tokio::test]
4428 async fn test_no_content_type_for_empty_body() {
4429 let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
4430
4431 let client = reqwest::Client::new();
4432 let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
4433
4434 let (http_result, _) = tokio::join!(send_fut, async {
4435 if let Some(mut envelope) = rx.recv().await {
4436 envelope.exchange.input.body = camel_component_api::Body::Empty;
4437 if let Some(reply_tx) = envelope.reply_tx {
4438 let _ = reply_tx.send(Ok(envelope.exchange));
4439 }
4440 }
4441 });
4442
4443 let resp = http_result.unwrap();
4444 assert_eq!(resp.status().as_u16(), 200);
4445 assert!(
4446 resp.headers().get("content-type").is_none(),
4447 "Empty body should not set Content-Type"
4448 );
4449
4450 token.cancel();
4451 }
4452
4453 #[tokio::test]
4454 async fn test_no_content_type_for_raw_bytes_body() {
4455 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
4456
4457 let client = reqwest::Client::new();
4458 let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
4459
4460 let (http_result, _) = tokio::join!(send_fut, async {
4461 if let Some(mut envelope) = rx.recv().await {
4462 envelope.exchange.input.body =
4463 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
4464 if let Some(reply_tx) = envelope.reply_tx {
4465 let _ = reply_tx.send(Ok(envelope.exchange));
4466 }
4467 }
4468 });
4469
4470 let resp = http_result.unwrap();
4471 assert_eq!(resp.status().as_u16(), 200);
4472 assert!(
4473 resp.headers().get("content-type").is_none(),
4474 "Raw Bytes body should not set Content-Type"
4475 );
4476
4477 token.cancel();
4478 }
4479
4480 #[tokio::test]
4481 async fn test_content_type_from_stream_metadata() {
4482 use camel_component_api::{StreamBody, StreamMetadata};
4483 use futures::stream;
4484
4485 let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
4486
4487 let client = reqwest::Client::new();
4488 let send_fut = client
4489 .get(format!("http://127.0.0.1:{port}/stream-ct"))
4490 .send();
4491
4492 let (http_result, _) = tokio::join!(send_fut, async {
4493 if let Some(mut envelope) = rx.recv().await {
4494 let chunks: Vec<Result<bytes::Bytes, CamelError>> =
4495 vec![Ok(bytes::Bytes::from("audio data"))];
4496 let stream = Box::pin(stream::iter(chunks));
4497 envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
4498 stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
4499 metadata: StreamMetadata {
4500 size_hint: None,
4501 content_type: Some("audio/mpeg".to_string()),
4502 origin: None,
4503 },
4504 });
4505 if let Some(reply_tx) = envelope.reply_tx {
4506 let _ = reply_tx.send(Ok(envelope.exchange));
4507 }
4508 }
4509 });
4510
4511 let resp = http_result.unwrap();
4512 assert_eq!(resp.status().as_u16(), 200);
4513 let ct = resp
4514 .headers()
4515 .get("content-type")
4516 .expect("Content-Type header should be present");
4517 assert_eq!(ct, "audio/mpeg");
4518 let body = resp.text().await.unwrap();
4519 assert_eq!(body, "audio data");
4520
4521 token.cancel();
4522 }
4523
4524 #[tokio::test]
4525 async fn test_user_content_type_overrides_inferred() {
4526 let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
4527
4528 let client = reqwest::Client::new();
4529 let send_fut = client
4530 .get(format!("http://127.0.0.1:{port}/override-ct"))
4531 .send();
4532
4533 let (http_result, _) = tokio::join!(send_fut, async {
4534 if let Some(mut envelope) = rx.recv().await {
4535 envelope.exchange.input.body =
4536 camel_component_api::Body::Json(serde_json::json!({"ok": true}));
4537 envelope.exchange.input.set_header(
4538 "Content-Type",
4539 serde_json::Value::String("text/html".to_string()),
4540 );
4541 if let Some(reply_tx) = envelope.reply_tx {
4542 let _ = reply_tx.send(Ok(envelope.exchange));
4543 }
4544 }
4545 });
4546
4547 let resp = http_result.unwrap();
4548 assert_eq!(resp.status().as_u16(), 200);
4549 let ct = resp
4550 .headers()
4551 .get("content-type")
4552 .expect("Content-Type header should be present");
4553 assert_eq!(
4554 ct, "text/html",
4555 "User-set Content-Type should take precedence over inferred type"
4556 );
4557
4558 token.cancel();
4559 }
4560
4561 #[tokio::test]
4562 async fn test_user_content_type_with_bytes_body() {
4563 let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
4564
4565 let client = reqwest::Client::new();
4566 let send_fut = client
4567 .get(format!("http://127.0.0.1:{port}/bytes-ct"))
4568 .send();
4569
4570 let (http_result, _) = tokio::join!(send_fut, async {
4571 if let Some(mut envelope) = rx.recv().await {
4572 envelope.exchange.input.body =
4573 camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
4574 envelope.exchange.input.set_header(
4575 "Content-Type",
4576 serde_json::Value::String("application/json".to_string()),
4577 );
4578 if let Some(reply_tx) = envelope.reply_tx {
4579 let _ = reply_tx.send(Ok(envelope.exchange));
4580 }
4581 }
4582 });
4583
4584 let resp = http_result.unwrap();
4585 assert_eq!(resp.status().as_u16(), 200);
4586 let ct = resp
4587 .headers()
4588 .get("content-type")
4589 .expect("Content-Type header should be present for Bytes body with user header");
4590 assert_eq!(
4591 ct, "application/json",
4592 "User Content-Type should be sent for Bytes body"
4593 );
4594
4595 token.cancel();
4596 }
4597
4598 #[tokio::test]
4603 async fn monitor_task_silent_on_clean_exit() {
4604 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
4605 monitor_axum_task(
4607 handle,
4608 "127.0.0.1:0".to_string(),
4609 noop_rt(),
4610 "test-monitor".into(),
4611 )
4612 .await;
4613 }
4614
4615 #[tokio::test]
4616 async fn monitor_task_handles_panicked_task() {
4617 let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
4618 panic!("simulated server crash");
4619 });
4620 monitor_axum_task(
4622 handle,
4623 "127.0.0.1:9999".to_string(),
4624 noop_rt(),
4625 "test-monitor".into(),
4626 )
4627 .await;
4628 }
4629
4630 #[test]
4635 fn http_auth_basic_debug_redacts_password() {
4636 let auth = HttpAuth::Basic {
4637 username: "admin".to_string(),
4638 password: "hunter2".to_string(),
4639 };
4640 let debug = format!("{:?}", auth);
4641 assert!(
4642 !debug.contains("hunter2"),
4643 "password must be redacted: {debug}"
4644 );
4645 assert!(debug.contains("admin"), "username should appear: {debug}");
4646 }
4647
4648 #[test]
4649 fn http_auth_bearer_debug_redacts_token() {
4650 let auth = HttpAuth::Bearer {
4651 token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
4652 };
4653 let debug = format!("{:?}", auth);
4654 assert!(
4655 !debug.contains("eyJhbGci"),
4656 "token must be redacted: {debug}"
4657 );
4658 }
4659
4660 #[test]
4661 fn http_auth_none_debug_shows_variant() {
4662 let debug = format!("{:?}", HttpAuth::None);
4663 assert!(
4664 debug.contains("None"),
4665 "None variant should appear: {debug}"
4666 );
4667 }
4668
4669 #[test]
4670 fn http_endpoint_config_debug_redacts_auth_credentials() {
4671 let config = HttpEndpointConfig::from_uri(
4672 "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
4673 )
4674 .unwrap();
4675 let debug = format!("{:?}", config);
4676 assert!(
4677 !debug.contains("secret123"),
4678 "password must be redacted in HttpEndpointConfig debug: {debug}"
4679 );
4680 }
4681
4682 use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
4687 use tower_http::services::ServeDir;
4688
4689 fn make_test_registry() -> HttpRouteRegistry {
4690 HttpRouteRegistry::new()
4691 }
4692
4693 fn make_test_state(registry: HttpRouteRegistry) -> AppState {
4694 AppState {
4695 registry,
4696 max_request_body: 2 * 1024 * 1024,
4697 max_response_body: 10 * 1024 * 1024,
4698 inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
4699 }
4700 }
4701
4702 #[allow(clippy::await_holding_lock)]
4703 #[tokio::test]
4704 async fn test_static_file_serving_serves_file_contents() {
4705 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4706 ServerRegistry::reset();
4707
4708 let temp_dir =
4710 std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
4711 std::fs::create_dir_all(&temp_dir).unwrap();
4712 std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
4713 std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
4714
4715 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
4716
4717 let registry = make_test_registry();
4718 let serve_dir = ServeDir::new(&canonical_dir)
4719 .precompressed_gzip()
4720 .precompressed_br()
4721 .append_index_html_on_directories(true);
4722
4723 let mount = StaticMount {
4724 mount_path: "/".to_string(),
4725 mode: MountMode::Static,
4726 dir: canonical_dir.clone(),
4727 cache_control: "public, max-age=3600".to_string(),
4728 error_pages: std::collections::HashMap::new(),
4729 serve_dir,
4730 };
4731 registry.register_static_mount(mount).await.unwrap();
4732
4733 let state = make_test_state(registry);
4734
4735 let req = Request::builder()
4737 .uri("/hello.txt")
4738 .body(AxumBody::empty())
4739 .unwrap();
4740 let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
4741 assert_eq!(resp.status(), StatusCode::OK);
4742 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4743 .await
4744 .unwrap();
4745 assert_eq!(&body[..], b"Hello, static world!");
4746
4747 let req = Request::builder()
4749 .uri("/style.css")
4750 .body(AxumBody::empty())
4751 .unwrap();
4752 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
4753 assert_eq!(resp.status(), StatusCode::OK);
4754 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4755 .await
4756 .unwrap();
4757 assert_eq!(&body[..], b"body { color: red; }");
4758
4759 let req = Request::builder()
4761 .uri("/missing.txt")
4762 .body(AxumBody::empty())
4763 .unwrap();
4764 let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
4765 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
4766
4767 std::fs::remove_dir_all(&temp_dir).ok();
4769 }
4770
4771 #[allow(clippy::await_holding_lock)]
4772 #[tokio::test]
4773 async fn test_spa_fallback_serves_index_for_unknown_paths() {
4774 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4775 ServerRegistry::reset();
4776
4777 let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
4778 std::fs::create_dir_all(&temp_dir).unwrap();
4779 std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
4780 std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
4781
4782 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
4783
4784 let registry = make_test_registry();
4785 let serve_dir = ServeDir::new(&canonical_dir)
4786 .precompressed_gzip()
4787 .precompressed_br()
4788 .append_index_html_on_directories(true);
4789
4790 let mount = StaticMount {
4791 mount_path: "/".to_string(),
4792 mode: MountMode::Spa,
4793 dir: canonical_dir.clone(),
4794 cache_control: "public, max-age=0".to_string(),
4795 error_pages: std::collections::HashMap::new(),
4796 serve_dir,
4797 };
4798 registry.register_static_mount(mount).await.unwrap();
4800
4801 let state = make_test_state(registry);
4802
4803 let req = Request::builder()
4805 .method("GET")
4806 .uri("/dashboard")
4807 .header("Accept", "text/html")
4808 .body(AxumBody::empty())
4809 .unwrap();
4810 let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
4811 assert_eq!(resp.status(), StatusCode::OK);
4812 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4813 .await
4814 .unwrap();
4815 assert_eq!(&body[..], b"<h1>SPA App</h1>");
4816
4817 let req = Request::builder()
4819 .method("GET")
4820 .uri("/app.js")
4821 .body(AxumBody::empty())
4822 .unwrap();
4823 let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
4824 assert_eq!(resp.status(), StatusCode::OK);
4825 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4826 .await
4827 .unwrap();
4828 assert_eq!(&body[..], b"console.log('app')");
4829
4830 let req = Request::builder()
4832 .method("GET")
4833 .uri("/api/data")
4834 .header("Accept", "application/json")
4835 .body(AxumBody::empty())
4836 .unwrap();
4837 let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
4838 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
4839
4840 let req = Request::builder()
4842 .method("GET")
4843 .uri("/style.css")
4844 .header("Accept", "text/html")
4845 .body(AxumBody::empty())
4846 .unwrap();
4847 let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
4848 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
4849
4850 std::fs::remove_dir_all(&temp_dir).ok();
4852 }
4853
4854 #[allow(clippy::await_holding_lock)]
4855 #[tokio::test]
4856 async fn test_error_page_mapping_serves_custom_404() {
4857 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4858 ServerRegistry::reset();
4859
4860 let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
4861 let errors_dir = temp_dir.join("errors");
4862 std::fs::create_dir_all(&errors_dir).unwrap();
4863 std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
4864 std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
4865
4866 let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
4867 let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
4868
4869 let registry = make_test_registry();
4870 let serve_dir = ServeDir::new(&canonical_dir)
4871 .precompressed_gzip()
4872 .precompressed_br()
4873 .append_index_html_on_directories(true);
4874
4875 let mut error_pages = std::collections::HashMap::new();
4876 error_pages.insert(404, canonical_404);
4877
4878 let mount = StaticMount {
4879 mount_path: "/".to_string(),
4880 mode: MountMode::Static,
4881 dir: canonical_dir.clone(),
4882 cache_control: "public, max-age=0".to_string(),
4883 error_pages,
4884 serve_dir,
4885 };
4886 registry.register_static_mount(mount).await.unwrap();
4887
4888 let state = make_test_state(registry);
4889
4890 let req = Request::builder()
4892 .method("GET")
4893 .uri("/missing.html")
4894 .body(AxumBody::empty())
4895 .unwrap();
4896 let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
4897 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
4898 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4899 .await
4900 .unwrap();
4901 assert_eq!(&body[..], b"<h1>Custom 404</h1>");
4902
4903 let req = Request::builder()
4905 .method("GET")
4906 .uri("/index.html")
4907 .body(AxumBody::empty())
4908 .unwrap();
4909 let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
4910 assert_eq!(resp.status(), StatusCode::OK);
4911 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4912 .await
4913 .unwrap();
4914 assert_eq!(&body[..], b"<h1>Home</h1>");
4915
4916 std::fs::remove_dir_all(&temp_dir).ok();
4918 }
4919
4920 #[tokio::test]
4921 async fn http_consumer_returns_body_and_code_on_stop() {
4922 use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
4923 use camel_core::route::{CompiledStep, compose_pipeline_with_handler};
4924 use tower::ServiceExt;
4925
4926 let set_body_step = CompiledStep::Process {
4928 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
4929 ex.input.body = Body::Text("nope".into());
4930 Box::pin(async move { Ok(ex) })
4931 }),
4932 body_contract: None,
4933 };
4934 let set_status_step = CompiledStep::Process {
4935 processor: BoxProcessor::from_fn(|mut ex: Exchange| {
4936 ex.input.set_header(
4937 "CamelHttpResponseCode",
4938 serde_json::Value::Number(409.into()),
4939 );
4940 Box::pin(async move { Ok(ex) })
4941 }),
4942 body_contract: None,
4943 };
4944 let pipeline = compose_pipeline_with_handler(
4945 vec![set_body_step, set_status_step, CompiledStep::Stop],
4946 None,
4947 );
4948
4949 let ex = Exchange::new(Message::default());
4950 let result = pipeline.oneshot(ex).await;
4951 assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
4952 let returned = result.unwrap();
4953 assert_eq!(returned.input.body.as_text(), Some("nope"));
4954 assert_eq!(
4955 returned
4956 .input
4957 .header("CamelHttpResponseCode")
4958 .and_then(|v| v.as_u64()),
4959 Some(409)
4960 );
4961 }
4962
4963 #[tokio::test]
4964 async fn http_consumer_returns_200_when_body_empty_on_stop() {
4965 use camel_api::{Exchange, Message};
4973 use camel_core::route::{CompiledStep, compose_pipeline_with_handler};
4974 use tower::ServiceExt;
4975
4976 let pipeline = compose_pipeline_with_handler(vec![CompiledStep::Stop], None);
4977 let ex = Exchange::new(Message::default());
4978 let result = pipeline.oneshot(ex).await;
4979 assert!(result.is_ok(), "Stop with empty body arrives as Ok");
4980 }
4983}