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